"""Exit-related zone state transitions.
These helpers encapsulate the exit checkbox logic so callbacks can delegate
all zone mutations to the state manager.
"""
from __future__ import annotations
from typing import Any
from dash import html
from pipeworks_mud_mapper.models.room import (
DIRECTION_OFFSETS,
DIRECTION_SHORT,
OPPOSITE_DIRECTION,
SHORT_TO_DIRECTION,
Direction,
)
from pipeworks_mud_mapper.services.exit_utils import split_exits_by_scope
from pipeworks_mud_mapper.services.state.types import ZoneTransition
def _find_room_in_direction(
rooms: dict[str, dict],
from_coords: list[int],
direction: Direction,
*,
exclude_room: str | None = None,
) -> str | None:
"""Find the nearest room ID in a cardinal direction from coordinates."""
dx, dy, dz = DIRECTION_OFFSETS[direction]
fx, fy, fz = from_coords
candidates: list[tuple[int, str]] = []
for room_id, room_data in rooms.items():
if exclude_room and room_id == exclude_room:
continue
coords = room_data.get("coords", [0, 0, 0])
if not isinstance(coords, list) or len(coords) < 3:
continue
rx, ry, rz = coords[0], coords[1], coords[2]
in_direction = True
if dx != 0:
if dx > 0 and rx <= fx:
in_direction = False
elif dx < 0 and rx >= fx:
in_direction = False
elif ry != fy or rz != fz:
in_direction = False
elif dy != 0:
if dy > 0 and ry <= fy:
in_direction = False
elif dy < 0 and ry >= fy:
in_direction = False
elif rx != fx or rz != fz:
in_direction = False
elif dz != 0:
if dz > 0 and rz <= fz:
in_direction = False
elif dz < 0 and rz >= fz:
in_direction = False
elif rx != fx or ry != fy:
in_direction = False
if in_direction:
distance = abs(rx - fx) + abs(ry - fy) + abs(rz - fz)
candidates.append((distance, room_id))
if not candidates:
return None
candidates.sort(key=lambda item: item[0])
return candidates[0][1]
[docs]
def apply_exit_changes(
*,
zone_data: dict | None,
selected_room: str | None,
checked_values: list[str],
) -> ZoneTransition:
"""Apply exit checkbox changes to the zone.
Returns a ZoneTransition with extra effects for checkbox values and feedback UI.
"""
if not selected_room or not zone_data:
return ZoneTransition(zone_data=None, changed=False)
rooms = zone_data.get("rooms", {})
room = rooms.get(selected_room)
if not room:
return ZoneTransition(zone_data=None, changed=False)
coords = room.get("coords", [0, 0, 0])
# Split exits so the checkbox UI only reflects local (same-zone) exits.
# Cross-zone exits are managed in a separate UI section.
current_exits = room.get("exits", {})
local_exits, zone_exits = split_exits_by_scope(current_exits)
current_checked = {DIRECTION_SHORT[d] for d in local_exits if d in DIRECTION_SHORT}
new_checked = set(checked_values)
added = new_checked - current_checked
removed = current_checked - new_checked
if not added and not removed:
return ZoneTransition(zone_data=None, changed=False)
updated_zone = dict(zone_data)
updated_zone["rooms"] = {rid: dict(r) for rid, r in zone_data.get("rooms", {}).items()}
updated_room = updated_zone["rooms"][selected_room]
# Preserve cross-zone exits and only mutate local exits in this handler.
updated_local_exits = dict(local_exits)
feedback_messages: list[str] = []
rejected_directions: list[tuple[str, str]] = []
for short_dir in removed:
direction = SHORT_TO_DIRECTION.get(short_dir)
if direction and direction in updated_local_exits:
del updated_local_exits[direction]
feedback_messages.append(f"Removed {short_dir}")
for short_dir in added:
direction = SHORT_TO_DIRECTION.get(short_dir)
if not direction:
continue
if direction in zone_exits:
rejected_directions.append((short_dir, "zone exit set"))
feedback_messages.append(f"⚠️ {short_dir}: zone exit set")
continue
target_room_id = _find_room_in_direction(
rooms,
coords,
direction,
exclude_room=selected_room,
)
if target_room_id:
updated_local_exits[direction] = target_room_id
feedback_messages.append(f"{short_dir}→{target_room_id}")
opposite_dir = OPPOSITE_DIRECTION.get(direction)
if opposite_dir:
target_room_data = updated_zone["rooms"][target_room_id]
target_exits = dict(target_room_data.get("exits", {}))
if opposite_dir not in target_exits:
target_exits[opposite_dir] = selected_room
target_room_data["exits"] = target_exits
else:
rejected_directions.append((short_dir, "no room"))
feedback_messages.append(f"⚠️ {short_dir}: no room")
# Merge local exits back with any preserved cross-zone exits.
updated_room["exits"] = {**zone_exits, **updated_local_exits}
final_checked = [v for v in checked_values if v not in {d for d, _ in rejected_directions}]
exit_info: list[Any] = []
if updated_local_exits:
exit_info = [
html.Span(
[
html.Span(DIRECTION_SHORT.get(d, d), className="fw-bold"),
f"→{t} ",
],
className="me-2",
)
for d, t in updated_local_exits.items()
]
else:
exit_info = [html.Small("No exits defined", className="text-muted")]
if rejected_directions:
exit_info.append(html.Br())
exit_info.extend(
[
html.Span(
f"⚠️ {direction}: {reason} ",
className="text-warning small",
)
for direction, reason in rejected_directions
]
)
return ZoneTransition(
zone_data=updated_zone,
feedback=None,
unsaved=True,
effects={
"exit_values": final_checked,
"exit_feedback": exit_info,
},
changed=True,
)