feat: zod를 사용하여 외부 JSON 데이터 입력 검증 추가 및 스키마 리팩토링
This commit is contained in:
@@ -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`
|
||||||
|
|||||||
@@ -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 ?? []);
|
||||||
|
|||||||
Reference in New Issue
Block a user