- 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
244 lines
11 KiB
TypeScript
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)] : []),
|
|
];
|
|
}
|