Files
connectai/src/features/astraOffice/view/layoutSchema.ts
T

181 lines
6.3 KiB
TypeScript

/**
* 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<OfficeDeskCell['face']>(['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<string, unknown>;
if (!Array.isArray(r.cells)) return null;
const isV2 = r.schema === 2 || r.cells.some(
(c) =>
c && typeof c === 'object' &&
(typeof (c as Record<string, unknown>).deskSprite === 'string'
|| typeof (c as Record<string, unknown>).agentKey === 'string'
|| typeof (c as Record<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
if (typeof c.roleKey !== 'string' || !c.roleKey) return null;
const face = typeof c.face === 'string' && (VALID_FACES as ReadonlySet<string>).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<string, unknown>;
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];
}