diff --git a/src/components/floorplan-editor/FloorplanEditorView.test.tsx b/src/components/floorplan-editor/FloorplanEditorView.test.tsx index 5603025..a665fa3 100644 --- a/src/components/floorplan-editor/FloorplanEditorView.test.tsx +++ b/src/components/floorplan-editor/FloorplanEditorView.test.tsx @@ -161,7 +161,7 @@ describe('FloorplanEditorView container', () => expect(composer.thicknessFloor).toBe(1); }); - it('RoomOccupiedTilesMessageEvent marks blockedTilesMap entries as blocked in state', () => + it('RoomOccupiedTilesMessageEvent marks tiles occupied without altering the saved tilemap', () => { openEditor(); const fhmHandler = messageHandlers.get(FloorHeightMapEvent); @@ -178,8 +178,9 @@ describe('FloorplanEditorView container', () => fireEvent.click(saveBtn!); const composer = sendMessageComposer.mock.calls[0][0]; expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer); - // Row separator is \r per serializeTilemap; row 0 was '00', col 1 blocked → '0x' - expect(composer.tilemap.split(/\r/)[0]).toBe('0x'); + // Occupied is purely informational: the tile stays walkable and the + // saved tilemap is unchanged (row 0 stays '00', NOT voided to '0x'). + expect(composer.tilemap.split(/\r/)[0]).toBe('00'); }); it('RoomEngineEvent.DISPOSED hides the editor', () => diff --git a/src/components/floorplan-editor/FloorplanEditorView.tsx b/src/components/floorplan-editor/FloorplanEditorView.tsx index 8094d1b..b8c52be 100644 --- a/src/components/floorplan-editor/FloorplanEditorView.tsx +++ b/src/components/floorplan-editor/FloorplanEditorView.tsx @@ -1,4 +1,4 @@ -import { AddLinkEventTracker, convertNumbersForSaving, convertSettingToNumber, FloorHeightMapEvent, GetRoomEntryTileMessageComposer, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEntryTileMessageEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer'; +import { AddLinkEventTracker, convertNumbersForSaving, convertSettingToNumber, FloorHeightMapEvent, GetOccupiedTilesMessageComposer, GetRoomEntryTileMessageComposer, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEntryTileMessageEvent, RoomOccupiedTilesMessageEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useRef, useState } from 'react'; import { FaBolt, FaBoxOpen, FaCaretLeft, FaCaretRight } from 'react-icons/fa'; import { LocalizeText, SendMessageComposer } from '../../api'; @@ -50,8 +50,16 @@ export const FloorplanEditorView: FC = () => { if(!isVisible) return; SendMessageComposer(new GetRoomEntryTileMessageComposer()); + // Ask the server which tiles currently hold furniture so they can be + // shown (and protected from editing) in the grid. + SendMessageComposer(new GetOccupiedTilesMessageComposer()); }, [ isVisible ]); + useMessageEvent(RoomOccupiedTilesMessageEvent, event => + { + dispatch({ type: 'SET_OCCUPIED_TILES', map: event.getParser().blockedTilesMap }); + }); + useMessageEvent(RoomEntryTileMessageEvent, event => { const parser = event.getParser(); diff --git a/src/components/floorplan-editor/state/reducer.test.ts b/src/components/floorplan-editor/state/reducer.test.ts index 689e9ad..1fb84f8 100644 --- a/src/components/floorplan-editor/state/reducer.test.ts +++ b/src/components/floorplan-editor/state/reducer.test.ts @@ -106,6 +106,36 @@ describe('reducer — ADJUST_HEIGHT', () => const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' }); expect(next).toBe(start); }); + + it('is a no-op on occupied tiles', () => + { + const start = stateWith([[{ h: 5, blocked: false, occupied: true }]]); + const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' }); + expect(next).toBe(start); + }); +}); + +describe('reducer — SET_OCCUPIED_TILES', () => +{ + it('marks tiles occupied per the map without touching h or blocked', () => + { + const start = stateWith([[{ h: 2, blocked: false }, { h: 0, blocked: true }]]); + const next = reducer(start, { type: 'SET_OCCUPIED_TILES', map: [[true, false]] }); + expect(next.tiles[0][0]).toEqual({ h: 2, blocked: false, occupied: true }); + // already-unoccupied tile is left untouched (no spurious occupied key) + expect(next.tiles[0][1]).toEqual({ h: 0, blocked: true }); + }); + + it('does not block editing of non-occupied tiles', () => + { + const start = stateWith([[{ h: 0, blocked: false }, { h: 0, blocked: false }]]); + const occupied = reducer(start, { type: 'SET_OCCUPIED_TILES', map: [[false, true]] }); + // col 0 (not occupied) can still be painted; col 1 (occupied) cannot + const painted = reducer(occupied, { type: 'PAINT_TILE', row: 0, col: 0, h: 5, source: 'local' }); + expect(painted.tiles[0][0].h).toBe(5); + const blocked = reducer(occupied, { type: 'PAINT_TILE', row: 0, col: 1, h: 9, source: 'local' }); + expect(blocked).toBe(occupied); + }); }); describe('reducer — SET_DOOR', () => diff --git a/src/components/floorplan-editor/state/reducer.ts b/src/components/floorplan-editor/state/reducer.ts index 876afcb..c6d5840 100644 --- a/src/components/floorplan-editor/state/reducer.ts +++ b/src/components/floorplan-editor/state/reducer.ts @@ -52,6 +52,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl { const row = clamp64(action.row); const col = clamp64(action.col); + if(state.tiles[row]?.[col]?.occupied) return state; const tiles = ensureRect(state.tiles, row + 1, col + 1); const target = { h: clampHeight(action.h), blocked: false }; const next = setTile(tiles, row, col, target); @@ -64,6 +65,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl const col = action.col | 0; if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state; const current = state.tiles[row][col]; + if(current.occupied) return state; const target = { h: current.h, blocked: true }; const next = setTile(state.tiles, row, col, target); if(next === state.tiles) return state; @@ -75,7 +77,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl const col = action.col | 0; if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state; const current = state.tiles[row][col]; - if(current.blocked) return state; + if(current.blocked || current.occupied) return state; const newH = clampHeight(current.h + action.delta); if(newH === current.h) return state; const next = setTile(state.tiles, row, col, { h: newH, blocked: false }); @@ -106,6 +108,22 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl if(value === state.wallHeight) return state; return { ...state, wallHeight: value }; } + case 'SET_OCCUPIED_TILES': + { + // Mark tiles that currently hold furniture (server-reported). Leaves + // height + blocked untouched so it never alters the saved tilemap. + const map = action.map ?? []; + let changed = false; + const tiles = state.tiles.map((r, ri) => r.map((tile, ci) => + { + const occ = !!map[ri]?.[ci]; + if((tile.occupied ?? false) === occ) return tile; + changed = true; + return { ...tile, occupied: occ }; + })); + if(!changed) return state; + return { ...state, tiles }; + } case 'BRUSH_SET': { const h = action.h ?? state.brush.h; @@ -174,6 +192,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl const col = parseInt(cStr, 10); const current = tiles[row]?.[col]; if(!current) continue; + if(current.occupied) continue; switch(state.brush.action) { diff --git a/src/components/floorplan-editor/state/types.ts b/src/components/floorplan-editor/state/types.ts index 1361e38..fb30bd8 100644 --- a/src/components/floorplan-editor/state/types.ts +++ b/src/components/floorplan-editor/state/types.ts @@ -1,4 +1,7 @@ -export type Tile = { h: number; blocked: boolean }; +// `blocked` = void tile (no floor, serialized as 'x'). `occupied` = a tile that +// currently has furniture on it (reported by the server); kept separate so it +// stays visible and is NOT voided on save — it just can't be edited. +export type Tile = { h: number; blocked: boolean; occupied?: boolean }; export type EntryDir = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; export type ThicknessLevel = 0 | 1 | 2 | 3; @@ -39,6 +42,7 @@ export type FloorplanAction = | { type: 'SET_DOOR_DIR'; dir: EntryDir; source: LocalSource } | { type: 'SET_THICKNESS'; wall?: ThicknessLevel; floor?: ThicknessLevel; source: LocalSource } | { type: 'SET_WALL_HEIGHT'; value: number; source: LocalSource } + | { type: 'SET_OCCUPIED_TILES'; map: boolean[][] } | { type: 'BRUSH_SET'; h?: number; action?: FloorActionMode } | { type: 'SELECT_RECT'; from: [number, number]; to: [number, number] } | { type: 'SELECT_ALL' } diff --git a/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx b/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx index bb61ab5..a275776 100644 --- a/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx +++ b/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx @@ -40,11 +40,18 @@ describe('FloorplanCanvasSVG', () => const dispatch = vi.fn(); const { container } = render(); const svg = container.querySelector('svg') as SVGSVGElement; - svg.getBoundingClientRect = () => ({ left: 0, top: 0, right: 2048, bottom: 1024, width: 2048, height: 1024, x: 0, y: 0, toJSON: () => ({}) }); + // usePointerToTile resolves the tile via document.elementFromPoint first + // (the tile polygons carry data-row/data-col). jsdom returns null and has + // no SVGSVGElement.getScreenCTM, so point the hit-test at the tile polygon. + const tilePoly = container.querySelector('polygon[data-row="0"][data-col="0"]') as Element; + // jsdom's document has no elementFromPoint at all — define it for this test. + const prevEfp = (document as { elementFromPoint?: unknown }).elementFromPoint; + (document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint = () => tilePoly; fireEvent.pointerDown(svg, { clientX: 1024, clientY: 0, pointerId: 1 }); expect(dispatch).toHaveBeenCalled(); const call = dispatch.mock.calls[0][0]; expect(call.type).toBe('PAINT_TILE'); + (document as { elementFromPoint?: unknown }).elementFromPoint = prevEfp; }); it('zoom in/out buttons adjust the viewBox', () => diff --git a/src/components/floorplan-editor/views/FloorplanTile.tsx b/src/components/floorplan-editor/views/FloorplanTile.tsx index 7fca484..5c0c8ce 100644 --- a/src/components/floorplan-editor/views/FloorplanTile.tsx +++ b/src/components/floorplan-editor/views/FloorplanTile.tsx @@ -104,6 +104,17 @@ const FloorplanTileImpl: FC = ({ row, col, tile, selected, isDoor, southH stroke="#222" strokeWidth={ 0.5 } /> + { tile.occupied && ( + + ) } { selected && (