diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index f5e9af06..050740ed 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -10337,6 +10337,33 @@ const MUTANT_DEFINITIONS = [ replace: ' if (false && boundsIntersect(groupBounds[left], groupBounds[right])) join(left, right);', }], }, + { + id: 'iso-rigid-groups-cross-room-boundary', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="never join close markers" test/iso-overlays.test.mjs', + because: '#651 groups markers only within one owning room. Removing the owner check lets a ' + + 'close pair across a shared wall move as one group and cross the room boundary', + patches: [{ + file: 'src/iso-overlays.ts', + find: ' if (stable[right].placement.owner?.id !== owner) continue;', + replace: ' if (false) continue; // mutant: room ownership no longer separates groups', + }], + }, + { + id: 'iso-rigid-fallback-drops-room-wall-overlap-priority', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="rigid fallback prioritizes" test/iso-overlays.test.mjs', + because: '#651 degraded placement must preserve room ownership first, clear walls second and ' + + 'accept inter-group overlap only last. Distance-only fallback can pin a marker in masonry', + patches: [{ + file: 'src/iso-overlays.ts', + find: ' return left.roomViolations - right.roomViolations\n' + + ' || left.wallViolations - right.wallViolations\n' + + ' || left.overlapPenalty - right.overlapPenalty\n' + + ' || rigidOffsetOrder(left.offset, right.offset);', + replace: ' return rigidOffsetOrder(left.offset, right.offset);', + }], + }, { id: 'iso-scene-restores-per-marker-collision-resolver', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' diff --git a/test/iso-overlays.test.mjs b/test/iso-overlays.test.mjs index b10ba172..d4803560 100644 --- a/test/iso-overlays.test.mjs +++ b/test/iso-overlays.test.mjs @@ -146,6 +146,66 @@ test('rigid overlay groups preserve a row and use one deterministic wall displac } }); +test('rigid overlay groups never join close markers owned by different rooms', () => { + const left = square('left', 0, 0, 100, 160, [50, 80]); + const right = square('right', 100, 0, 200, 160, [150, 80]); + const wall = { outer: [[94, 0], [106, 0], [106, 160], [94, 160]] }; + const make = (id, room, x) => ({ + id, + kind: 'device', + placement: placement({ + floorAnchor: [x, 80], rooms: [left, right], preferredRoomId: room.id, + wallSilhouettes: [wall], footprintHalfSize: [5, 5], wallHeight: 0, + visualOffset: 0, sceneUnitsPerCssPixel: 1, camera: identityCamera, + }), + screenHalfSize: [7, 7], + }); + const leftItem = make('left-device', left, 90); + const rightItem = make('right-device', right, 110); + const result = resolveIsoOverlayRigidGroups({ + items: [leftItem, rightItem], rooms: [left, right], wallSilhouettes: [wall], + sceneUnitsPerCssPixel: 1, visualOffset: 0, camera: identityCamera, + }); + const leftPlacement = result.placements.get(isoOverlayCollisionKey('device', leftItem.id)); + const rightPlacement = result.placements.get(isoOverlayCollisionKey('device', rightItem.id)); + assert.ok(leftPlacement.nudgeCss[0] < 0, + 'the left-room marker clears the shared wall into its own room'); + assert.ok(rightPlacement.nudgeCss[0] > 0, + 'the right-room marker clears the shared wall into its own room'); + assert.notDeepEqual(leftPlacement.nudgeCss, rightPlacement.nudgeCss, + 'close markers across a room boundary must remain separate groups'); +}); + +test('rigid fallback prioritizes room, then wall, then overlap inside the 48px cap', () => { + const obstacleRoom = square('obstacle-room', 40, 0, 60, 40, [50, 30]); + const targetRoom = square('target-room', 40, 0, 60, 40, [50, 30]); + const wall = { outer: [[40, 0], [60, 0], [60, 14], [40, 14]] }; + const make = (id, room, y) => ({ + id, + kind: 'device', + placement: placement({ + floorAnchor: [50, y], rooms: [obstacleRoom, targetRoom], preferredRoomId: room.id, + wallSilhouettes: [wall], footprintHalfSize: [5, 5], wallHeight: 0, + visualOffset: 0, sceneUnitsPerCssPixel: 1, camera: identityCamera, + }), + screenHalfSize: [9, 9], + }); + const obstacle = make('a-obstacle', obstacleRoom, 30); + const target = make('b-target', targetRoom, 18); + const result = resolveIsoOverlayRigidGroups({ + items: [obstacle, target], rooms: [obstacleRoom, targetRoom], wallSilhouettes: [wall], + sceneUnitsPerCssPixel: 1, visualOffset: 0, camera: identityCamera, + }); + const resolved = result.placements.get(isoOverlayCollisionKey('device', target.id)); + assert.equal(resolved.status, 'degraded', 'the narrow room has no conflict-free placement'); + assert.equal(resolved.reason, 'overlay-collision', + 'after preserving room ownership and clearing the wall, overlap is the remaining debt'); + assert.equal(resolved.nearWallAfter, false, + 'wall clearance outranks overlap with the previously accepted group'); + assert.ok(resolved.nudgeCss[1] > 0 && resolved.nudgeDistanceCss <= 48, + 'the fallback moves into the room without exceeding the global cap'); +}); + test('group collision checks room-edge events before accepting a legal farther hint', () => { const room = { id: 'diagonal-strip',