"""Exit management callbacks.
This module handles both:
- Local exit checkbox changes (bidirectional exits within a zone)
- Cross-zone exit edits via the Workspace table/editor (zone_id:room_id)
Design Decisions
----------------
**Bidirectional by default**: When an exit checkbox is checked and a
target room is found, both the forward and reverse exits are created.
This matches player expectations in MUDs.
**No room = rejected**: If no room exists in the checked direction,
the checkbox is automatically unchecked and a warning is shown.
**Removal is one-way**: When unchecking an exit, only the exit from
the current room is removed. The reverse exit on the target room
remains (can be manually removed if needed).
Component Dependencies
----------------------
**Inputs:**
- ``exit-checkboxes``: Checklist of direction abbreviations
- ``workspace-zone-exit-direction``: Selected direction for zone exits
- ``workspace-zone-exit-zone``: Selected zone for cross-zone exit
- ``workspace-zone-exit-room``: Selected room for cross-zone exit
- ``workspace-zone-exit-save``: Persist the zone exit selection
- ``workspace-zone-exit-clear``: Remove a zone exit from a direction
**States:**
- ``selected-room``: Currently selected room
- ``current-zone-data``: Zone data for room lookup + mutation
**Outputs:**
- ``current-zone-data``: Updated with new exits
- ``exit-checkboxes``: Corrected values (rejected unchecked)
- ``exit-feedback``: Status display
- ``workspace-zone-exit-table``: Zone exit summary table
- ``workspace-zone-exit-feedback``: Status display for cross-zone exits
- ``has-unsaved-changes``: Unsaved flag
See Also
--------
- ``services/state``: Exit state transitions via the state manager
- ``models/room.py``: Direction constants
"""
import dash_bootstrap_components as dbc
from dash import ALL, Input, Output, State, callback, ctx, html, no_update
from pipeworks_mud_mapper.models.room import DIRECTION_SHORT, SHORT_TO_DIRECTION, Direction
from pipeworks_mud_mapper.services.exit_utils import (
EXIT_SHORT_ORDER,
format_zone_exit,
parse_zone_exit,
split_exits_by_scope,
)
from pipeworks_mud_mapper.services.state import ZoneAction, apply_zone_action
from pipeworks_mud_mapper.services.world_service import load_world_zone_ids, load_zone_room_ids
[docs]
@callback(
Output("current-zone-data", "data", allow_duplicate=True),
Output("exit-checkboxes", "value", allow_duplicate=True),
Output("exit-feedback", "children", allow_duplicate=True),
Output("has-unsaved-changes", "data", allow_duplicate=True),
Input("exit-checkboxes", "value"),
State("selected-room", "data"),
State("current-zone-data", "data"),
prevent_initial_call=True,
)
def handle_exit_changes(
checked_values: list[str],
selected_room: str | None,
zone_data: dict | None,
) -> tuple:
"""Handle exit checkbox changes - add or remove exits.
When an exit checkbox is checked:
1. Find the nearest room in that direction
2. If found, create exit and reverse exit (bidirectional)
3. If not found, reject and show warning
When an exit checkbox is unchecked:
1. Remove the exit from current room only
2. Reverse exit on target room is NOT removed (can be done manually)
Parameters
----------
checked_values : list[str]
List of checked direction abbreviations (e.g., ["N", "E"]).
selected_room : str | None
Currently selected room ID.
zone_data : dict | None
Current zone data.
Returns
-------
tuple
Updated zone data, corrected checkbox values, feedback, unsaved flag.
Notes
-----
- Uses find_room_in_direction to locate nearest room
- OPPOSITE_DIRECTION maps direction to its reverse
- Rejected directions (no room found) are unchecked automatically
- Feedback shows current exits and any warnings
"""
action = ZoneAction(
type="EXIT_CHANGE",
payload={
"selected_room": selected_room,
"checked_values": checked_values,
},
)
transition = apply_zone_action(zone_data, action)
if not transition.changed or transition.zone_data is None:
return no_update, no_update, no_update, no_update
final_checked = transition.effects.get("exit_values", no_update)
exit_info = transition.effects.get("exit_feedback", no_update)
return transition.zone_data, final_checked, exit_info, True
def _zone_exit_alert(message: str, color: str = "info") -> dbc.Alert:
"""Build a compact alert for zone exit actions."""
return dbc.Alert(message, color=color, className="mb-0 py-1", duration=4000)
def _build_zone_exit_rows(exits: dict[Direction, str] | None) -> list[dict[str, str]]:
"""Summarize zone exits into ordered row data for the workspace table."""
_, zone_exits = split_exits_by_scope(exits or {})
rows: list[dict[str, str]] = []
used_directions: set[Direction] = set()
# Preserve the UI order so authors see a predictable N/E/S/W/U/D list.
for short_dir in EXIT_SHORT_ORDER:
direction = SHORT_TO_DIRECTION.get(short_dir)
if not direction:
continue
target = zone_exits.get(direction)
if not target:
continue
zone_id, room_id = parse_zone_exit(target)
rows.append(
{
"direction_short": short_dir,
"zone_id": zone_id or "—",
"room_id": room_id or "—",
"target": target,
}
)
used_directions.add(direction)
# Append any unexpected directions so malformed data is still visible.
for direction, target in zone_exits.items():
if direction in used_directions:
continue
short_dir = DIRECTION_SHORT.get(direction, str(direction).upper()[:1])
zone_id, room_id = parse_zone_exit(target)
rows.append(
{
"direction_short": short_dir,
"zone_id": zone_id or "—",
"room_id": room_id or "—",
"target": target,
}
)
return rows
[docs]
@callback(
Output("workspace-zone-exit-zone", "options"),
Input("initial-load", "n_intervals"),
prevent_initial_call=True,
)
def load_workspace_zone_exit_zone_options(_: int | None) -> list[dict[str, str]]:
"""Load zone options from world metadata for the workspace editor."""
zone_ids = load_world_zone_ids()
return [{"label": zone_id, "value": zone_id} for zone_id in zone_ids]
[docs]
@callback(
Output("workspace-zone-exit-room", "options"),
Output("workspace-zone-exit-room", "disabled"),
Input("workspace-zone-exit-zone", "value"),
State("workspace-zone-exit-room", "value"),
)
def update_workspace_zone_exit_room_options(
zone_id: str | None,
current_room: str | None,
) -> tuple[list[dict[str, str]], bool]:
"""Update room options when the selected zone changes."""
if not zone_id:
return [], True
rooms = load_zone_room_ids(zone_id)
options = [{"label": room, "value": room} for room in rooms]
# Preserve the current room selection if it's missing from options.
if current_room and all(opt["value"] != current_room for opt in options):
options.append({"label": f"{current_room} (unlisted)", "value": current_room})
return options, False
[docs]
@callback(
Output("workspace-zone-exit-table", "children"),
Input("selected-room", "data"),
Input("current-zone-data", "data"),
prevent_initial_call=False,
)
def update_workspace_zone_exit_table(
selected_room: str | None,
zone_data: dict | None,
) -> html.Div:
"""Render the zone exit table for the selected room."""
if not selected_room:
return html.Div(
"Select a room to inspect zone exits.",
className="text-muted small",
)
if not zone_data:
return html.Div(
"Zone data is unavailable.",
className="text-muted small",
)
room = zone_data.get("rooms", {}).get(selected_room)
if not room:
return html.Div(
"Selected room not found in the active map.",
className="text-muted small",
)
rows = _build_zone_exit_rows(room.get("exits", {}))
if not rows:
return html.Div(
"No zone exits defined for this room.",
className="text-muted small",
)
table_rows = []
for row in rows:
direction_short = row["direction_short"]
row_kwargs: dict[str, object] = {}
if direction_short in EXIT_SHORT_ORDER:
# Only allow editing standard directions from the table.
row_kwargs = {
"id": {"type": "workspace-zone-exit-row", "direction": direction_short},
"style": {"cursor": "pointer"},
"n_clicks": 0,
"title": "Click to edit zone exit",
}
table_rows.append(
html.Tr(
[
html.Td(direction_short),
html.Td(row["zone_id"]),
html.Td(row["room_id"]),
html.Td(row["target"]),
],
**row_kwargs,
)
)
return dbc.Table(
[
html.Thead(
html.Tr(
[
html.Th("Dir"),
html.Th("Zone"),
html.Th("Room"),
html.Th("Target"),
]
)
),
html.Tbody(table_rows),
],
bordered=True,
hover=True,
size="sm",
className="small mb-0",
)
[docs]
@callback(
Output("workspace-zone-exit-direction", "value"),
Output("workspace-zone-exit-zone", "value"),
Output("workspace-zone-exit-room", "value"),
Input("selected-room", "data"),
Input({"type": "workspace-zone-exit-row", "direction": ALL}, "n_clicks"),
State("current-zone-data", "data"),
prevent_initial_call=True,
)
def populate_workspace_zone_exit_editor(
selected_room: str | None,
row_clicks: list[int],
zone_data: dict | None,
) -> tuple[str | None, str | None, str | None]:
"""Populate the zone exit editor from table clicks."""
trigger = ctx.triggered_id
# When switching rooms, clear the editor to avoid stale selections.
if trigger == "selected-room":
return None, None, None
if not selected_room or not zone_data or not any(row_clicks):
return no_update, no_update, no_update
if not isinstance(trigger, dict):
return no_update, no_update, no_update
direction_short = trigger.get("direction")
if direction_short not in EXIT_SHORT_ORDER:
return no_update, no_update, no_update
direction = SHORT_TO_DIRECTION.get(direction_short)
if not direction:
return no_update, no_update, no_update
room = zone_data.get("rooms", {}).get(selected_room)
if not room:
return no_update, no_update, no_update
_, zone_exits = split_exits_by_scope(room.get("exits", {}))
target = zone_exits.get(direction)
zone_id, room_id = parse_zone_exit(target)
return direction_short, zone_id, room_id
[docs]
@callback(
Output("workspace-zone-exit-feedback", "children", allow_duplicate=True),
Input("selected-room", "data"),
prevent_initial_call=True,
)
def clear_workspace_zone_exit_feedback(_: str | None) -> html.Div:
"""Clear action feedback when the selected room changes."""
return html.Div()
[docs]
@callback(
Output("current-zone-data", "data", allow_duplicate=True),
Output("workspace-zone-exit-feedback", "children", allow_duplicate=True),
Output("has-unsaved-changes", "data", allow_duplicate=True),
Input("workspace-zone-exit-save", "n_clicks"),
Input("workspace-zone-exit-clear", "n_clicks"),
State("workspace-zone-exit-direction", "value"),
State("workspace-zone-exit-zone", "value"),
State("workspace-zone-exit-room", "value"),
State("selected-room", "data"),
State("current-zone-data", "data"),
prevent_initial_call=True,
)
def handle_workspace_zone_exit_action(
save_clicks: int | None,
clear_clicks: int | None,
direction_short: str | None,
zone_id: str | None,
room_id: str | None,
selected_room: str | None,
zone_data: dict | None,
) -> tuple:
"""Apply workspace zone exit changes to the active map."""
trigger = ctx.triggered_id
if trigger not in {"workspace-zone-exit-save", "workspace-zone-exit-clear"}:
return no_update, no_update, no_update
if not selected_room or not zone_data:
return (
no_update,
_zone_exit_alert("Select a room before editing zone exits.", "warning"),
no_update,
)
if direction_short not in EXIT_SHORT_ORDER:
return no_update, _zone_exit_alert("Choose a direction to edit.", "warning"), no_update
direction = SHORT_TO_DIRECTION.get(direction_short)
if not direction:
return no_update, _zone_exit_alert("Invalid direction selection.", "warning"), no_update
rooms = zone_data.get("rooms", {})
room = rooms.get(selected_room)
if not room:
return no_update, _zone_exit_alert("Selected room not found.", "warning"), no_update
current_exits = room.get("exits", {})
local_exits, zone_exits = split_exits_by_scope(current_exits)
if trigger == "workspace-zone-exit-save":
if not zone_id or not room_id:
return (
no_update,
_zone_exit_alert("Select both a zone and a room.", "warning"),
no_update,
)
if direction in local_exits:
return (
no_update,
_zone_exit_alert(
f"{direction_short}: remove the local exit before adding a zone exit.",
"warning",
),
no_update,
)
desired_target = format_zone_exit(zone_id, room_id)
existing_target = zone_exits.get(direction)
if desired_target == existing_target:
return (
no_update,
_zone_exit_alert("Zone exit already set for that direction.", "info"),
no_update,
)
# Clone zone data before mutation so Dash sees a new object and
# room state updates remain predictable.
updated_zone = dict(zone_data)
updated_zone["rooms"] = {rid: dict(r) for rid, r in rooms.items()}
updated_room = updated_zone["rooms"][selected_room]
updated_exits = dict(current_exits)
updated_exits[direction] = desired_target
updated_room["exits"] = updated_exits
return (
updated_zone,
_zone_exit_alert(
f"Saved {direction_short} → {desired_target}",
"success",
),
True,
)
# Clear action removes only zone exits, leaving any local exit intact.
existing_target = zone_exits.get(direction)
if not existing_target:
return (
no_update,
_zone_exit_alert("No zone exit set for that direction.", "info"),
no_update,
)
# Clone zone data before mutation so the store updates cleanly.
updated_zone = dict(zone_data)
updated_zone["rooms"] = {rid: dict(r) for rid, r in rooms.items()}
updated_room = updated_zone["rooms"][selected_room]
updated_exits = dict(current_exits)
del updated_exits[direction]
updated_room["exits"] = updated_exits
return (
updated_zone,
_zone_exit_alert(f"Cleared {direction_short} zone exit.", "secondary"),
True,
)