Files
Aislo/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts
T
eomsangdonandClaude Opus 5.5 8ca2f1cd6e feat(M02): 48-2 구조물 옵션 정의 편집 화면 — 군 · 종류 · 옵션 · 선택지 · 배치 격자 · 그룹 형상 · 규칙 · 판 이력
- 옛 「옵션 보이기 · 그룹」 화면을 정의 편집 화면으로 넓힘(시스템 관리자 고쳐 씀 · 다른 역할 읽기만)
- 자료 손질 = M02_SO_Def_Model.ts(정의 틀 48-1 꼴) · 시험 test_m02_so_def_model.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 16:15:10 +09:00

269 lines
10 KiB
TypeScript

/* =============================================================================
* M02_SO_Def_Model.ts
* 구조물 정의 편집 화면(PLAN 48-2)의 자료 꼴 · 순수 손질 함수 — 화면 그리기 없음.
* 정본 = 마스터 템플릿 `구조물정의.json`(48-1 · 틀 `docs/raw/M02_구조물옵션정의/정의_틀.md`).
* 키는 만들 때 자동 · 안 바뀜 · 이름 자유 · 지움 = 「안 씀」(`enabled: false`).
* 선택지는 글 그대로가 값 — 이름을 바꾸면 이 정의 안의 기본값 · 규칙도 같이 바꿈.
* ========================================================================== */
export type OptionForm = "select" | "number" | "text" | "bool";
export interface Spot {
줄: number;
칸: number;
너비: number;
}
export interface OptionDef {
key: string;
label: string;
input: OptionForm;
choices?: string[];
unit?: string | null;
default?: unknown;
enabled?: boolean;
phase?: "b05" | "detail";
[more: string]: unknown;
}
export interface TypeDef {
type_id: string;
group: string;
name: string;
options: OptionDef[];
enabled?: boolean;
[more: string]: unknown;
}
export type ShapeKey = "길이" | "높이" | "전" | "후";
export const SHAPE_KEYS: readonly ShapeKey[] = ["길이", "높이", "전", "후"];
export interface GroupDef {
id: string;
이름: string;
옵션: string[];
형상?: Partial<Record<ShapeKey, { 받기: boolean; 뜻: string }>>;
}
/** 만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값(하나 이상) */
export interface RuleDef {
종류: string;
만약: { 옵션: string; 값들: string[] };
그러면: { 옵션: string; 선택지?: string[]; 보이기?: boolean; 기본값?: unknown };
}
export interface StructDef {
판: number;
구조물군: Record<string, string>;
동작: Record<string, string>;
types: TypeDef[];
종류: Record<string, { 기준측점?: boolean; 동작?: string[] }>;
표시: Record<string, Record<string, false>>;
그룹: Record<string, GroupDef[]>;
배치: Record<string, Record<string, Spot>>;
규칙: RuleDef[];
[more: string]: unknown;
}
export const NAME_MAX = 40;
/** B05 · B06 좌측 구조물 칸 = 두 칸 격자 */
export const GRID_COLS = 2;
/** 깊은 사본 — 고칠 사본을 서버 문서와 떼어 놓음 */
export const clone = <T>(value: T): T => structuredClone(value);
/** 새 키 — 머리글 + 영문 소문자 · 숫자 6자 · 이미 있는 키와 안 겹침 */
export function newKey(prefix: string, taken: Iterable<string>): string {
const used = new Set(taken);
for (;;) {
const key = `${prefix}${Math.random().toString(36).slice(2, 8)}`;
if (key.length > prefix.length + 3 && !used.has(key)) return key;
}
}
/** 목록 안에서 한 칸 옮김 — 끝이면 그대로 · 옮겼으면 참 */
export function step<T>(list: T[], index: number, delta: number): boolean {
const at = index + delta;
if (index < 0 || at < 0 || at >= list.length) return false;
[list[index], list[at]] = [list[at], list[index]];
return true;
}
/** 쓰는 것만 — 「안 씀」(enabled 거짓) 뺌 */
export const live = <T extends { enabled?: boolean }>(list: readonly T[]): T[] =>
list.filter((x) => x.enabled !== false);
/** 기준 측점 칸 보이기 — 없으면 보임 */
export const baseShown = (def: StructDef, typeId: string): boolean =>
def.종류[typeId]?.기준측점 !== false;
/** 이름 검사 — 1~40자 · 같은 목록 안에서 안 겹침 · 틀린 이름들 */
export function badNames(names: readonly string[]): string[] {
const seen = new Set<string>();
const bad: string[] = [];
for (const raw of names) {
const name = raw.trim();
if (!name || name.length > NAME_MAX || seen.has(name)) bad.push(name || "(빈 이름)");
seen.add(name);
}
return bad;
}
/** 구조물군 순서 바꿈 — `{키: 이름}` 묶음의 키 차례 */
export function stepFamily(def: StructDef, key: string, delta: number): boolean {
const entries = Object.entries(def.구조물군);
if (
!step(
entries,
entries.findIndex(([k]) => k === key),
delta,
)
)
return false;
def.구조물군 = Object.fromEntries(entries);
return true;
}
/** 종류 순서 바꿈 — 같은 군 안 앞뒤 종류와 `types` 자리를 바꿈 */
export function stepType(def: StructDef, typeId: string, delta: number): boolean {
const t = def.types.find((x) => x.type_id === typeId);
if (!t) return false;
const same = def.types.filter((x) => x.group === t.group);
const other = same[same.indexOf(t) + delta];
if (!other) return false;
const i = def.types.indexOf(t);
const j = def.types.indexOf(other);
[def.types[i], def.types[j]] = [def.types[j], def.types[i]];
return true;
}
/** 선택지 이름 바꿈 — 그 옵션 기본값 · 이 종류 규칙의 값들 · 선택지 · 기본값도 같이 */
export function renameChoice(
def: StructDef,
typeId: string,
o: OptionDef,
from: string,
to: string,
): void {
if (from === to || !o.choices) return;
o.choices = o.choices.map((c) => (c === from ? to : c));
if (o.default === from) o.default = to;
for (const r of def.규칙) {
if (r.종류 !== typeId) continue;
if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.map((v) => (v === from ? to : v));
if (r.그러면.옵션 === o.key) {
if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.map((v) => (v === from ? to : v));
if (r.그러면.기본값 === from) r.그러면.기본값 = to;
}
}
}
/** 선택지 뺌 — 기본값 · 규칙에서도 뺌(규칙 값들이 비면 그 규칙은 검사에서 걸림) */
export function dropChoice(def: StructDef, typeId: string, o: OptionDef, value: string): void {
o.choices = (o.choices ?? []).filter((c) => c !== value);
if (o.default === value) o.default = null;
for (const r of def.규칙) {
if (r.종류 !== typeId) continue;
if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.filter((v) => v !== value);
if (r.그러면.옵션 === o.key) {
if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.filter((v) => v !== value);
if (r.그러면.기본값 === value) delete r.그러면.기본값;
}
}
}
/** 배치 격자 — 정한 자리 먼저 · 안 정한 옵션은 차례대로 빈 칸 채움 · 안 씀은 빠짐 */
export function placeAll(
options: readonly OptionDef[],
spots: Record<string, Spot> = {},
cols = GRID_COLS,
): Map<string, Spot> {
const out = new Map<string, Spot>();
const taken = new Set<string>();
const mark = (c: Spot): void => {
for (let i = 0; i < c.너비; i += 1) taken.add(`${c.줄}:${c.칸 + i}`);
};
const shown = live(options);
for (const o of shown) {
const s = spots[o.key];
if (!s) continue;
const width = Math.max(1, Math.min(cols, s.너비 || 1));
const cell = { 줄: s.줄, 칸: Math.min(s.칸, cols - width + 1), 너비: width };
out.set(o.key, cell);
mark(cell);
}
let row = 1;
let col = 1;
for (const o of shown) {
if (out.has(o.key)) continue;
while (taken.has(`${row}:${col}`)) {
col += 1;
if (col > cols) ((row += 1), (col = 1));
}
const cell = { 줄: row, 칸: col, 너비: 1 };
out.set(o.key, cell);
mark(cell);
}
return out;
}
/** 겹친 칸 — 두 옵션이 같은 줄 · 칸을 쓰면 그 키들 */
export function overlaps(cells: Map<string, Spot>): string[] {
const owner = new Map<string, string>();
const bad = new Set<string>();
for (const [key, c] of cells)
for (let i = 0; i < c.너비; i += 1) {
const at = `${c.줄}:${c.칸 + i}`;
const other = owner.get(at);
if (other) bad.add(other).add(key);
else owner.set(at, key);
}
return [...bad];
}
/** 규칙 한 줄이 틀렸나 — 서버 검사(`M02_SO_Definition._rule_errors`)와 같은 뜻 */
export function ruleBad(def: StructDef, r: RuleDef): boolean {
const t = def.types.find((x) => x.type_id === r.종류);
const src = t?.options.find((o) => o.key === r.만약.옵션);
const dst = t?.options.find((o) => o.key === r.그러면.옵션);
if (!src || !dst || src === dst || !r.만약.값들.length) return true;
if (src.input === "select" && !r.만약.값들.every((v) => src.choices?.includes(v))) return true;
const { 선택지, 보이기, 기본값 } = r.그러면;
if (선택지 === undefined && 보이기 === undefined && 기본값 === undefined) return true;
let allowed = dst.choices ?? [];
if (선택지 !== undefined) {
if (dst.input !== "select" || !선택지.every((v) => allowed.includes(v))) return true;
allowed = 선택지;
}
return 기본값 !== undefined && dst.input === "select" && !allowed.includes(String(기본값));
}
/** 저장 전 검사 — 화면에 띄울 까닭 글들(빈 목록 = 저장 됨) */
export function problems(def: StructDef): string[] {
const out: string[] = [];
const fam = badNames(Object.values(def.구조물군));
if (fam.length) out.push(`구조물군 이름: ${fam.join(", ")}`);
for (const g of Object.keys(def.구조물군)) {
const bad = badNames(def.types.filter((t) => t.group === g).map((t) => t.name));
if (bad.length) out.push(`${def.구조물군[g]} 종류 이름: ${bad.join(", ")}`);
}
for (const t of def.types) {
const opt = badNames(t.options.map((o) => o.label));
if (opt.length) out.push(`${t.name} 옵션 이름: ${opt.join(", ")}`);
for (const o of t.options) {
if (o.input !== "select") continue;
const ch = badNames(o.choices ?? []);
if (ch.length) out.push(`${t.name}.${o.label} 선택지: ${ch.join(", ")}`);
if (!o.choices?.length) out.push(`${t.name}.${o.label} 고르기 꼴은 선택지가 있어야 함`);
}
const grp = badNames((def.그룹[t.type_id] ?? []).map((g) => g.이름));
if (grp.length) out.push(`${t.name} 그룹 이름: ${grp.join(", ")}`);
const lap = overlaps(placeAll(t.options, def.배치[t.type_id]));
if (lap.length) out.push(`${t.name} 배치 겹침: ${lap.join(", ")}`);
}
def.규칙.forEach((r, i) => {
if (ruleBad(def, r)) out.push(`규칙 ${i + 1} 이 틀림`);
});
return out;
}