/* ============================================================================= * M02_SO_Def_Check.ts * 구조물 표 검사(PLAN 322-2 · 1-3 규칙) — 정의 `반영` · `부품` 꼴 · 표 줄과 부품이 맞음 · 만든 뒤 고정. * * · 반영 = `{종류: {목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D}}` · 값 = 부품 상자에 있는 것 * · 부품 = `{"갈래.부품": false}`(끈 것만) * · 표 줄(정의 ⊕ 처음값) — 형상 none 이면 횡단도도 none(218) · 부품 자리꼴 · 정본 = 그 종류 줄 · * 부품이 읽는 칸 중 기본값 없는 칸(비워 두는 뜻 칸 빼고)은 새로 숨기지 못함(정의 「표시」 · 숨기면 * 그림이 늘 기본값을 씀 · 앞 판에서 이미 숨긴 칸은 그대로 둠) * · 앞 판이 없으면(옛 판 읽기) 꼴만 검사 * · 앞 판과 비교 — 종류 지우기 없음 · 자리꼴 · 정본 · 군 글자 고정 · 선택지 값 고정(이름은 이름 표로) * 서버 짝 = `M02_SO_Check.py`(같은 글) · 거울 시험 = `resources/tester/test_322_2_check_mirror.py`. * ========================================================================== */ import PARTS from "../../common_util/common_util_structure_parts.json" with { type: "json" }; import { resolveCatalog, type CatalogDefinition, } from "../../common_util/common_util_structure_catalog"; import { OWNER_KO, PLACE_KO, shortName } from "./M02_SO_Def_Reflect"; type Branch = "횡단도" | "카드이름" | "형상" | "3D"; interface Part { 이름: string; "읽는 칸": string[]; 자리꼴: string[]; 정본: string[]; } const PARTS_BY = (PARTS as unknown as { parts: Record> }).parts; export const BRANCHES: readonly Branch[] = ["횡단도", "카드이름", "형상", "3D"]; /** 갈래마다 「없음」 값 — 카드이름은 종류 이름(name). */ export const OFF_VALUE: Record = { 횡단도: "none", 카드이름: "name", 형상: "none", "3D": "none", }; export const LISTS = ["both", "b06", "hidden"] as const; const FIELDS = new Set(["목록", "형상칸", ...BRANCHES]); /** 검사가 읽는 정의 칸. */ export interface CheckDef extends CatalogDefinition { types?: ReadonlyArray<{ type_id: string; name?: string; group?: string; placement?: string; managed_by?: string | null; options?: ReadonlyArray<{ key: string; input?: string; choices?: readonly string[]; default?: unknown; empty_means?: string | null; label?: string; }>; }>; 표시?: Record>; } const isObject = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); /** 부품 상자에 있는 부품 — 제 칸만(물려받은 이름 아님). */ const partOf = (branch: string, id: string): Part | undefined => { const box = (PARTS_BY as Record>)[branch]; return box && Object.hasOwn(box, id) ? box[id] : undefined; }; /** 반영 · 부품 꼴. */ function shapeErrors(doc: CheckDef, known: Set): string[] { const out: string[] = []; const reflect: unknown = doc.반영 ?? {}; if (!isObject(reflect)) out.push("반영은 {종류: 묶음} 이어야 함"); else for (const [tid, row] of Object.entries(reflect)) { if (!known.has(tid) || !isObject(row)) { out.push(`반영 「${tid}」 — 없는 종류이거나 묶음 아님`); continue; } for (const [field, value] of Object.entries(row)) { const where = `반영 「${tid}.${field}」`; if (!FIELDS.has(field)) out.push(`${where} — 모르는 칸`); else if (field === "목록") { if (!(LISTS as readonly unknown[]).includes(value)) out.push(`${where} — both · b06 · hidden 중 하나`); } else if (field === "형상칸") { const ok = isObject(value) && Object.entries(value).every(([k, v]) => k === "form" ? typeof v === "string" && !!v.trim() : k === "weep" && typeof v === "boolean", ); if (!ok) out.push(`${where} — form 글 · weep 참거짓`); } else { const branch = field as Branch; if (typeof value !== "string" || (value !== OFF_VALUE[branch] && !partOf(branch, value))) out.push(`${where} — 없는 부품 「${String(value)}」`); } } } const parts: unknown = doc.부품 ?? {}; if (!isObject(parts)) out.push("부품은 {갈래.부품: 참거짓} 이어야 함"); else for (const [key, value] of Object.entries(parts)) { const at = key.indexOf("."); if (at < 0 || !partOf(key.slice(0, at), key.slice(at + 1))) out.push(`부품 「${key}」 — 없는 부품`); else if (typeof value !== "boolean") out.push(`부품 「${key}」 — 참거짓`); } return out; } const hiddenIn = (doc: CheckDef | null, tid: string): string[] => Object.entries(doc?.표시?.[tid] ?? {}) .filter(([, shown]) => shown === false) .map(([key]) => key); /** 표 줄 — 218 규칙 · 부품 자리꼴 · 정본 · 읽는 칸 보임(새로 숨김만). */ function rowErrors(doc: CheckDef, before: CheckDef | null): string[] { const out: string[] = []; const rows = resolveCatalog(doc); const raw = resolveCatalog({ ...doc, 부품: {} }); // 부품 끄기 전 — 끈 탓 가름(323-9) const offUsers = new Map(); // 끈 형상 부품 → 횡단도를 바꿔야 하는 종류 for (const t of doc.types ?? []) { const row = rows.get(t.type_id); if (!row) continue; if (row.shape === "none" && row.station !== "none") { const was = raw.get(t.type_id)?.shape ?? "none"; if (was !== "none" && doc.부품?.[`형상.${was}`] === false) offUsers.set(was, [...(offUsers.get(was) ?? []), `${row.groupName} · ${row.name}`]); else out.push(`「${row.name}」 형상이 「없음」이면 횡단도도 「안 만듦」`); } // 앞 판 없음 = 새로 숨긴 것 없음 · 복사한 종류 = 본보기가 앞 판에서 숨긴 칸도 그대로(323-7) const kept = [t.type_id, row.base].flatMap((id) => hiddenIn(before ?? doc, id)); const hidden = hiddenIn(doc, t.type_id).filter((key) => !kept.includes(key)); // 숨기면 그림이 기본값을 씀 — 기본값도 비워 두는 뜻도 없는 칸만 못 숨김 const keys = new Set( (t.options ?? []) .filter((o) => (o.default === null || o.default === undefined) && !o.empty_means) .map((o) => o.key), ); const labelOf = (key: string): string => (t.options ?? []).find((o) => o.key === key)?.label ?? key; const used: Array<[Branch, string]> = [ ["횡단도", row.station], ["카드이름", row.title], ["형상", row.shape], ["3D", row.model3d], ]; for (const [branch, id] of used) { const part = partOf(branch, id); if (!part) continue; const name = shortName(part.이름); const place = PLACE_KO[row.placement] ?? row.placement; const owner = OWNER_KO[row.owner] ?? row.owner; if (!part.자리꼴.includes(row.placement) || !part.정본.includes(row.owner)) out.push(`「${row.name}」 ${branch} 「${name}」 — ${place} · ${owner}에 안 맞음`); for (const key of part["읽는 칸"]) if (keys.has(key) && hidden.includes(key)) out.push( `「${row.name}」 ${branch} 「${name}」 이 쓰는 칸 「${labelOf(key)}」 — 숨길 수 없음`, ); } } offUsers.forEach((names, id) => out.push( `부품 「${shortName(partOf("형상", id)?.이름 ?? id)}」 끔 — 이 부품을 쓰는 종류(${names.join(", ")})의 ` + "횡단도를 「안 만듦」으로 바꿔야 저장", ), ); return out; } /** 앞 판과 비교 — 만든 뒤 고정. */ /** 본보기(복사한 종류 · 323-7) — 원본(구조물 정본 · 본보기 아닌 종류) · 같은 군 · 자리꼴 · 정본(파이썬 짝). */ function templateErrors(doc: CheckDef): string[] { const out: string[] = []; const kinds = doc.종류 ?? {}; const now = new Map((doc.types ?? []).map((t) => [t.type_id, t])); for (const [tid, row] of Object.entries(kinds)) { const src = row?.본보기; const t = now.get(tid); if (src == null || !t) continue; const base = now.get(src); const name = t.name || tid; if (!base || src === tid || kinds[src]?.본보기) { out.push(`「${name}」 본보기 「${src}」 — 원본 종류여야 함`); continue; } const same = (["group", "placement", "managed_by"] as const).every( (k) => (t[k] ?? null) === (base[k] ?? null), ) && !base.managed_by; if (!same) out.push(`「${name}」 본보기 「${base.name || src}」 와 군 · 자리꼴 · 정본이 같아야 함`); } return out; } function fixedErrors(doc: CheckDef, before: CheckDef): string[] { const out: string[] = []; const now = new Map((doc.types ?? []).map((t) => [t.type_id, t])); const baseOf = (d: CheckDef, tid: string) => d.종류?.[tid]?.본보기 ?? null; for (const old of before.types ?? []) { const t = now.get(old.type_id); const name = old.name ?? old.type_id; if (!t) { out.push(`종류 「${name}」 — 지울 수 없음`); continue; } const fixed: Array<[string, unknown, unknown]> = [ ["자리꼴", old.placement, t.placement], ["정본", old.managed_by ?? null, t.managed_by ?? null], ["군 글자", old.group, t.group], ["본보기", baseOf(before, old.type_id), baseOf(doc, old.type_id)], ]; for (const [what, a, b] of fixed) if (a !== b) out.push(`「${name}」 ${what} — 만든 뒤 고정`); const options = new Map((t.options ?? []).map((o) => [o.key, o])); for (const o of old.options ?? []) { const n = options.get(o.key); if (o.input !== "select" || !n) continue; const a = [...(o.choices ?? [])].sort().join("\u0000"); const b = [...(n.choices ?? [])].sort().join("\u0000"); if (a !== b) out.push(`「${name}」 「${o.label ?? o.key}」 선택지 값 — 고칠 수 없음(이름만 고침)`); } } return out; } /** 구조물 표 검사 — 틀린 것 글들(빈 목록 = 통과). `before` = 지금 서버 판(없으면 고정 검사 안 함). */ export function catalogProblems(doc: CheckDef, before: CheckDef | null): string[] { const known = new Set((doc.types ?? []).map((t) => t.type_id)); const shape = shapeErrors(doc, known); if (shape.length) return shape; const templates = templateErrors(doc); return [ ...templates, ...(before ? [...rowErrors(doc, before), ...fixedErrors(doc, before)] : []), ]; }