181 lines
6.3 KiB
TypeScript
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];
|
|
}
|