/** * Pixel Office layout 저장 스키마 — workspaceState 의 `g1nation.pixelOfficeLayout` 키 * 에 저장되는 객체의 런타임 validator + v1 → v2 migration. * * 옛 runtime.ts 의 `_isV2Snap()` heuristic 을 정식 schema 로 격상. webview 에서 받는 * 즉시 한 번 통과시키면 깨진 데이터 / 옛 데이터 모두 안전하게 처리된다. * * 백엔드는 unknown 그대로 저장하지만, *로드 직후* 이 validator 를 적용해 정규화한다. */ export interface OfficeDeskCell { /** 안정적 식별자 — DOM dataset.role 로도 쓰임. */ roleKey: string; /** 매핑된 agent id. 비어있으면 unmapped. */ agentKey: string; label: string; charRow: number; // 0~7 deskSprite: string; /** 앉은 face. */ face: 'L' | 'R' | 'U' | 'D'; boss: boolean; /** seat 에서 잠시 일어났다 가는 dock 좌표. */ dock?: [number, number]; /** 랜덤 roam 후보 좌표들. */ roam?: Array<[number, number]>; deskX: number; deskY: number; deskW: number; deskRot: number; deskZ: number; seatX: number; seatY: number; charRot: number; charZ: number; /** 캐릭터를 지운 빈 책상. */ noChar: boolean; } export interface OfficeProp { id: string; name: string; x: number; y: number; w?: number; rot: number; z: number; } export interface LayoutV2 { schema: 2; cells: OfficeDeskCell[]; objs: OfficeProp[]; } const VALID_FACES = new Set(['L', 'R', 'U', 'D']); /** * raw 가 valid v2 layout 이면 정규화된 LayoutV2 를, 아니면 null. * v1 (옛 좌표만 있는 포맷) 은 별도 `migrateLayout()` 사용. */ export function validateLayout(raw: unknown): LayoutV2 | null { if (!raw || typeof raw !== 'object') return null; const r = raw as Record; if (!Array.isArray(r.cells)) return null; const isV2 = r.schema === 2 || r.cells.some( (c) => c && typeof c === 'object' && (typeof (c as Record).deskSprite === 'string' || typeof (c as Record).agentKey === 'string' || typeof (c as Record).charRow === 'number'), ); if (!isV2) return null; const cells = r.cells.map((c) => _normalizeCell(c)).filter((c): c is OfficeDeskCell => c !== null); const objsRaw = Array.isArray(r.objs) ? r.objs : []; const objs = objsRaw.map((o) => _normalizeProp(o)).filter((o): o is OfficeProp => o !== null); return { schema: 2, cells, objs }; } /** * v1 (옛 좌표 패치 포맷) → v2 (전체 station 정의) 마이그레이션. v1 은 좌표만 갖고 있어 * default station 의 나머지 필드(charRow, deskSprite 등)를 채워줘야 한다. webview 의 * default station 매핑이 함께 주어져야 정확. 없으면 best-effort. * * 이번 세션 stub: v1 입력이 들어오면 v2 shape 으로 일단 변환 (default 필드는 0/빈 값). * 다음 세션에서 default station 룩업과 결합. */ export function migrateLayout(raw: unknown): LayoutV2 | null { const asV2 = validateLayout(raw); if (asV2) return asV2; if (!raw || typeof raw !== 'object') return null; const r = raw as Record; if (!Array.isArray(r.cells)) return null; const cells: OfficeDeskCell[] = r.cells .map((cRaw) => { if (!cRaw || typeof cRaw !== 'object') return null; const c = cRaw as Record; if (typeof c.roleKey !== 'string') return null; return _normalizeCell({ ...c, // v1 은 charRow / deskSprite 등이 없으니 안전한 기본값. charRow: 0, deskSprite: 'desk-main', face: 'R', boss: false, agentKey: c.roleKey, // 옛 키가 곧 agent 였음. label: c.roleKey, noChar: false, }); }) .filter((c): c is OfficeDeskCell => c !== null); const objs = Array.isArray(r.objs) ? r.objs.map((o) => _normalizeProp(o)).filter((o): o is OfficeProp => o !== null) : []; return { schema: 2, cells, objs }; } function _normalizeCell(raw: unknown): OfficeDeskCell | null { if (!raw || typeof raw !== 'object') return null; const c = raw as Record; if (typeof c.roleKey !== 'string' || !c.roleKey) return null; const face = typeof c.face === 'string' && (VALID_FACES as ReadonlySet).has(c.face) ? (c.face as OfficeDeskCell['face']) : 'R'; return { roleKey: c.roleKey, agentKey: typeof c.agentKey === 'string' ? c.agentKey : '', label: typeof c.label === 'string' ? c.label : c.roleKey, charRow: _num(c.charRow, 0), deskSprite: typeof c.deskSprite === 'string' ? c.deskSprite : 'desk-main', face, boss: !!c.boss, dock: _pair(c.dock), roam: Array.isArray(c.roam) ? (c.roam.map(_pair).filter(Boolean) as Array<[number, number]>) : undefined, deskX: _num(c.deskX, 0), deskY: _num(c.deskY, 0), deskW: _num(c.deskW, 112), deskRot: _num(c.deskRot, 0), deskZ: _num(c.deskZ, 0), seatX: _num(c.seatX, 0), seatY: _num(c.seatY, 0), charRot: _num(c.charRot, 0), charZ: _num(c.charZ, 0), noChar: !!c.noChar, }; } function _normalizeProp(raw: unknown): OfficeProp | null { if (!raw || typeof raw !== 'object') return null; const o = raw as Record; if (typeof o.name !== 'string') return null; return { id: typeof o.id === 'string' ? o.id : `obj_${Math.random().toString(36).slice(2, 8)}`, name: o.name, x: _num(o.x, 0), y: _num(o.y, 0), w: typeof o.w === 'number' ? o.w : undefined, rot: _num(o.rot, 0), z: _num(o.z, 0), }; } function _num(v: unknown, fallback: number): number { return typeof v === 'number' && Number.isFinite(v) ? v : fallback; } function _pair(v: unknown): [number, number] | undefined { if (!Array.isArray(v) || v.length !== 2) return undefined; const a = typeof v[0] === 'number' ? v[0] : NaN; const b = typeof v[1] === 'number' ? v[1] : NaN; if (!Number.isFinite(a) || !Number.isFinite(b)) return undefined; return [a, b]; }