feat: zod를 사용하여 외부 JSON 데이터 입력 검증 추가 및 스키마 리팩토링

This commit is contained in:
2025-12-28 14:53:52 +00:00
parent ed9ee671a7
commit 174c171e10
2 changed files with 34 additions and 36 deletions
+1
View File
@@ -40,6 +40,7 @@ monorepo plan is prepared alongside it.
## Cross-Cutting Policies ## Cross-Cutting Policies
- No ad-hoc randomness for gameplay; use deterministic RNG - No ad-hoc randomness for gameplay; use deterministic RNG
- External JSON/data inputs must be validated with zod; name zod schemas with a `z` prefix.
- Keep domain logic independent of endpoints or UI - Keep domain logic independent of endpoints or UI
- Prefer clear Korean comments in core gameplay logic for maintainers - Prefer clear Korean comments in core gameplay logic for maintainers
- Test strategy and layering: `docs/testing-policy.md` - Test strategy and layering: `docs/testing-policy.md`
+33 -36
View File
@@ -26,6 +26,12 @@ const FALLBACK_STAT: ScenarioStatBlock = {
const isRecord = (value: unknown): value is UnknownRecord => const isRecord = (value: unknown): value is UnknownRecord =>
typeof value === 'object' && value !== null && !Array.isArray(value); typeof value === 'object' && value !== null && !Array.isArray(value);
const toRecordOrUndefined = (value: unknown): UnknownRecord | undefined =>
isRecord(value) ? value : undefined;
const toArrayOrUndefined = (value: unknown): unknown[] | undefined =>
Array.isArray(value) ? value : undefined;
const asNumber = (value: unknown, fallback: number): number => const asNumber = (value: unknown, fallback: number): number =>
typeof value === 'number' ? value : fallback; typeof value === 'number' ? value : fallback;
@@ -43,9 +49,11 @@ const asStringArray = (value: unknown): string[] =>
? value.filter((item): item is string => typeof item === 'string') ? value.filter((item): item is string => typeof item === 'string')
: []; : [];
const recordSchema = z.record(z.string(), z.unknown()); const zRecord = z.record(z.string(), z.unknown());
const rowArraySchema = z.array(z.unknown()); const zUnknownArray = z.array(z.unknown());
const statSchema = z const zOptionalRecord = z.preprocess(toRecordOrUndefined, zRecord.optional());
const zOptionalArray = z.preprocess(toArrayOrUndefined, zUnknownArray.optional());
const zStatInput = z
.object({ .object({
total: z.number().optional(), total: z.number().optional(),
min: z.number().optional(), min: z.number().optional(),
@@ -57,44 +65,33 @@ const statSchema = z
}) })
.partial(); .partial();
const mapSchema = z.preprocess( const zScenarioDefaults = z
(value) => (isRecord(value) ? value : undefined),
recordSchema.optional()
);
const scenarioDefaultsSchema = z
.object({ .object({
stat: z.preprocess( stat: z.preprocess(toRecordOrUndefined, zStatInput.optional()),
(value) => (isRecord(value) ? value : undefined),
statSchema.optional()
),
iconPath: z.string().optional(), iconPath: z.string().optional(),
}) })
.passthrough(); .passthrough();
const scenarioSchema = z const zScenarioInput = z
.object({ .object({
title: z.string(), title: z.string(),
startYear: z.number().optional(), startYear: z.number().optional(),
life: z.number().optional(), life: z.number().optional(),
fiction: z.number().optional(), fiction: z.number().optional(),
history: z.array(z.string()).optional(), history: zOptionalArray,
iconPath: z.string().optional(), iconPath: z.string().optional(),
stat: z.preprocess( stat: z.preprocess(toRecordOrUndefined, zStatInput.optional()),
(value) => (isRecord(value) ? value : undefined), map: zOptionalRecord,
statSchema.optional() const: zOptionalRecord,
), nation: zOptionalArray,
map: mapSchema, diplomacy: zOptionalArray,
const: mapSchema, general: zOptionalArray,
nation: z.array(rowArraySchema).optional(), general_ex: zOptionalArray,
diplomacy: z.array(rowArraySchema).optional(), general_neutral: zOptionalArray,
general: z.array(rowArraySchema).optional(), cities: zOptionalArray,
general_ex: z.array(rowArraySchema).optional(), events: zOptionalArray,
general_neutral: z.array(rowArraySchema).optional(), initialEvents: zOptionalArray,
cities: z.array(z.unknown()).optional(), initialActions: zOptionalArray,
events: z.array(z.unknown()).optional(),
initialEvents: z.array(z.unknown()).optional(),
initialActions: z.array(z.unknown()).optional(),
ignoreDefaultEvents: z.boolean().optional(), ignoreDefaultEvents: z.boolean().optional(),
}) })
.passthrough(); .passthrough();
@@ -131,7 +128,7 @@ const parseScenarioEnvironment = (
}; };
const parseNationRow = (row: unknown, index: number): ScenarioNation => { const parseNationRow = (row: unknown, index: number): ScenarioNation => {
const parsed = rowArraySchema.safeParse(row); const parsed = zUnknownArray.safeParse(row);
if (!parsed.success) { if (!parsed.success) {
throw new Error(`Scenario nation row ${index} is not an array.`); throw new Error(`Scenario nation row ${index} is not an array.`);
} }
@@ -167,7 +164,7 @@ const parseNationRow = (row: unknown, index: number): ScenarioNation => {
}; };
const parseDiplomacyRow = (row: unknown, index: number): ScenarioDiplomacy => { const parseDiplomacyRow = (row: unknown, index: number): ScenarioDiplomacy => {
const parsed = rowArraySchema.safeParse(row); const parsed = zUnknownArray.safeParse(row);
if (!parsed.success) { if (!parsed.success) {
throw new Error(`Scenario diplomacy row ${index} is not an array.`); throw new Error(`Scenario diplomacy row ${index} is not an array.`);
} }
@@ -185,7 +182,7 @@ const parseGeneralRow = (
index: number, index: number,
label: string label: string
): ScenarioGeneral => { ): ScenarioGeneral => {
const parsed = rowArraySchema.safeParse(row); const parsed = zUnknownArray.safeParse(row);
if (!parsed.success) { if (!parsed.success) {
throw new Error(`Scenario ${label} row ${index} is not an array.`); throw new Error(`Scenario ${label} row ${index} is not an array.`);
} }
@@ -250,7 +247,7 @@ const parseDiplomacyRows = (rows: unknown[]): ScenarioDiplomacy[] =>
export const parseScenarioDefaults = (raw: unknown): ScenarioDefaults => { export const parseScenarioDefaults = (raw: unknown): ScenarioDefaults => {
// 기본 시나리오 설정값을 안전하게 읽는다. // 기본 시나리오 설정값을 안전하게 읽는다.
const data = scenarioDefaultsSchema.parse(raw); const data = zScenarioDefaults.parse(raw);
const stat = parseScenarioStatBlock(data.stat, FALLBACK_STAT); const stat = parseScenarioStatBlock(data.stat, FALLBACK_STAT);
const iconPath = asString(data.iconPath, '.'); const iconPath = asString(data.iconPath, '.');
return { stat, iconPath }; return { stat, iconPath };
@@ -261,7 +258,7 @@ export const parseScenarioDefinition = (
defaults: ScenarioDefaults defaults: ScenarioDefaults
): ScenarioDefinition => { ): ScenarioDefinition => {
// 시나리오 JSON을 런타임에서 쓰는 구조로 정규화한다. // 시나리오 JSON을 런타임에서 쓰는 구조로 정규화한다.
const data = scenarioSchema.parse(raw); const data = zScenarioInput.parse(raw);
const stat = parseScenarioStatBlock(data.stat, defaults.stat); const stat = parseScenarioStatBlock(data.stat, defaults.stat);
const mapConfig = data.map ?? {}; const mapConfig = data.map ?? {};
const constConfig = data.const ?? {}; const constConfig = data.const ?? {};
@@ -278,7 +275,7 @@ export const parseScenarioDefinition = (
typeof data.startYear === 'number' ? data.startYear : null; typeof data.startYear === 'number' ? data.startYear : null;
const life = typeof data.life === 'number' ? data.life : null; const life = typeof data.life === 'number' ? data.life : null;
const fiction = typeof data.fiction === 'number' ? data.fiction : null; const fiction = typeof data.fiction === 'number' ? data.fiction : null;
const history = data.history ?? []; const history = asStringArray(data.history);
const ignoreDefaultEvents = Boolean(data.ignoreDefaultEvents); const ignoreDefaultEvents = Boolean(data.ignoreDefaultEvents);
const nations = parseNationRows(data.nation ?? []); const nations = parseNationRows(data.nation ?? []);
const diplomacy = parseDiplomacyRows(data.diplomacy ?? []); const diplomacy = parseDiplomacyRows(data.diplomacy ?? []);