Files
Aislo/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts
T
eomsangdonandClaude Opus 5.5 dd35589079 fix(구조물표): 323-9 ④ 작은 것 — 검사 글 띄어쓰기 「… · 구조물에 안 맞음」(화면 · 서버 짝) · 노선 끝(EP)처럼 같은 m 카드 둘이면 3D 관 · 세월교 · BOX 몸체를 한 번만(앞 카드 · ownsBodies)
- M02_SO_Def_Check.ts ↔ M02_SO_Check.py 글 · test_322_2_check_mirror 기대 글
- B05 3D: buildCorridorStructures 관 몸체 = 같은 m 앞 카드만 · 벽은 그대로(wallSweepAt)
- 시험: test_323_9_twin_cards_3d(세월교 · BOX 카드 둘 = 하나와 같음 · 고치기 전 두 배)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 21:13:36 +09:00

244 lines
11 KiB
TypeScript

/* =============================================================================
* 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<Branch, Record<string, Part>> }).parts;
export const BRANCHES: readonly Branch[] = ["횡단도", "카드이름", "형상", "3D"];
/** 갈래마다 「없음」 값 — 카드이름은 종류 이름(name). */
export const OFF_VALUE: Record<Branch, string> = {
횡단도: "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<string, Record<string, boolean>>;
}
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
/** 부품 상자에 있는 부품 — 제 칸만(물려받은 이름 아님). */
const partOf = (branch: string, id: string): Part | undefined => {
const box = (PARTS_BY as Record<string, Record<string, Part>>)[branch];
return box && Object.hasOwn(box, id) ? box[id] : undefined;
};
/** 반영 · 부품 꼴. */
function shapeErrors(doc: CheckDef, known: Set<string>): 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<string, string[]>(); // 끈 형상 부품 → 횡단도를 바꿔야 하는 종류
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)] : []),
];
}