class_name WorldPreview
extends Control

signal facility_selected(instance_id: StringName)
signal staff_assignment_dropped(data: Dictionary, facility_id: StringName)
signal construction_selected(job_id: StringName)
signal legacy_stair_selected(stair_id: StringName)
signal cell_clicked(cell: Vector3i)
signal hovered_cell_changed(cell: Variant)
signal build_preview_changed(report: Dictionary)
signal excavation_preview_changed(report: Dictionary)

enum LayerMode {
	FRONT,
	BACK,
}

const RoomPresentation := preload("res://scripts/ui/room_presentation.gd")
const People := preload("res://scripts/ui/pixel_people.gd")
const PixelArt := preload("res://scripts/ui/pixel_tower_art.gd")
const InactiveLayerRooms := preload("res://scripts/ui/inactive_layer_rooms.gd")
const LAYER_LENS_SHADER := preload("res://assets/shaders/layer_lens.gdshader")
const LAYER_TRANSITION_SECONDS := 0.55
const MAP_PADDING := Vector2(76.0, 70.0)
const INVALID_PREVIEW := {&"can_build": false, &"error_codes": [], &"reasons": [], &"cells": []}
const INVALID_EXCAVATION_PREVIEW := {
	&"ok": false,
	&"cells": [],
	&"cost": {},
	&"build_minutes": 0,
	&"errors": [&"MISSING_DEPENDENCY"],
}

var layer_mode := LayerMode.FRONT:
	set(value):
		var previous := layer_mode
		layer_mode = clampi(value, LayerMode.FRONT, LayerMode.BACK) as LayerMode
		if previous != layer_mode and is_node_ready():
			_start_layer_transition()
		if _hovered_cell != null:
			_hovered_cell = Vector3i(_hovered_cell.x, _hovered_cell.y, layer_mode)
		queue_redraw()
		_update_build_preview()
		_update_excavation_preview()

var selected_entity_id: StringName = &"":
	set(value):
		selected_entity_id = value
		queue_redraw()

var build_definition: Variant = null
var build_rotation := 0
var excavation_mode := false
var demolition_mode := false
var visual_theme: VisualThemeProfile = null

var zoom_level := 1.0
var pan_offset := Vector2.ZERO
var show_grid := false
var pan_mode := false
var _drawing := false
var _draw_cache: Dictionary = {}

var _inactive_layer_art: Node2D
var _layer_lens: ColorRect
var _layer_transition_elapsed := LAYER_TRANSITION_SECONDS

var _animation_time := 0.0
var _animation_frame := -1
var _actor_tracks: Dictionary = {}
var _elevator_tracks: Dictionary = {}
var _boarded_actor_ids: Dictionary = {}
var _carried_patients: Dictionary = {}
var _treated_patients: Dictionary = {}
var _resting_staff: Dictionary = {}
var _room_presentations: Dictionary = {}
var _queue_positions: Dictionary = {}
var _queue_hidden: Dictionary = {}
var _landing_badges: Array[Dictionary] = []
var _transport_report: Dictionary = {}
var _life_observation: Dictionary = {}
var _life_overlay: Node2D
var _life_observation_serial := 0
var _dragging := false
var _drag_button := MOUSE_BUTTON_NONE

var _runtime: Variant = null
var _hovered_cell: Variant = null
var _preview_topology_key: Array = []
var _connection_cache_key: Array = []
var _connection_cache_report: Dictionary = {}
var _last_preview_report: Dictionary = INVALID_PREVIEW.duplicate(true)
var _last_excavation_preview_report: Dictionary = INVALID_EXCAVATION_PREVIEW.duplicate(true)


func _ready() -> void:
	clip_contents = true
	texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
	focus_mode = Control.FOCUS_CLICK
	mouse_filter = Control.MOUSE_FILTER_STOP
	resized.connect(queue_redraw)
	mouse_exited.connect(_on_mouse_exited)
	if _runtime == null:
		_runtime = get_node_or_null("/root/SimulationRuntime")
	_connect_runtime()
	_inactive_layer_art = InactiveLayerRooms.new()
	_inactive_layer_art.name = "InactiveLayerRooms"
	add_child(_inactive_layer_art)
	_life_overlay = Node2D.new()
	_life_overlay.name = "LifeOverlay"
	_life_overlay.draw.connect(_draw_life_overlay)
	add_child(_life_overlay)
	_layer_lens = ColorRect.new()
	_layer_lens.name = "LayerLens"
	_layer_lens.mouse_filter = Control.MOUSE_FILTER_IGNORE
	_layer_lens.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	var lens_material := ShaderMaterial.new()
	lens_material.shader = LAYER_LENS_SHADER
	_layer_lens.material = lens_material
	_layer_lens.visible = false
	add_child(_layer_lens)
	queue_redraw()


func set_runtime(value: Variant) -> void:
	_connection_cache_key.clear()
	_connection_cache_report.clear()
	_disconnect_runtime()
	_runtime = value
	_actor_tracks.clear()
	_elevator_tracks.clear()
	_boarded_actor_ids.clear()
	_connect_runtime()
	queue_redraw()
	_update_build_preview()
	_update_excavation_preview()


func set_visual_theme(profile: VisualThemeProfile) -> void:
	visual_theme = profile
	queue_redraw()


func set_build_definition(definition: Variant, rotation := 0) -> void:
	if definition != null:
		demolition_mode = false
	if definition != null and excavation_mode:
		set_excavation_mode(false)
	build_definition = definition
	build_rotation = _normalized_rotation(rotation)
	_update_build_preview()
	queue_redraw()


func clear_build_definition() -> void:
	build_definition = null
	build_rotation = 0
	_last_preview_report = INVALID_PREVIEW.duplicate(true)
	build_preview_changed.emit(_last_preview_report.duplicate(true))
	queue_redraw()


func set_build_rotation(value: int) -> void:
	build_rotation = _normalized_rotation(value)
	_update_build_preview()
	queue_redraw()


func set_excavation_mode(enabled: bool) -> void:
	if enabled:
		demolition_mode = false
	if excavation_mode == enabled:
		_update_excavation_preview()
		return
	excavation_mode = enabled
	if excavation_mode and build_definition != null:
		build_definition = null
		build_rotation = 0
		_last_preview_report = INVALID_PREVIEW.duplicate(true)
		build_preview_changed.emit(_last_preview_report.duplicate(true))
	_update_excavation_preview()
	queue_redraw()


func get_excavation_preview_report(cell: Variant = null) -> Dictionary:
	var target: Variant = _hovered_cell if cell == null else cell
	if not target is Vector3i:
		return INVALID_EXCAVATION_PREVIEW.duplicate(true)
	var excavation: Variant = _service(&"excavation_service")
	if excavation != null and excavation.has_method(&"validate_excavation"):
		var report: Variant = excavation.call(
			&"validate_excavation",
			Vector3i(target.x, target.y, layer_mode)
		)
		if report is Dictionary:
			return report.duplicate(true)
	return INVALID_EXCAVATION_PREVIEW.duplicate(true)


func get_hovered_cell() -> Variant:
	return _hovered_cell


func clear_hovered_cell() -> void:
	_set_hovered_cell(null)


func get_build_preview_report(cell: Variant = null) -> Dictionary:
	var target: Variant = _hovered_cell if cell == null else cell
	if build_definition == null or not target is Vector3i:
		return INVALID_PREVIEW.duplicate(true)
	if _runtime != null and _runtime.has_method(&"preview_build_facility"):
		var result: Dictionary = _runtime.call(&"preview_build_facility", build_definition.id, Vector3i(target.x, target.y, layer_mode), build_rotation)
		result[&"connection"] = _build_connection_report(
			_cells_from_serialized(result.get(&"cells", [])),
			Vector3i(target.x, target.y, layer_mode))
		return result
	var construction: Variant = _service(&"construction_service")
	if construction != null and construction.has_method(&"validate_placement"):
		var report: Variant = construction.call(
			&"validate_placement",
			build_definition,
			Vector3i(target.x, target.y, layer_mode),
			build_rotation
		)
		if report != null and report.has_method(&"to_dictionary"):
			var result: Dictionary = report.call(&"to_dictionary")
			result[&"connection"] = _build_connection_report(
				_cells_from_serialized(result.get(&"cells", [])),
				Vector3i(target.x, target.y, layer_mode))
			return result
	return {
		&"can_build": _fallback_cells_are_available(Vector3i(target.x, target.y, layer_mode)),
		&"error_codes": [],
		&"reasons": [],
		&"cells": _serialize_cells(_fallback_build_cells(Vector3i(target.x, target.y, layer_mode))),
	}


func get_map_rect() -> Rect2:
	if _drawing and _draw_cache.has(&"rect"):
		return _draw_cache[&"rect"]
	var bounds := _world_bounds()
	if bounds.is_empty():
		return Rect2()
	var dimensions := _cell_dimensions()
	var map_size := Vector2(
		int(bounds[&"max_x"]) - int(bounds[&"min_x"]) + 1,
		int(bounds[&"max_y"]) - int(bounds[&"min_y"]) + 1
	) * dimensions
	var result_value = Rect2(((size - map_size) * 0.5 + pan_offset).floor(), map_size)

	if _drawing:
		_draw_cache[&"rect"] = result_value
	return result_value


func _cell_dimensions() -> Vector2:
	if _drawing and _draw_cache.has(&"dimensions"):
		return _draw_cache[&"dimensions"]
	var bounds := _world_bounds()
	if bounds.is_empty():
		return Vector2(32, 48)
	var columns := maxi(int(bounds[&"max_x"]) - int(bounds[&"min_x"]) + 1, 1)
	var rows := maxi(int(bounds[&"max_y"]) - int(bounds[&"min_y"]) + 1, 1)
	var available := (size - MAP_PADDING * 2.0).max(Vector2.ONE)
	var fit := minf(available.x / (columns * 16.0), available.y / (rows * 24.0))
	# Native 16px units use integer magnification. Large worlds can be viewed
	# at overview scale, then enlarged with the same invertible camera mapping.
	var pixel := maxf(floorf(fit), 1.0) if fit >= 1.0 else maxf(fit, 0.25)
	var result_value = (Vector2(16, 24) * pixel * zoom_level).round().max(Vector2(4, 6))

	if _drawing:
		_draw_cache[&"dimensions"] = result_value
	return result_value


func set_zoom(value: float, anchor := Vector2(-1, -1)) -> void:
	var before := get_map_rect()
	var at := size * 0.5 if anchor.x < 0 else anchor
	var relative := (at - before.position) / before.size if before.has_area() else Vector2(0.5, 0.5)
	zoom_level = clampf(value, 0.5, 4.0)
	var after := get_map_rect()
	pan_offset += at - (after.position + relative * after.size)
	_clamp_pan()
	_set_hovered_cell(canvas_to_cell(at))
	queue_redraw()


func reset_view() -> void:
	zoom_level = 1.0
	pan_offset = Vector2.ZERO
	_actor_tracks.clear()
	queue_redraw()


func focus_operating_area() -> void:
	# Explicit framing, never continuous tracking: the player's pan stays put.
	zoom_level = 1.0
	pan_offset = Vector2.ZERO
	var area := Rect2()
	var construction: Variant = _service(&"construction_service")
	if construction != null:
		for item: Dictionary in construction.get_facilities():
			if int(item.get(&"layer", 0)) != layer_mode:
				continue
			var cells := _cells_from_serialized(item.get(&"occupied_cells", []))
			if cells.is_empty():
				continue
			var rect := _cell_bounds_rect(cells)
			area = area.merge(rect) if area.has_area() else rect
	if area.has_area():
		var relative := (area.get_center()-get_map_rect().position) / get_map_rect().size
		zoom_level = clampf(minf(size.x*0.68/area.size.x, size.y*0.65/area.size.y), 0.5, 2.0)
		var rect := get_map_rect()
		pan_offset += size*Vector2(0.5, 0.58) - (rect.position+rect.size*relative)
	else:
		zoom_level = 2.0
		pan_offset += size*Vector2(0.5, 0.65) - cell_to_canvas(Vector3i(0, 1, layer_mode))
	_clamp_pan()
	clear_hovered_cell()
	queue_redraw()


func set_pan_mode(enabled: bool) -> void:
	pan_mode = enabled
	_end_pan_drag()
	clear_hovered_cell()


func _end_pan_drag() -> void:
	_dragging = false
	_drag_button = MOUSE_BUTTON_NONE
	mouse_default_cursor_shape = Control.CURSOR_DRAG if pan_mode else Control.CURSOR_ARROW


func _notification(what: int) -> void:
	if what == NOTIFICATION_APPLICATION_FOCUS_OUT:
		_end_pan_drag()


func _clamp_pan() -> void:
	var extent := get_map_rect().size * 0.5 + size * 0.3
	pan_offset = pan_offset.clamp(-extent, extent)


func _start_layer_transition() -> void:
	if _layer_lens == null: return
	_layer_transition_elapsed = 0.0
	var center := size * 0.5
	if _hovered_cell != null:
		center = cell_to_canvas(_hovered_cell)
	var lens_material := _layer_lens.material as ShaderMaterial
	lens_material.set_shader_parameter("center", center / size.max(Vector2.ONE))
	lens_material.set_shader_parameter("extent", size)
	lens_material.set_shader_parameter("progress", 0.0)
	_layer_lens.visible = true


func _process(delta: float) -> void:
	# UI transitions complete even in active pause; actor time remains frozen.
	if _layer_lens != null and _layer_lens.visible:
		_layer_transition_elapsed = minf(_layer_transition_elapsed + maxf(delta, 0.0), LAYER_TRANSITION_SECONDS)
		var lens_material := _layer_lens.material as ShaderMaterial
		lens_material.set_shader_parameter("extent", size)
		lens_material.set_shader_parameter("progress", _layer_transition_elapsed / LAYER_TRANSITION_SECONDS)
		_layer_lens.visible = _layer_transition_elapsed < LAYER_TRANSITION_SECONDS
	if build_definition != null and _hovered_cell is Vector3i and _current_preview_topology() != _preview_topology_key:
		_update_build_preview()
		queue_redraw()
	var clock := get_node_or_null("/root/GameClock")
	if clock != null and bool(clock.get("paused")):
		return
	var speed := float(clock.get("speed")) if clock != null else 1.0
	_animation_time += delta * maxf(speed, 0.0)
	var frame := int(_animation_time * 12.0)
	if frame != _animation_frame:
		_animation_frame = frame
		queue_redraw()


func _unhandled_key_input(event: InputEvent) -> void:
	if not event is InputEventKey or not event.pressed or event.echo:
		return
	if event.keycode == KEY_HOME:
		reset_view()
		get_viewport().set_input_as_handled()
	elif event.keycode == KEY_G:
		show_grid = not show_grid
		queue_redraw()
		get_viewport().set_input_as_handled()


func cell_to_canvas(cell: Vector3i) -> Vector2:
	var bounds := _world_bounds()
	if bounds.is_empty():
		return Vector2.ZERO
	return get_map_rect().position + Vector2(
		float(cell.x - int(bounds[&"min_x"])) + 0.5,
		float(int(bounds[&"max_y"]) - cell.y) + 0.5
	) * _cell_dimensions()


func canvas_to_cell(canvas_position: Vector2) -> Variant:
	var bounds := _world_bounds()
	var map_rect := get_map_rect()
	if bounds.is_empty() or not map_rect.has_area() or not map_rect.has_point(canvas_position):
		return null
	var local_position := (canvas_position - map_rect.position) / _cell_dimensions()
	var cell := Vector3i(int(bounds[&"min_x"]) + floori(local_position.x), int(bounds[&"max_y"]) - floori(local_position.y), layer_mode)
	var world: Variant = _service(&"world_service")
	if world != null and world.has_method(&"is_in_bounds") and not bool(world.call(&"is_in_bounds", cell)):
		return null
	return cell


func get_cell_rect(cell: Vector3i) -> Rect2:
	if _world_bounds().is_empty():
		return Rect2()
	var dimensions := _cell_dimensions()
	return Rect2(cell_to_canvas(cell) - dimensions * 0.5, dimensions)


func facility_at_canvas(canvas_position: Vector2) -> StringName:
	var cell: Variant = canvas_to_cell(canvas_position)
	if not cell is Vector3i:
		return &""
	var world: Variant = _service(&"world_service")
	if world == null or not world.has_method(&"get_occupant"):
		return &""
	var occupant_id := StringName(world.call(&"get_occupant", cell))
	if occupant_id.is_empty():
		return &""
	var kind := &""
	if world.has_method(&"get_occupant_kind"):
		kind = StringName(world.call(&"get_occupant_kind", cell))
	return occupant_id if kind == &"facility" else &""


func _gui_input(event: InputEvent) -> void:
	if event is InputEventMouseButton:
		if not event.pressed and event.button_index == _drag_button:
			_end_pan_drag()
			accept_event()
			return
		if event.button_index == MOUSE_BUTTON_MIDDLE or (pan_mode and event.button_index == MOUSE_BUTTON_LEFT):
			if event.pressed:
				_dragging = true
				_drag_button = event.button_index
				mouse_default_cursor_shape = Control.CURSOR_DRAG
			accept_event()
			return
		if event.pressed and event.button_index in [MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN]:
			if pan_mode and not event.ctrl_pressed:
				var direction := 1.0 if event.button_index == MOUSE_BUTTON_WHEEL_UP else -1.0
				pan_offset.y += direction * _cell_dimensions().y * maxf(event.factor, 0.0)
				_clamp_pan()
				queue_redraw()
			else:
				set_zoom(zoom_level + (0.5 if event.button_index == MOUSE_BUTTON_WHEEL_UP else -0.5), event.position)
			accept_event()
			return
	if event is InputEventMouseMotion and _dragging:
		var mask := MOUSE_BUTTON_MASK_LEFT if _drag_button == MOUSE_BUTTON_LEFT else MOUSE_BUTTON_MASK_MIDDLE
		if not event.button_mask & mask:
			_end_pan_drag()
			return
		pan_offset += event.relative
		_clamp_pan()
		queue_redraw()
		accept_event()
		return
	if event is InputEventMouseMotion:
		if not pan_mode:
			_set_hovered_cell(canvas_to_cell(event.position))
		return
	if not event is InputEventMouseButton or not event.pressed or event.button_index != MOUSE_BUTTON_LEFT:
		return
	var cell: Variant = canvas_to_cell(event.position)
	if not cell is Vector3i:
		return
	if build_definition != null or excavation_mode or demolition_mode:
		cell_clicked.emit(cell)
		accept_event()
		return
	var stair_id := legacy_stair_at_canvas(event.position)
	if not stair_id.is_empty():
		selected_entity_id = stair_id
		legacy_stair_selected.emit(stair_id)
		accept_event()
		return
	var world: Variant = _service(&"world_service")
	if world != null and world.has_method(&"get_occupant"):
		var occupant_id := StringName(world.call(&"get_occupant", cell))
		var kind := &""
		if world.has_method(&"get_occupant_kind"):
			kind = StringName(world.call(&"get_occupant_kind", cell))
		if not occupant_id.is_empty() and kind == &"facility":
			selected_entity_id = occupant_id
			facility_selected.emit(occupant_id)
			accept_event()
			return
		if not occupant_id.is_empty() and kind == &"construction":
			selected_entity_id = occupant_id
			construction_selected.emit(occupant_id)
			accept_event()
			return
	cell_clicked.emit(cell)
	accept_event()


func _draw() -> void:
	_drawing = true
	_draw_cache.clear()
	_draw_contents()
	_drawing = false
	_draw_cache.clear()


func _draw_contents() -> void:
	_life_observation = {}
	if _life_overlay != null:
		_life_overlay.queue_redraw()
	if _inactive_layer_art != null:
		_inactive_layer_art.visible = false
	_draw_background()
	var bounds := _world_bounds()
	if bounds.is_empty():
		_draw_center_message("世界データを待っています")
		return
	var map_rect := get_map_rect()
	if map_rect.size == Vector2.ZERO:
		return
	_life_observation = _build_life_observation()
	_life_observation_serial += 1
	_life_observation[&"observation_serial"] = _life_observation_serial
	_life_observation[&"overlay_consumed_serial"] = -1
	_room_presentations = _life_observation[&"rooms"]
	_carried_patients = _life_observation[&"carried_patients"]
	_treated_patients = _life_observation[&"treated_patients"]
	_resting_staff = _life_observation[&"resting_staff"]
	_queue_positions = _life_observation[&"queue_positions"]
	_queue_hidden = _life_observation[&"queue_hidden"]
	_landing_badges.assign(_life_observation[&"landing_badges"])
	_transport_report = _life_observation[&"elevators"]
	_boarded_actor_ids = _life_observation[&"boarded"]
	_actor_tracks = _life_observation[&"actor_tracks"]
	_elevator_tracks = _life_observation[&"elevator_tracks"]
	_draw_world_cells(bounds, map_rect)
	_draw_world_entity_icons()
	_draw_legacy_stairs()
	_draw_other_layer()
	_draw_construction_labels()
	_draw_facility_labels()
	_draw_excavation_jobs()
	_draw_build_preview()
	_draw_excavation_preview()
	_draw_hover()
	_draw_map_caption(map_rect)


## The report describes the last submitted draw, not an independently sampled world.
## Callers cannot mutate either the frame or its interpolation tracks.
func get_life_observation_report() -> Dictionary:
	return _life_observation.duplicate(true)


## Pure with respect to Runtime and this Control: helpers mutate only fresh local
## dictionaries (including copies of visual tracks). No whole Runtime snapshot
## is copied per frame; whole typed snapshots belong to sparse diagnostics.
func _build_life_observation() -> Dictionary:
	var model := _observe_rooms()
	model.merge(_observe_queues())
	model[&"layer"] = layer_mode
	model[&"zoom"] = zoom_level
	model[&"pan"] = pan_offset
	model[&"viewport"] = size
	model[&"animation_time"] = _animation_time
	model[&"actor_tracks"] = _actor_tracks.duplicate(true)
	model[&"elevator_tracks"] = _elevator_tracks.duplicate(true)
	model[&"boarded"] = {}
	var clock := get_node_or_null("/root/GameClock")
	model[&"paused"] = clock != null and bool(clock.get("paused"))
	model[&"cabins"] = _observe_elevators(model)
	model[&"actor_drawings"] = _observe_actors(model)
	var guests: Array[Dictionary] = []
	var staff: Array[Dictionary] = []
	var tasks: Array[Dictionary] = []
	var guest_service: Variant = _service(&"guest_service")
	if guest_service != null and guest_service.has_method(&"get_guests"):
		for guest: GuestRuntime in guest_service.get_guests():
			guests.append({&"id": guest.guest_id, &"state": GuestRuntime.State.keys()[guest.state],
				&"cell": guest.current_cell, &"target": guest.target_facility_id,
				&"service": guest.current_service_id, &"queue": guest.queue_request_id})
	var staff_service: Variant = _service(&"staff_service")
	if staff_service != null:
		for member: StaffRuntime in staff_service.get_staff_members():
			staff.append({&"id": member.staff_id, &"state": StaffRuntime.State.keys()[member.state],
				&"cell": member.current_cell, &"task": member.current_task_id,
				&"facility": member.assigned_facility_id, &"role": member.role_id,
				&"carried_inventory_id": member.carried_inventory_id})
		for task: StaffTask in staff_service.get_tasks():
			tasks.append({&"id": task.task_id, &"state": StaffTask.State.keys()[task.state],
				&"type": task.task_type, &"staff": task.assigned_staff_id,
				&"target": task.target_entity_id, &"rescue_facility": task.rescue_facility_id})
	model[&"guests"] = guests
	model[&"staff"] = staff
	model[&"tasks"] = tasks
	_fit_life_actors(model)
	var bridge: Variant = _service(&"supply_logistics")
	var goods: Dictionary = bridge.get_report() if bridge != null else {&"requests": [], &"deliveries": []}
	model[&"goods"] = goods
	var badges: Array[Dictionary] = []
	var facilities: Variant = _service(&"facility_service")
	var world: Variant = _service(&"world_service")
	if facilities != null and world != null:
		for instance: FacilityInstance in facilities.get_instances():
			if instance.origin.z != layer_mode:
				continue
			var cells: Array[Vector3i] = world.get_entity_cells(instance.instance_id)
			if cells.is_empty():
				continue
			var rect := _cell_bounds_rect(cells)
			var data: Dictionary = model[&"rooms"][instance.instance_id]
			data[&"rect"] = rect
			data[&"definition_id"] = instance.definition_id
			var count := RoomPresentation.slots(rect, _cell_dimensions().x / 16.0, instance.definition_id).size()
			badges.append({&"facility": instance.instance_id, &"kind": &"business",
				&"at": rect.position + Vector2(2, 2),
				&"text": RoomPresentation.business_label(data) + " " + RoomPresentation.label(data, count)})
			var requests: Array[Dictionary] = []
			for request: Dictionary in goods.get(&"requests", []):
				if request[&"source_facility_id"] == instance.instance_id or request[&"destination_facility_id"] == instance.instance_id:
					requests.append(request)
			if not requests.is_empty():
				badges.append({&"facility": instance.instance_id, &"kind": &"supply",
					&"at": rect.position + Vector2(2, 16), &"requests": requests,
					&"text": RoomPresentation.supply_label(requests)})
	model[&"badges"] = badges
	_resolve_observation_labels(model)
	var obstacles: Array[Rect2] = []
	for cabin: Dictionary in model[&"cabins"]:
		obstacles.append(cabin[&"rect"])
		obstacles.append(cabin[&"badge_rect"])
	for collection: Array in [badges, model[&"landing_badges"], model.get(&"actor_aggregations", [])]:
		for badge: Dictionary in collection:
			obstacles.append(_readable_badge_rect(badge[&"at"], badge[&"text"]))
	model[&"traffic_obstacles"] = obstacles
	var traffic: Array[Dictionary] = []
	var path: Variant = _service(&"path_service")
	var bounds := _world_bounds()
	if path != null and not bounds.is_empty():
		for y in range(int(bounds[&"min_y"]), int(bounds[&"max_y"]) + 1):
			for x in range(int(bounds[&"min_x"]), int(bounds[&"max_x"]) + 1):
				var cell := Vector3i(x, y, layer_mode)
				var row: Dictionary = path.get_cell_traffic(cell)
				if not row.is_empty():
					var cell_rect := get_cell_rect(cell)
					var rect := cell_rect.grow(-2.0)
					var world_pixel := _cell_dimensions().x / 16.0
					var thickness := clampf(rect.size.y * 0.16, 2.0, 5.0)
					var band := Rect2(Vector2(rect.position.x + rect.size.x * 0.12,
						cell_rect.end.y - 2 * world_pixel - thickness * 0.5),
						Vector2(rect.size.x * 0.76, thickness))
					traffic.append({&"cell": cell, &"traffic": row, &"rect": rect, &"band_rect": band,
						&"segment_rects": _traffic_segments(band, rect, obstacles)})
	model[&"traffic"] = traffic
	return model


## Subtract opaque observation regions, retaining a visible density mark even
## when the ordinary floor band is completely behind a moving cabin.
func _traffic_segments(band: Rect2, cell: Rect2, obstacles: Array[Rect2]) -> Array[Rect2]:
	for y: float in [band.position.y, cell.position.y, cell.end.y - band.size.y]:
		var pieces: Array[Rect2] = [Rect2(Vector2(band.position.x, y), band.size)]
		for obstacle: Rect2 in obstacles:
			var remaining: Array[Rect2] = []
			for piece: Rect2 in pieces:
				var cut := piece.intersection(obstacle)
				if not cut.has_area():
					remaining.append(piece)
					continue
				for part: Rect2 in [Rect2(piece.position, Vector2(piece.size.x, cut.position.y-piece.position.y)),
					Rect2(Vector2(piece.position.x, cut.end.y), Vector2(piece.size.x, piece.end.y-cut.end.y)),
					Rect2(Vector2(piece.position.x, cut.position.y), Vector2(cut.position.x-piece.position.x, cut.size.y)),
					Rect2(Vector2(cut.end.x, cut.position.y), Vector2(piece.end.x-cut.end.x, cut.size.y))]:
					if part.has_area():
						remaining.append(part)
			pieces = remaining
		if not pieces.is_empty():
			return pieces
	return []


func _resolve_observation_labels(model: Dictionary) -> void:
	var placed: Array[Dictionary] = []
	for i: int in model[&"cabins"].size():
		var cabin: Dictionary = model[&"cabins"][i]
		placed.append({&"id": "cabin/%d/rect" % i, &"rect": cabin[&"rect"]})
		placed.append({&"id": "cabin/%d/badge" % i, &"rect": cabin[&"badge_rect"]})
	for kind: String in ["business", "supply", "landing", "aggregation"]:
		var collection: Array = model[&"badges"] if kind in ["business", "supply"] else model[&"landing_badges"] if kind == "landing" else model.get(&"actor_aggregations", [])
		for i: int in collection.size():
			var badge: Dictionary = collection[i]
			if kind in ["business", "supply"] and badge[&"kind"] != kind:
				continue
			var anchor: Vector2 = badge[&"at"]
			var at := anchor
			var displaced_by: Array[String] = []
			# Each collision advances to an existing right edge. Positive widths
			# guarantee termination; touching edges are allowed.
			while true:
				var blocker: Dictionary = {}
				for previous: Dictionary in placed:
					if _readable_badge_rect(at, badge[&"text"]).intersection(previous[&"rect"]).has_area():
						blocker = previous
						break
				if blocker.is_empty():
					break
				displaced_by.append(blocker[&"id"])
				at.x = (blocker[&"rect"] as Rect2).end.x
			badge[&"anchor"] = anchor
			badge[&"at"] = at
			badge[&"displaced_by"] = displaced_by.duplicate(true)
			placed.append({&"id": "%s/%d" % [kind, i], &"rect": _readable_badge_rect(at, badge[&"text"])})


func _draw_life_badges(canvas: CanvasItem) -> void:
	for badge: Dictionary in _life_observation.get(&"badges", []):
		_draw_readable_badge(badge[&"at"], badge[&"text"], canvas)


func _draw_background() -> void:
	var night := false
	var clock := get_node_or_null("/root/GameClock")
	if clock != null:
		var hour := int(clock.call("get_hour"))
		night = hour >= 18 or hour < 6
	var sky := Color("b5cbb5") if not night else Color("273c49")
	if layer_mode == LayerMode.BACK:
		sky = Color("526d68") if not night else Color("233d40")
	draw_rect(Rect2(Vector2.ZERO, size), sky)
	# Pixel silhouettes make the world frame distinct from the usable floors.
	var horizon := size.y * 0.65
	for band in range(3):
		var color := [Color("8cae98"), Color("749981"), Color("567e68")][band] as Color
		if night:
			color = color.darkened(0.48)
		for x in range(-24, int(size.x)+64, 48):
			var crown_y := horizon + band*46 - posmod(x*17+band*29, 83)
			draw_rect(Rect2(x, crown_y, 48, size.y-crown_y), color)
			draw_rect(Rect2(x+8, crown_y-12, 32, 12), color)
	if night:
		for i in range(28):
			var at := Vector2(posmod(i*137+31, maxi(int(size.x),1)), posmod(i*47+13,maxi(int(horizon),1)))
			draw_rect(Rect2(at,Vector2(2,2)),Color("c6d3b6"))
	else:
		for i in range(5):
			var x := float(posmod(i*263+41, maxi(int(size.x),1)))
			var y := 32.0+float(i%3)*27.0
			draw_rect(Rect2(x,y,86,12),Color("d5dec4"))
			draw_rect(Rect2(x+17,y-7,46,7),Color("d5dec4"))


func _draw_world_cells(bounds: Dictionary, map_rect: Rect2) -> void:
	var world: Variant = _service(&"world_service")
	if world == null:
		return
	var p := _cell_dimensions().x / 16.0
	var ground := get_cell_rect(Vector3i(0,0,layer_mode)).end.y
	var trunk := Rect2(map_rect.position-Vector2(9*p,22*p), map_rect.size+Vector2(18*p,24*p))
	draw_rect(trunk, Color("574d39"))
	draw_rect(trunk.grow(-2*p), Color("776445"))
	for x in range(int(trunk.position.x+4*p),int(trunk.end.x),maxi(int(7*p),1)):
		var notch := float(posmod(x*11,17))*p
		draw_rect(Rect2(x,trunk.position.y+notch,2*p,trunk.size.y-notch),Color("8a7350"))
	# Branches, crown and roots belong to the scenery, never facility slots.
	for side in [-1,1]:
		var edge := trunk.position.x if side < 0 else trunk.end.x
		for branch in range(3):
			var y := trunk.position.y+(branch*24+22)*p
			var x := edge-(18+branch*4)*p if side<0 else edge
			draw_rect(Rect2(x,y,(18+branch*4)*p,5*p),Color("69583d"))
			_draw_leaf_cluster(Vector2(x+(2 if side<0 else 14)*p,y-5*p),p,branch)
		var root_x := edge-28*p if side<0 else edge-2*p
		draw_rect(Rect2(root_x,ground-3*p,30*p,6*p),Color("65583e"))
		draw_rect(Rect2(root_x+(10*p if side<0 else 0),ground-7*p,20*p,5*p),Color("776445"))
	draw_rect(Rect2(trunk.position.x-12*p,trunk.position.y-14*p,trunk.size.x+24*p,17*p),Color("385d45"))
	for i in range(17):
		var arch := sin(float(i)/16.0*PI)*17.0
		_draw_leaf_cluster(Vector2(trunk.position.x+float(i)*trunk.size.x/16.0,trunk.position.y-4*p-arch*p-float(i%3)*3*p),p,i)
	# Unbuilt space stays dark wood; completed rooms cut into it below.
	for y in range(int(bounds[&"min_y"]), int(bounds[&"max_y"])+1):
		for x in range(int(bounds[&"min_x"]),int(bounds[&"max_x"])+1):
			var cell := Vector3i(x,y,layer_mode)
			var rect := get_cell_rect(cell)
			if not rect.intersects(Rect2(Vector2.ZERO,size)):
				continue
			var excavated := bool(world.call(&"is_excavated",cell)) if world.has_method(&"is_excavated") else true
			if not excavated:
				draw_rect(rect,Color("47463e"))
				if posmod(x+y,3)==0:
					draw_rect(Rect2(rect.position+Vector2(5,11)*p,Vector2(5,2)*p),Color("5a594b"))
			elif bool(world.call(&"is_walkable",cell)):
				draw_rect(rect.grow(-p), Color("aaa083") if layer_mode==LayerMode.FRONT else Color("829a8d"))
				draw_rect(Rect2(rect.position.x,rect.end.y-4*p,rect.size.x,3*p),Color("b69465"))
				draw_rect(Rect2(rect.position.x,rect.end.y-p,rect.size.x,p),Color("514839"))
				if posmod(x,4)==0:
					PixelArt.window(self,rect.position+Vector2(5,4)*p,p)
			var kind := StringName(world.call(&"get_occupant_kind",cell))
			if kind==&"construction":
				draw_rect(rect.grow(-p),Color("8d7951"))
				_draw_hatch(rect.grow(-p),Color("e1bd6c"),rect.size.x)
			if show_grid or build_definition != null or excavation_mode:
				draw_rect(rect,Color("dfd4ad",0.25),false,1.0)
		var label_at := Vector2(map_rect.position.x-32*p, get_cell_rect(Vector3i(0,y,layer_mode)).get_center().y)
		var floor_name := "%dF" % (y+1) if y>=0 else "B%d" % -y
		draw_rect(Rect2(label_at-Vector2(2,8)*p,Vector2(22,12)*p),Color("2f493f"))
		draw_string(ThemeDB.fallback_font,label_at+Vector2(0,1)*p,floor_name,HORIZONTAL_ALIGNMENT_CENTER,18*p,clampi(int(7*p),10,18),Color("e4d9b9"))
	# A narrow garden frames the entrance without hiding underground floors.
	draw_rect(Rect2(map_rect.position.x-35*p,ground,map_rect.size.x+70*p,3*p),Color("58784c"))
	var town := visual_theme.pixel_town_tiles if visual_theme != null else null
	for side in [-1,1]:
		var gx := map_rect.position.x-32*p if side<0 else map_rect.end.x+15*p
		PixelArt.tile(self,town,4,Rect2(gx,ground-30*p,16*p,16*p))
		PixelArt.tile(self,town,16,Rect2(gx,ground-14*p,16*p,16*p))
		PixelArt.tile(self,town,2,Rect2(gx-12*p,ground-8*p,12*p,12*p))


func _draw_leaf_cluster(center: Vector2, p: float, variation: int) -> void:
	var c := center.round()
	var shift := float(variation%3)*2.0
	draw_rect(Rect2(c-Vector2(24,5)*p,Vector2(48,12)*p),Color("385d45"))
	draw_rect(Rect2(c-Vector2(20,10)*p,Vector2(40,20)*p),Color("4c7950"))
	draw_rect(Rect2(c-Vector2(13+shift,15)*p,Vector2(29,25)*p),Color("4c7950"))
	draw_rect(Rect2(c-Vector2(22,5)*p,Vector2(37,10)*p),Color("5e8b57"))
	draw_rect(Rect2(c-Vector2(11+shift,13)*p,Vector2(23,7)*p),Color("739957"))
	for i in range(9):
		var offset := Vector2(posmod(i*11+variation*3,38)-19,posmod(i*7+variation,21)-11)*p
		draw_rect(Rect2(c+offset,Vector2(3+i%2,2)*p),Color("95ad69") if i%3==0 else Color("648957"))


func _draw_life_overlay() -> void:
	if _life_observation.is_empty():
		return
	# These are actual child indices and the passes executed by this callback.
	_life_observation[&"overlay_child_indices"] = [_inactive_layer_art.get_index(), _life_overlay.get_index(), _layer_lens.get_index()]
	_life_observation[&"overlay_passes"] = []
	_life_observation[&"overlay_passes"].append(&"traffic")
	_draw_congestion_overlay(_life_overlay)
	_life_observation[&"overlay_passes"].append(&"cabins")
	_draw_elevators(_life_overlay)
	_draw_life_badges(_life_overlay)
	_life_observation[&"overlay_passes"].append(&"actors")
	for cabin: Dictionary in _life_observation.get(&"cabins", []):
		for actor: Dictionary in cabin[&"passengers"]:
			_draw_observed_actor(actor, _life_overlay)
	_draw_actors(_life_overlay)
	for badge: Dictionary in _life_observation.get(&"actor_aggregations", []):
		_draw_readable_badge(badge[&"at"], badge[&"text"], _life_overlay)
	_life_observation[&"overlay_consumed_serial"] = _life_observation[&"observation_serial"]


func _draw_congestion_overlay(canvas: CanvasItem) -> void:
	for row: Dictionary in _life_observation.get(&"traffic", []):
		var traffic: Dictionary = row[&"traffic"]
		var density := int(traffic[&"current_density"])
		var blocked := bool(traffic[&"temporary_block"])
		if density <= 0 and not blocked:
			continue
		var ratio := float(density) / maxi(int(traffic[&"base_capacity"]), 1)
		var color := Color("e34332") if blocked or ratio > 1.0 else Color("e3a62f") if ratio >= 0.65 else Color("55c96b")
		# At overview zoom a whole cell can be covered by opaque badges. Still
		# submit its density band, beneath those later draws, and report no visible
		# fragments. No traffic cell or raw density row is dropped.
		if row[&"segment_rects"].is_empty():
			canvas.draw_rect(row[&"band_rect"], Color(color, 0.90))
		for segment: Rect2 in row[&"segment_rects"]:
			canvas.draw_rect(segment, Color(color, 0.90))


## Visual-only level comes from the existing live FacilityInstance, not the
## construction footprint dictionary. No upgrade or simulation value is written.
func _facility_visual_level(instance_id: StringName) -> int:
	var service: Variant = _service(&"facility_service")
	if service == null or not service.has_method(&"get_instance"):
		return 1
	var instance: Variant = service.get_instance(instance_id)
	return int(instance.level) if instance != null else 1


func _completed_facilities_for_draw() -> Array:
	if _drawing and _draw_cache.has(&"completed_facilities"):
		return _draw_cache[&"completed_facilities"]
	var construction: Variant = _service(&"construction_service")
	var facilities: Array = construction.get_facilities() if construction != null else []
	if _drawing:
		_draw_cache[&"completed_facilities"] = facilities
	return facilities


func _construction_jobs_for_draw() -> Array:
	if _drawing and _draw_cache.has(&"construction_jobs"):
		return _draw_cache[&"construction_jobs"]
	var construction: Variant = _service(&"construction_service")
	var jobs: Array = construction.get_jobs() if construction != null else []
	if _drawing:
		_draw_cache[&"construction_jobs"] = jobs
	return jobs


func _other_layer_rooms() -> Array[Dictionary]:
	var rooms: Array[Dictionary] = []
	for facility: Dictionary in _completed_facilities_for_draw():
		if int(facility.get(&"layer", 0)) == layer_mode: continue
		var cells := _cells_from_serialized(facility.get(&"occupied_cells", []))
		if cells.is_empty(): continue
		var rect := _cell_bounds_rect(cells)
		if rect.intersects(Rect2(Vector2.ZERO, size)):
			rooms.append({&"id": facility[&"instance_id"], &"rect": rect, &"definition_id": facility[&"definition_id"], &"origin_x": cells[0].x,
				&"level": _facility_visual_level(StringName(facility[&"instance_id"]))})
	for job: ConstructionJob in _construction_jobs_for_draw():
		if job.layer == layer_mode or job.state in [ConstructionJob.State.COMPLETED, ConstructionJob.State.CANCELLED]: continue
		if job.reserved_cells.is_empty(): continue
		var rect := _cell_bounds_rect(job.reserved_cells)
		if rect.intersects(Rect2(Vector2.ZERO, size)):
			rooms.append({&"id":job.job_id, &"rect":rect, &"definition_id":job.definition_id,
				&"origin_x":job.origin.x, &"building":true, &"progress":job.progress})
	for stair: Dictionary in get_legacy_stair_records():
		var upper: Vector3i = stair[&"to"] if stair[&"to"].y > stair[&"from"].y else stair[&"from"]
		if upper.z == layer_mode: continue
		var rect := _legacy_stair_rect(upper)
		if rect.intersects(Rect2(Vector2.ZERO,size)):
			rooms.append({&"id":stair[&"id"], &"rect":rect, &"legacy_stair":true,
				&"definition_id":&"service_stairs" if upper.z==LayerMode.BACK else &"guest_stairs", &"origin_x":upper.x})
	return rooms


func _draw_other_layer() -> void:
	# Opposite-layer footprints are visual context only. Hit testing still uses
	# the selected layer's WorldService occupancy, including overlapping rooms.
	var rooms := _other_layer_rooms()
	if _inactive_layer_art != null:
		_inactive_layer_art.visible = layer_mode == LayerMode.FRONT
		_inactive_layer_art.set("rooms", rooms)
		_inactive_layer_art.set("pixel_scale", _cell_dimensions().x / 16.0)
		_inactive_layer_art.set("dungeon", visual_theme.pixel_dungeon_tiles if visual_theme != null else null)
		_inactive_layer_art.set("town", visual_theme.pixel_town_tiles if visual_theme != null else null)
		_inactive_layer_art.queue_redraw()
	for room: Dictionary in rooms:
		var rect: Rect2 = room[&"rect"]
		if bool(room.get(&"building", false)):
			var color := Color(0.95,0.76,0.35,0.45)
			draw_rect(rect.grow(-2),color,false,1.0)
			_draw_hatch(rect.grow(-3),Color(color,0.24),_cell_dimensions().x)
			_draw_readable_badge(rect.position+Vector2(3,3),"%s 工事%d%%" % ["裏" if layer_mode==LayerMode.FRONT else "表",clampi(int(room[&"progress"])/100,0,100)])
			continue
		if layer_mode == LayerMode.FRONT:
			draw_rect(rect.grow(-2), Color(0.6, 0.87, 0.9, 0.45), false, 1.0)
		else:
			var color := Color(0.94, 0.83, 0.54, 0.55)
			draw_rect(rect.grow(-2), color, false, 1.0)
			draw_line(rect.position+Vector2(3,3), rect.end-Vector2(3,3), Color(color,0.2), 1.0)


func _draw_world_entity_icons() -> void:
	var construction: Variant = _service(&"construction_service")
	if construction == null or not construction.has_method(&"get_facilities"):
		return
	var p := _cell_dimensions().x / 16.0
	var dungeon := visual_theme.pixel_dungeon_tiles if visual_theme != null else null
	var town := visual_theme.pixel_town_tiles if visual_theme != null else null
	for value: Variant in _completed_facilities_for_draw():
		if not value is Dictionary or int(value.get(&"layer",0)) != layer_mode:
			continue
		var facility: Dictionary = value
		var cells := _cells_from_serialized(facility.get(&"occupied_cells",[]))
		if cells.is_empty():
			continue
		var rect := _cell_bounds_rect(cells)
		if not rect.intersects(Rect2(Vector2.ZERO,size)):
			continue
		var id := StringName(facility.get(&"definition_id",&""))
		PixelArt.room(self,rect,id,p,dungeon,town,layer_mode==LayerMode.BACK,cells[0].x, _room_presentations.get(StringName(facility.get(&"instance_id", &"")), {}), _facility_visual_level(StringName(facility.get(&"instance_id", &""))))
		var instance_id := StringName(facility.get(&"instance_id",&""))
		# Thin status lamp conveys operational state without repainting a room.
		draw_rect(Rect2(rect.end.x-4*p,rect.position.y+2*p,2*p,2*p),_facility_color(instance_id))
		if instance_id==selected_entity_id:
			draw_rect(rect.grow(-p),Color("f5d582"),false,2*p)


func _readable_badge_rect(at: Vector2, text: String) -> Rect2:
	# A conservative em-width box makes geometry independently reproducible.
	# Text still uses the existing fallback font and size.
	return Rect2(at, Vector2(text.length() * 10 + 6, 14))


func _draw_readable_badge(at: Vector2, text: String, canvas: CanvasItem = null) -> void:
	if canvas == null:
		canvas = self
	var rect := _readable_badge_rect(at, text)
	if not rect.intersects(Rect2(Vector2.ZERO, size)):
		return
	canvas.draw_rect(rect, Color("34413b"))
	canvas.draw_string(ThemeDB.fallback_font, at+Vector2(3, 11), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 10, Color("fff2ce"))


func _prepare_room_presentations() -> void:
	var model := _observe_rooms()
	_room_presentations = model[&"rooms"]
	_carried_patients = model[&"carried_patients"]
	_treated_patients = model[&"treated_patients"]
	_resting_staff = model[&"resting_staff"]


func _observe_rooms() -> Dictionary:
	var _room_presentations: Dictionary = {}
	var _carried_patients: Dictionary = {}
	var _treated_patients: Dictionary = {}
	var _resting_staff: Dictionary = {}
	var service: Variant = _service(&"facility_service")
	if service == null:
		return {&"rooms": _room_presentations, &"carried_patients": _carried_patients, &"treated_patients": _treated_patients, &"resting_staff": _resting_staff}
	for instance: Variant in service.get_instances():
		if instance.origin.z == layer_mode:
			_room_presentations[instance.instance_id] = RoomPresentation.describe(instance)

	var staff: Variant = _service(&"staff_service")
	if staff != null:
		if _runtime is Object and _runtime.has_method("get_staff_rest_report"):
			for member: StaffRuntime in staff.get_staff_members():
				if member.state != StaffRuntime.State.OFF_DUTY: continue
				var rest: Dictionary = _runtime.get_staff_rest_report(member.staff_id)
				if not rest.get(&"resting", false): continue
				_resting_staff[member.staff_id] = rest[&"facility_id"]
				var room: Dictionary = _room_presentations.get(rest[&"facility_id"], {})
				if not room.is_empty():
					room[&"people"].append(member.staff_id)
					room[&"count"] += 1
		for task: StaffTask in staff.get_tasks():
			if task.task_type != &"rescue_transport" or task.is_terminal() or task.assigned_staff_id.is_empty(): continue
			if task.state == StaffTask.State.WORKING:
				_treated_patients[task.target_entity_id] = task.rescue_facility_id
				var data: Dictionary = _room_presentations.get(task.rescue_facility_id, {})
				if not data.is_empty():
					data[&"people"].append(task.target_entity_id)
					data[&"count"] += 1
			else:
				_carried_patients[task.target_entity_id] = task.assigned_staff_id

	return {&"rooms": _room_presentations, &"carried_patients": _carried_patients, &"treated_patients": _treated_patients, &"resting_staff": _resting_staff}


func _prepare_queues() -> void:
	var model := _observe_queues()
	_queue_positions = model[&"queue_positions"]
	_queue_hidden = model[&"queue_hidden"]
	_landing_badges.assign(model[&"landing_badges"])
	_transport_report = model[&"elevators"]


func _observe_queues() -> Dictionary:
	var _queue_positions: Dictionary = {}
	var _queue_hidden: Dictionary = {}
	var _landing_badges: Array[Dictionary] = []
	var _transport_report: Dictionary = {}
	var groups: Dictionary = {}
	var facilities: Variant = _service(&"facility_service")
	var guests: Variant = _service(&"guest_service")
	if facilities != null and guests != null:
		for instance: Variant in facilities.get_instances():
			for request_id: StringName in instance.queue_entries:
				var request: Variant = facilities.get_request(request_id)
				if request == null:
					continue
				for guest_id: StringName in request.guest_ids:
					var guest: Variant = guests.get_guest(guest_id)
					if guest != null and guest.state == GuestRuntime.State.QUEUING:
						_add_queue_member(groups, "guest/"+String(guest_id), guest.current_cell)
	var transport: Variant = _service(&"elevators")
	if transport != null:
		_transport_report = transport.get_report()
		var trips: Array = _transport_report.get(&"trips", [])
		trips.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a[&"enqueued_at"]) < int(b[&"enqueued_at"]) or (a[&"enqueued_at"] == b[&"enqueued_at"] and String(a[&"actor_id"]) < String(b[&"actor_id"])))
		for trip: Dictionary in trips:
			if trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED:
				var cell: Variant = trip[&"from"]
				if cell is Array:
					cell = Vector3i(cell[0], cell[1], cell[2])
				_add_queue_member(groups, _elevator_actor_key(String(trip[&"actor_id"])), cell)
	var lift_cells: Dictionary = {}
	for shaft: Dictionary in _transport_report.get(&"shafts", []):
		for stop: Vector3i in _cells_from_serialized(shaft.get(&"stops", [])):
			for offset: int in range(maxi(int(shaft.get(&"width", 2)), 1)):
				lift_cells[stop + Vector3i(offset, 0, 0)] = true
	var reserved: Dictionary = {}
	var cells: Array = groups.keys()
	cells.sort_custom(func(a: Vector3i, b: Vector3i) -> bool: return a.y < b.y or (a.y == b.y and a.x < b.x))
	for cell: Vector3i in cells:
		var members: Array = groups[cell]
		var lane: Array[Vector3i] = []
		if not lift_cells.has(cell):
			lane.append(cell)
		var world: Variant = _service(&"world_service")
		for direction: int in [-1, 1]:
			for distance: int in range(1, 9):
				var next := cell + Vector3i(direction*distance, 0, 0)
				if world == null or not world.is_walkable(next):
					break
				if lift_cells.has(next):
					break
				var occupant: StringName = world.get_occupant(next)
				var facility: Variant = facilities.get_instance(occupant) if facilities != null else null
				if facility != null and facility.definition_id not in [&"guest_corridor", &"service_corridor"]:
					break
				lane.append(next)
		var index := 0
		for target: Vector3i in lane:
			if index >= members.size():
				break
			if reserved.has(target):
				continue
			reserved[target] = true
			_queue_positions[members[index]] = cell_to_canvas(target)
			index += 1
		for hidden: int in range(index, members.size()):
			_queue_hidden[members[hidden]] = true
		var text := RoomPresentation.queue_label(members.size(), members.size()-index)
		_landing_badges.append({&"at": get_cell_rect(cell).position + Vector2(-80 if lift_cells.has(cell) else 0, -16), &"text": text, &"cell": cell, &"members": members.duplicate(), &"visible_count": index, &"overflow": members.size()-index})

	return {&"queue_positions": _queue_positions, &"queue_hidden": _queue_hidden, &"landing_badges": _landing_badges, &"elevators": _transport_report, &"shaft_footprint": lift_cells.keys()}


func _add_queue_member(groups: Dictionary, key: String, cell: Vector3i) -> void:
	if cell.z != layer_mode:
		return
	if not groups.has(cell):
		groups[cell] = []
	if not groups[cell].has(key):
		groups[cell].append(key)


func get_legacy_stair_records() -> Array[Dictionary]:
	if _drawing and _draw_cache.has(&"legacy_stair_records"):
		return _draw_cache[&"legacy_stair_records"]
	var result: Array[Dictionary] = []
	var service: Variant = _service(&"legacy_stairs")
	if service == null or not service.has_method(&"create_snapshot"):
		return result
	var snapshot: Variant = service.call(&"create_snapshot")
	if not snapshot is Dictionary:
		return result
	for row: Variant in snapshot.get(&"stairs", []):
		if row is Dictionary and row.get(&"from") is Vector3i and row.get(&"to") is Vector3i:
			var record: Dictionary = row.duplicate(true)
			var path: Variant = _service(&"path_service")
			var connector: Dictionary = path.call(&"get_connector", StringName(row[&"id"])) if path != null else {}
			record[&"access_id"] = connector.get(&"access_id", &"")
			result.append(record)
	if _drawing:
		_draw_cache[&"legacy_stair_records"] = result
	return result


func legacy_stair_at_canvas(canvas_position: Vector2) -> StringName:
	for stair: Dictionary in get_legacy_stair_records():
		var upper: Vector3i = stair[&"to"] if stair[&"to"].y > stair[&"from"].y else stair[&"from"]
		if upper.z == layer_mode and _legacy_stair_rect(upper).has_point(canvas_position):
			return StringName(stair[&"id"])
	return &""


func _legacy_stair_rect(upper: Vector3i) -> Rect2:
	var rect := get_cell_rect(upper)
	# A small target leaves the rest of an existing room available for selection.
	return Rect2(rect.position+rect.size*Vector2(0.49,0.24), rect.size*Vector2(0.5,0.7))


func _draw_legacy_stairs() -> void:
	for stair: Dictionary in get_legacy_stair_records():
		var upper: Vector3i = stair[&"to"] if stair[&"to"].y > stair[&"from"].y else stair[&"from"]
		if upper.z != layer_mode:
			continue
		var rect := _legacy_stair_rect(upper)
		if not rect.intersects(Rect2(Vector2.ZERO, size)):
			continue
		var p := rect.size.x/14.0
		draw_rect(rect, Color("3c4e40",0.5))
		PixelArt.stairs(self,rect,p,upper.z == LayerMode.BACK,true)
		if StringName(stair[&"id"]) == selected_entity_id:
			draw_rect(rect,Color("f5d582"),false,maxf(p,1))


func _cell_bounds_rect(cells: Array[Vector3i]) -> Rect2:
	if cells.is_empty():
		return Rect2()
	var result := get_cell_rect(cells[0])
	for index: int in range(1, cells.size()):
		result = result.merge(get_cell_rect(cells[index]))
	return result


func _draw_hatch(rect: Rect2, color: Color, cell_size: float) -> void:
	if cell_size < 8.0:
		return
	var offset := -rect.size.y
	while offset < rect.size.x:
		var start := Vector2(rect.position.x + maxf(offset, 0.0), rect.end.y - maxf(-offset, 0.0))
		var finish := Vector2(rect.position.x + minf(offset + rect.size.y, rect.size.x), rect.end.y - minf(rect.size.y, rect.size.x - offset))
		draw_line(start, finish, color, 1.0)
		offset += maxf(6.0, cell_size * 0.45)


func _draw_construction_labels() -> void:
	var construction: Variant = _service(&"construction_service")
	if construction == null or not construction.has_method(&"get_jobs"):
		return
	var font := ThemeDB.fallback_font
	for job: Variant in _construction_jobs_for_draw():
		if job == null or int(job.layer) != layer_mode or int(job.state) in [2, 3]:
			continue
		var rect := get_cell_rect(job.origin)
		if rect.size.x < 12.0:
			continue
		var percent := clampi(int(job.progress) / 100, 0, 100)
		draw_string(font, rect.position + Vector2(3, -3), "%d%%" % percent, HORIZONTAL_ALIGNMENT_LEFT, -1, clampi(int(rect.size.x * 0.45), 9, 13), Color("f5d58a"))


func _draw_facility_labels() -> void:
	var construction: Variant = _service(&"construction_service")
	var registry: Variant = _service(&"definition_registry")
	if construction == null or not construction.has_method(&"get_facilities"):
		return
	for value: Variant in _completed_facilities_for_draw():
		if not value is Dictionary or int(value.get(&"layer",0))!=layer_mode:
			continue
		var facility: Dictionary = value
		var cells := _cells_from_serialized(facility.get(&"occupied_cells",[]))
		if cells.is_empty():
			continue
		var rect := _cell_bounds_rect(cells)
		var id := StringName(facility.get(&"definition_id",&""))
		var hovered := _hovered_cell is Vector3i and cells.has(_hovered_cell)
		var selected := StringName(facility.get(&"instance_id",&""))==selected_entity_id
		if not hovered and not selected and rect.size.x<100:
			continue
		if id in [&"guest_corridor",&"service_corridor",&"staff_door",&"guest_stairs",&"service_stairs"] and not hovered and not selected:
			continue
		var definition: Variant = registry.call(&"get_facility",id) if registry != null else null
		var label := String(definition.display_name) if definition != null else String(id)
		var font_size := 12
		var label_width := minf(ThemeDB.fallback_font.get_string_size(label,HORIZONTAL_ALIGNMENT_LEFT,-1,font_size).x+12,rect.size.x)
		var at := Vector2(rect.get_center().x-label_width*0.5,rect.position.y-17)
		draw_rect(Rect2(at,Vector2(label_width,16)),Color("304b41",0.95))
		draw_string(ThemeDB.fallback_font,at+Vector2(4,12),label,HORIZONTAL_ALIGNMENT_CENTER,label_width-8,font_size,Color("f0e2b9"))


func _draw_elevators(canvas: CanvasItem) -> void:
	for row: Dictionary in _life_observation.get(&"cabins", []):
		PixelArt.elevator_cabin(canvas, row[&"rect"], row[&"pixel"], layer_mode == LayerMode.BACK, row[&"operational"])
		PixelArt.elevator_gate(canvas, row[&"rect"], row[&"pixel"], row[&"doors_open"])
		_draw_readable_badge(row[&"badge_at"], row[&"badge"], canvas)
	for badge: Dictionary in _life_observation.get(&"landing_badges", []):
		_draw_readable_badge(badge[&"at"], badge[&"text"], canvas)


func _observe_elevators(observation: Dictionary) -> Array[Dictionary]:
	var cabins: Array[Dictionary] = []
	var _boarded_actor_ids: Dictionary = observation[&"boarded"]
	var _elevator_tracks: Dictionary = observation[&"elevator_tracks"]
	var report_value: Dictionary = observation[&"elevators"]
	var alive: Dictionary = {}
	var p := _cell_dimensions().x/16.0
	for value: Variant in report_value.get(&"shafts", []):
		if not value is Dictionary:
			continue
		var shaft: Dictionary = value
		var stops := _cells_from_serialized(shaft.get(&"stops", []))
		if stops.is_empty():
			continue
		var shaft_id := StringName(shaft.get(&"shaft_id", &""))
		alive[shaft_id] = true
		var passengers: Array = shaft.get(&"boarded", [])
		for actor_id: Variant in passengers:
			_boarded_actor_ids[_elevator_actor_key(String(actor_id))] = true
		var floor_value := float(shaft.get(&"visual_floor", shaft.get(&"current_floor", stops[0].y)))
		var visual_floor := _tracked_elevator_floor(shaft_id, floor_value, observation)
		if stops[0].z != layer_mode:
			continue
		var origin := get_cell_rect(stops[0])
		var width := maxi(int(shaft.get(&"width", 2)), 1)
		var baseline := origin.end.y-float(visual_floor-stops[0].y)*_cell_dimensions().y-3*p
		var cabin := Rect2(Vector2(origin.position.x+4*p,baseline-19*p).round(),Vector2(width*_cell_dimensions().x-8*p,19*p))
		if not cabin.intersects(Rect2(Vector2.ZERO,size)):
			continue
		var operational := bool(shaft.get(&"operational", true))
		var phase := StringName(shaft.get(&"phase", &"IDLE"))
		var doors_open := operational and phase in [&"IDLE", &"BOARDING", &"ALIGHTING"]
		var passengers_drawn: Array[Dictionary] = []
		var seat_columns := maxi(int((cabin.size.x / p - 4.0) / 8.0), 1)
		for index: int in range(passengers.size()):
			var actor_key := _elevator_actor_key(String(passengers[index]))
			var is_staff := actor_key.begins_with("staff/")
			var person: Variant = null
			var people: Variant = _service(&"staff_service" if is_staff else &"guest_service")
			var getter := &"get_staff" if is_staff else &"get_guest"
			if people != null and people.has_method(getter):
				person = people.call(getter,StringName(actor_key.get_slice("/",1)))
			var carrying := is_staff and person != null and not String(person.carried_inventory_id).is_empty()
			var state := int(person.state) if person != null else 0
			var x := cabin.position.x + (2.0 + (index % seat_columns + 0.5)*8.0)*p
			var feet := cabin.end.y - (2.0 + (int(index / seat_columns) % 2)*9.0)*p
			var center := Vector2(x, feet+3*p-_cell_dimensions().y*0.5)
			passengers_drawn.append(_actor_drawing(center,actor_key,is_staff,state,false,carrying,&"",true,observation))
		cabins.append({&"shaft_id": shaft_id, &"rect": cabin, &"pixel": p,
			&"operational": operational, &"doors_open": doors_open,
			&"phase": phase, &"passenger_count": passengers.size(),
			&"passengers": passengers_drawn,
			&"badge_at": Vector2(cabin.position.x, cabin.position.y-14),
			&"badge_rect": _readable_badge_rect(Vector2(cabin.position.x, cabin.position.y-14), RoomPresentation.elevator_label(shaft)),
			&"badge": RoomPresentation.elevator_label(shaft)})

	for shaft_id: Variant in _elevator_tracks.keys():
		if not alive.has(shaft_id):
			_elevator_tracks.erase(shaft_id)

	return cabins


func _elevator_actor_key(actor_id: String) -> String:
	if actor_id.begins_with("guest:"):
		return "guest/" + actor_id.trim_prefix("guest:")
	if actor_id.begins_with("staff:"):
		return "staff/" + actor_id.trim_prefix("staff:")
	return actor_id


func _tracked_elevator_floor(shaft_id: StringName, current_floor: float, observation: Dictionary = {}) -> float:
	var _elevator_tracks: Dictionary = observation.get(&"elevator_tracks", self._elevator_tracks)
	if not _elevator_tracks.has(shaft_id):
		_elevator_tracks[shaft_id] = {&"floor":current_floor,&"from":current_floor,&"at":_animation_time}
	var track: Dictionary = _elevator_tracks[shaft_id]
	if not is_equal_approx(float(track[&"floor"]),current_floor):
		track[&"from"] = float(track[&"floor"])
		track[&"floor"] = current_floor
		track[&"at"] = _animation_time
	var clock := get_node_or_null("/root/GameClock")
	if clock != null and bool(clock.get("paused")):
		return current_floor
	var t := clampf((_animation_time-float(track[&"at"]))/0.42,0.0,1.0)
	return lerpf(float(track[&"from"]),current_floor,t)


func _draw_actors(canvas: CanvasItem = null) -> void:
	for row: Dictionary in _life_observation.get(&"actor_drawings", []):
		_draw_observed_actor(row, canvas)


func _observe_actors(observation: Dictionary) -> Array[Dictionary]:
	var rows: Array[Dictionary] = []
	var _actor_tracks: Dictionary = observation[&"actor_tracks"]
	var _boarded_actor_ids: Dictionary = observation[&"boarded"]
	var _queue_hidden: Dictionary = observation[&"queue_hidden"]
	var _queue_positions: Dictionary = observation[&"queue_positions"]
	var _carried_patients: Dictionary = observation[&"carried_patients"]
	var _treated_patients: Dictionary = observation[&"treated_patients"]
	var _resting_staff: Dictionary = observation[&"resting_staff"]
	var counts: Dictionary = {}
	var alive: Dictionary = {}
	var service_slots: Dictionary = {}
	var guest_service: Variant = _service(&"guest_service")
	if guest_service != null and guest_service.has_method(&"get_guests"):
		for guest: Variant in guest_service.call(&"get_guests"):
			if guest == null:
				continue
			var key := "guest/"+String(guest.guest_id)
			alive[key] = true
			if _boarded_actor_ids.has(key) or _queue_hidden.has(key):
				_actor_tracks.erase(key)
				continue
			var position := _tracked_actor_position(key,guest.current_cell,observation)
			if guest.current_cell.z==layer_mode:
				var receiving := not String(guest.current_service_id).is_empty()
				if _queue_positions.has(key):
					position = _queue_positions[key]
				elif receiving:
					position = _service_use_position(guest,position,service_slots,observation)
					if not position.is_finite():
						continue
				else:
					position += _actor_offset(guest.current_cell,counts)
				rows.append(_actor_drawing(position,key,false,int(guest.state),receiving,false,_guest_activity(guest),false,observation))
	var staff_service: Variant = _service(&"staff_service")
	if staff_service != null and staff_service.has_method(&"get_staff_members"):
		for staff: Variant in staff_service.call(&"get_staff_members"):
			if staff == null:
				continue
			var key := "staff/"+String(staff.staff_id)
			alive[key] = true
			if _boarded_actor_ids.has(key) or _queue_hidden.has(key):
				_actor_tracks.erase(key)
				continue
			var position := _tracked_actor_position(key,staff.current_cell,observation)
			if staff.current_cell.z==layer_mode:
				if _queue_positions.has(key):
					rows.append(_actor_drawing(_queue_positions[key],key,true,int(staff.state),false,not String(staff.carried_inventory_id).is_empty(),_staff_work_animation(staff),false,observation))
					continue
				if _carried_patients.has(staff.staff_id):
					var carrier: Variant = staff_service.get_staff(_carried_patients[staff.staff_id])
					if carrier != null:
						position = _tracked_actor_position("staff/"+String(carrier.staff_id), carrier.current_cell,observation) - Vector2(0, 12*_cell_dimensions().x/16.0)
					rows.append(_actor_drawing(position,key,true,int(staff.state),false,false,&"",false,observation))
					continue
				if _resting_staff.has(staff.staff_id):
					position = _resting_staff_position(staff.staff_id, position,observation)
					if position.is_finite(): rows.append(_actor_drawing(position,key,true,int(staff.state),false,false,&"",false,observation))
					continue
				if _treated_patients.has(staff.staff_id):
					position = _rescue_treatment_position(staff.staff_id, position,observation)
					if position.is_finite(): rows.append(_actor_drawing(position,key,true,int(staff.state),false,false,&"",false,observation))
					continue
				rows.append(_actor_drawing(_queue_positions.get(key, _staff_work_position(staff, position+_actor_offset(staff.current_cell,counts),observation)),key,true,int(staff.state),false,not String(staff.carried_inventory_id).is_empty(),_staff_work_animation(staff),false,observation))
	for key: Variant in _actor_tracks.keys():
		if not alive.has(key):
			_actor_tracks.erase(key)

	return rows


func _staff_work_animation(staff: StaffRuntime) -> StringName:
	if staff.role_id != &"cleaner":
		return &"work"
	# A staffed base is not a cleaning site. Animate only a live job at its
	# authoritative destination, including after load and interruption.
	var service: Variant = _service(&"staff_service")
	var bridge: Variant = _service(&"cleaning_bridge")
	var task: StaffTask = service.get_task(staff.current_task_id) if service != null else null
	if staff.state == StaffRuntime.State.WORKING and task != null and task.task_type == FacilityCleaningBridge.TASK_TYPE and task.state == StaffTask.State.WORKING and bridge != null and bridge.task_can_work(task, staff):
		return &"clean"
	return &"idle"


func _staff_work_position(staff: Variant, fallback: Vector2, observation: Dictionary = {}) -> Vector2:
	var _room_presentations: Dictionary = observation.get(&"rooms", self._room_presentations)
	if staff.state not in [StaffRuntime.State.WORKING, StaffRuntime.State.AVAILABLE]:
		return fallback
	var service: Variant = _service(&"facility_service")
	var facility: Variant = service.get_instance(staff.assigned_facility_id) if service != null else null
	if facility == null:
		return fallback
	var world: Variant = _service(&"world_service")
	var cells: Array[Vector3i] = world.get_entity_cells(staff.assigned_facility_id) if world != null else []
	if not cells.has(staff.current_cell):
		return fallback
	var data: Dictionary = _room_presentations.get(staff.assigned_facility_id, {})
	var workers: Array = data.get(&"staff", [])
	var slot := workers.find(staff.staff_id)
	if slot < 0:
		return fallback
	var rect := _cell_bounds_rect(cells)
	var p := _cell_dimensions().x / 16.0
	if rect.size.y < 40*p:
		return fallback
	var columns := maxi(int(rect.size.x / (16*p)), 1)
	if slot >= columns:
		return fallback
	return Vector2(rect.position.x+(slot+0.5)*rect.size.x/columns, rect.position.y+26*p-_cell_dimensions().y*0.5)


func _tracked_actor_position(key: String, cell: Vector3i, observation: Dictionary = {}) -> Vector2:
	var _actor_tracks: Dictionary = observation.get(&"actor_tracks", self._actor_tracks)
	if not _actor_tracks.has(key):
		_actor_tracks[key] = {&"cell":cell,&"from":cell,&"at":_animation_time,&"direction":1.0}
	var track: Dictionary = _actor_tracks[key]
	var previous: Vector3i = track[&"cell"]
	if previous != cell:
		track[&"from"] = previous if previous.z==cell.z and absi(previous.x-cell.x)+absi(previous.y-cell.y)==1 else cell
		track[&"cell"] = cell
		track[&"at"] = _animation_time
		if previous.x!=cell.x:
			track[&"direction"] = 1.0 if cell.x>previous.x else -1.0
	var clock := get_node_or_null("/root/GameClock")
	var t := clampf((_animation_time-float(track[&"at"]))/0.42,0.0,1.0)
	# Pausing displays the authoritative cell, never an ambiguous half-step.
	if clock != null and bool(clock.get("paused")):
		t = 1.0
	return cell_to_canvas(track[&"from"]).lerp(cell_to_canvas(cell),t)


func _service_use_position(guest: Variant, fallback: Vector2, _slots: Dictionary, observation: Dictionary = {}) -> Vector2:
	var _room_presentations: Dictionary = observation.get(&"rooms", self._room_presentations)
	# Guest paths stop at an access cell. While a service is active, project the
	# occupant into that facility's use position; authoritative paths stay intact.
	var id := StringName(guest.target_facility_id)
	var world: Variant = _service(&"world_service")
	if world==null or id.is_empty():
		return fallback
	var cells := _cells_from_serialized(world.call(&"get_entity_cells",id))
	if cells.is_empty() or cells[0].z!=layer_mode:
		return fallback
	var rect := _cell_bounds_rect(cells)
	var data: Dictionary = _room_presentations.get(id, {})
	var slot := (data.get(&"people", []) as Array).find(guest.guest_id)
	if slot < 0:
		return fallback
	var p := _cell_dimensions().x/16.0
	var anchors := RoomPresentation.slots(rect, p, _guest_activity(guest))
	if slot >= anchors.size():
		return Vector2(INF, INF)
	var at := anchors[slot]
	if _guest_activity(guest) in [&"standard_room", &"premium_room"]:
		at.y -= 5*p
	return at + Vector2(0, 3*p-_cell_dimensions().y*0.5)



func _guest_activity(guest: Variant) -> StringName:
	if String(guest.target_facility_id).is_empty():
		return &""
	var service: Variant = _service(&"facility_service")
	var facility: Variant = service.call(&"get_instance",guest.target_facility_id) if service != null else null
	return StringName(facility.definition_id) if facility != null else &""


func _draw_pixel_actor(center: Vector2, key: String, staff: bool, state: int, receiving: bool, carrying: bool, activity: StringName = &"", riding := false) -> void:
	_draw_observed_actor(_actor_drawing(center, key, staff, state, receiving, carrying, activity, riding))


func _draw_observed_actor(row: Dictionary, canvas: CanvasItem = null) -> void:
	if canvas == null:
		canvas = self
	var at: Vector2 = row[&"at"]
	var p: float = row[&"scale"]
	canvas.draw_rect(Rect2(at + Vector2(5, 17) * p, Vector2(8, 1) * p), Color("303c40", 0.2))
	People.draw(canvas, at, p, row[&"mode"], row[&"frame"], row[&"variant"], row[&"staff"], row[&"flip"])


func _actor_drawing(center: Vector2, key: String, staff: bool, state: int, receiving: bool, carrying: bool, activity: StringName = &"", riding := false, observation: Dictionary = {}) -> Dictionary:
	var _actor_tracks: Dictionary = observation.get(&"actor_tracks", self._actor_tracks)
	var _queue_positions: Dictionary = observation.get(&"queue_positions", self._queue_positions)
	var world_pixel := _cell_dimensions().x / 16.0
	var scale := 0.5 if riding else 0.75
	var p := maxf(floorf(world_pixel * scale), 1.0) if world_pixel >= 2.0 else world_pixel * scale
	var track: Dictionary = _actor_tracks.get(key, {})
	var moving: bool = not receiving and not _queue_positions.has(key) and track.get(&"cell", Vector3i.ZERO) != track.get(&"from", Vector3i.ZERO) and _animation_time - float(track.get(&"at", 0.0)) < 0.42
	var clock := get_node_or_null("/root/GameClock")
	var paused := clock != null and bool(clock.get("paused"))
	moving = moving and not paused and not riding
	var variant := int(absi(key.hash())) % 15
	var frame := int(_animation_time * 12.0) + variant
	var mode: StringName = &"idle"
	if staff and state == StaffRuntime.State.INCAPACITATED:
		mode = &"collapsed"
	elif receiving and activity in [&"standard_room", &"premium_room"]:
		mode = &"standard_room_sleep" if activity == &"standard_room" else &"sleep"
	elif moving:
		mode = &"carry_walk" if carrying else &"walk"
	elif carrying:
		mode = &"carry"
	elif staff and state == StaffRuntime.State.WORKING and not riding:
		mode = activity if activity in [&"clean", &"idle", &"work"] else &"work"
	elif receiving and activity in [&"cafeteria", &"tavern"]:
		mode = &"eat"
	elif _queue_positions.has(key) or (not staff and state == GuestRuntime.State.QUEUING):
		mode = &"wait"
	var at := (center + Vector2(-8 * p, _cell_dimensions().y * 0.5 - 3 * world_pixel - 17 * p)).round()
	return {&"key": key, &"center": center, &"at": at, &"scale": p, &"mode": mode,
		&"frame": frame, &"variant": variant, &"staff": staff, &"state": state,
		&"receiving": receiving, &"carrying": carrying, &"activity": activity,
		&"riding": riding, &"flip": float(track.get(&"direction", 1.0)) < 0.0}


## Identity cores are tied to the unchanged PixelPeople poses, not sprite boxes.
func _life_core(row: Dictionary) -> Rect2:
	var phase := int(row[&"frame"]) % 8
	var core := Rect2(6, 1, 6, 12)
	if row[&"mode"] in [&"sleep", &"collapsed", &"standard_room_sleep"]:
		core = Rect2(2, 11, 13, 5)
		if row[&"mode"] == &"standard_room_sleep" and phase in [2, 3, 4]:
			core.position.y -= 1
			core.size.y += 1
	elif row[&"mode"] in [&"walk", &"carry_walk"] and phase in [2, 6]:
		core.position.y -= 1
	if row[&"flip"]:
		core.position.x = 16-core.end.x
	return Rect2(row[&"at"]+core.position*row[&"scale"], core.size*row[&"scale"])


func _life_track_region(key: String, cell: Vector3i, model: Dictionary) -> Dictionary:
	var track: Dictionary = model[&"actor_tracks"].get(key, {})
	var transit: bool = not model[&"paused"] and track.get(&"from", cell) != cell and _animation_time-float(track.get(&"at", 0.0)) < 0.42
	var rect := get_cell_rect(cell)
	if transit:
		rect = rect.merge(get_cell_rect(track[&"from"]))
	return {&"bounds": rect, &"transit": transit}


## Keep non-passengers on a real corridor landing when a cabin crosses their pose.
func _life_landing_region(region: Dictionary, cell: Vector3i, model: Dictionary) -> Dictionary:
	var blocked := false
	for cabin: Dictionary in model[&"cabins"]:
		if (region[&"bounds"] as Rect2).intersects(cabin[&"rect"]):
			blocked = true
			break
	if not blocked:
		return region
	var world: Variant = _service(&"world_service")
	var facilities: Variant = _service(&"facility_service")
	var footprint: Array = model[&"shaft_footprint"]
	var origins: Array = footprint.filter(func(c: Vector3i) -> bool: return c.y == cell.y and c.z == cell.z)
	origins.sort_custom(func(a: Vector3i, b: Vector3i) -> bool: return absi(a.x-cell.x) < absi(b.x-cell.x) or (absi(a.x-cell.x) == absi(b.x-cell.x) and a.x < b.x))
	var lane: Array[Vector3i] = []
	for origin: Vector3i in origins:
		for direction: int in [-1, 1]:
			for distance: int in range(1, 9):
				var target := origin + Vector3i(direction*distance, 0, 0)
				if world == null or footprint.has(target) or not world.is_walkable(target):
					break
				var facility: Variant = facilities.get_instance(world.get_occupant(target)) if facilities != null else null
				if facility != null and facility.definition_id not in [&"guest_corridor", &"service_corridor"]:
					break
				if not lane.has(target):
					lane.append(target)
	lane.sort_custom(func(a: Vector3i, b: Vector3i) -> bool: return absi(a.x-cell.x) < absi(b.x-cell.x) or (absi(a.x-cell.x) == absi(b.x-cell.x) and a.x < b.x))
	if lane.is_empty():
		var anchor := get_cell_rect(cell).position+Vector2(-80, -16)
		var nearest := INF
		for badge: Dictionary in model[&"landing_badges"]:
			var landing: Vector3i = badge[&"cell"]
			if landing.y == cell.y and landing.z == cell.z and absf(landing.x-cell.x) < nearest:
				nearest = absf(landing.x-cell.x)
				anchor = badge[&"at"]
		return {&"bounds": region[&"bounds"], &"transit": false, &"no_lane": true, &"badge_at": anchor}
	var selected: Vector3i = lane[0]
	for target: Vector3i in lane:
		if not model[&"queue_positions"].values().has(cell_to_canvas(target)):
			selected = target
			break
	return {&"bounds": get_cell_rect(selected), &"transit": false, &"center": cell_to_canvas(selected)}


func _life_actor_region(row: Dictionary, person: Dictionary, model: Dictionary) -> Dictionary:
	var key: String = row[&"key"]
	var cell: Vector3i = person[&"cell"]
	var result := _life_track_region(key, cell, model)
	var p := _cell_dimensions().x/16.0
	var world: Variant = _service(&"world_service")
	var facility_id: StringName = &""
	if model[&"queue_positions"].has(key):
		result = {&"bounds": Rect2(model[&"queue_positions"][key]-_cell_dimensions()*0.5, _cell_dimensions()), &"transit": false}
	elif not row[&"staff"]:
		if not String(person[&"service"]).is_empty():
			var room: Dictionary = model[&"rooms"].get(person[&"target"], {})
			if (room.get(&"people", []) as Array).has(person[&"id"]):
				facility_id = person[&"target"]
	else:
		var id: StringName = person[&"id"]
		if model[&"carried_patients"].has(id):
			for carrier: Dictionary in model[&"staff"]:
				if carrier[&"id"] == model[&"carried_patients"][id]:
					result = _life_actor_region({&"key": "staff/"+String(carrier[&"id"]), &"staff": true}, carrier, model)
					var rect: Rect2 = result[&"bounds"]
					rect = rect.grow(p)
					rect.position.y -= 12*p
					result[&"bounds"] = rect
					if result.has(&"center"):
						result[&"center"] -= Vector2(0, 12*p)
		elif model[&"resting_staff"].has(id):
			facility_id = model[&"resting_staff"][id]
		elif model[&"treated_patients"].has(id):
			facility_id = model[&"treated_patients"][id]
		elif person[&"state"] in ["WORKING", "AVAILABLE"] and world != null:
			var cells: Array[Vector3i] = world.get_entity_cells(person[&"facility"])
			var room: Dictionary = model[&"rooms"].get(person[&"facility"], {})
			var slot := (room.get(&"staff", []) as Array).find(id)
			var rect := _cell_bounds_rect(cells)
			var columns := maxi(int(rect.size.x/(16*p)), 1)
			if cells.has(cell) and slot >= 0 and slot < columns and rect.size.y >= 40*p:
				result = {&"bounds": Rect2(rect.position+Vector2(slot*rect.size.x/columns, 0), Vector2(rect.size.x/columns, rect.size.y)), &"transit": false}
	if not facility_id.is_empty() and world != null:
		var cells: Array[Vector3i] = world.get_entity_cells(facility_id)
		if not cells.is_empty():
			result = {&"bounds": _cell_bounds_rect(cells), &"transit": false}
	if not model[&"queue_positions"].has(key) and not result.has(&"center") and not result.get(&"no_lane", false):
		result = _life_landing_region(result, cell, model)
	result[&"bounds"] = (result[&"bounds"] as Rect2).grow(-p)
	return result


func _life_scaled(row: Dictionary, scale: float) -> Dictionary:
	var result := row.duplicate(true)
	result[&"scale"] = scale
	result[&"at"] = (row[&"center"]+Vector2(-8*scale, _cell_dimensions().y*0.5-3*_cell_dimensions().x/16.0-17*scale)).round()
	return result


## Forward/backward packing keeps each member's own interval. No relocation.
func _life_pack(group: Array, scale: float) -> Array:
	var trial: Array = []
	var right := -INF
	for member: Dictionary in group:
		var row := _life_scaled(member[&"row"], minf(scale, member[&"row"][&"scale"]))
		var core := _life_core(row)
		var bounds: Rect2 = member[&"bounds"]
		var x := maxf(maxf(core.position.x, bounds.position.x), right)
		row[&"at"].x += x-core.position.x
		right = x+core.size.x
		trial.append(row)
	var left := INF
	for i: int in range(trial.size()-1, -1, -1):
		var row: Dictionary = trial[i]
		var core := _life_core(row)
		var bounds: Rect2 = group[i][&"bounds"]
		var x := minf(core.position.x, minf(bounds.end.x, left)-core.size.x)
		row[&"at"].x += x-core.position.x
		left = x
		if x < bounds.position.x or core.position.y < bounds.position.y or core.end.y > bounds.end.y:
			return []
	return trial


## Rebuild vertical components at every fitted scale. Ceilings only decrease;
## once none decreases, the feet/cores and therefore the partition are fixed.
func _life_fitted_groups(members: Array) -> Array:
	var groups: Array = []
	while true:
		for member: Dictionary in members:
			member[&"row"].merge(_life_scaled(member[&"row"], member[&"row"][&"scale"]), true)
		members.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
			var ay := _life_core(a[&"row"]).position.y
			var by := _life_core(b[&"row"]).position.y
			return ay < by or (ay == by and a[&"row"][&"key"] < b[&"row"][&"key"]))
		groups = []
		var bottom := -INF
		for member: Dictionary in members:
			var core := _life_core(member[&"row"])
			if groups.is_empty() or core.position.y >= bottom:
				groups.append([])
				bottom = core.end.y
			else:
				bottom = maxf(bottom, core.end.y)
			groups.back().append(member)
		var reduced := false
		for group: Array in groups:
			group.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a[&"row"][&"center"].x < b[&"row"][&"center"].x or (a[&"row"][&"center"].x == b[&"row"][&"center"].x and a[&"row"][&"key"] < b[&"row"][&"key"]))
			var ceiling := 1.0
			for member: Dictionary in group:
				ceiling = maxf(ceiling, member[&"row"][&"scale"])
			if _life_pack(group, ceiling).is_empty() and ceiling > 1:
				for member: Dictionary in group:
					var scale := minf(member[&"row"][&"scale"], maxf(1, int(ceiling)-1))
					member[&"row"].merge(_life_scaled(member[&"row"], scale), true)
				reduced = true
		if not reduced:
			return groups
	return groups


func _fit_life_actors(model: Dictionary) -> void:
	var people: Dictionary = {}
	for person: Dictionary in model[&"guests"]:
		people["guest/"+String(person[&"id"])] = person
	for person: Dictionary in model[&"staff"]:
		people["staff/"+String(person[&"id"])] = person
	var members: Array = []
	var stranded: Array = []
	var all_rows: Array = model[&"actor_drawings"].duplicate()
	var passenger_bounds: Dictionary = {}
	for cabin: Dictionary in model[&"cabins"]:
		for row: Dictionary in cabin[&"passengers"]:
			all_rows.append(row)
			passenger_bounds[row[&"key"]] = (cabin[&"rect"] as Rect2).grow(-_cell_dimensions().x/16.0)
	for row: Dictionary in all_rows:
		var person: Dictionary = people[row[&"key"]]
		row[&"cell"] = person[&"cell"]
		var region := _life_actor_region(row, person, model) if not row[&"riding"] else {&"bounds": passenger_bounds[row[&"key"]], &"transit": false}
		if region.has(&"center"):
			var jitter := Vector2.ZERO
			if not model[&"carried_patients"].has(person[&"id"]):
				var track: Dictionary = model[&"actor_tracks"][row[&"key"]]
				var t := 1.0 if model[&"paused"] else clampf((_animation_time-float(track[&"at"]))/0.42, 0, 1)
				jitter = row[&"center"]-cell_to_canvas(track[&"from"]).lerp(cell_to_canvas(person[&"cell"]), t)
			row[&"center"] = region[&"center"]+jitter
			row.merge(_life_scaled(row, row[&"scale"]), true)
		region[&"row"] = row
		if region.get(&"no_lane", false):
			stranded.append(region)
		elif not region[&"transit"]:
			members.append(region)
	var groups := _life_fitted_groups(members)
	model[&"actor_aggregations"] = []
	var hidden: Dictionary = {}
	for member: Dictionary in stranded:
		var row: Dictionary = member[&"row"]
		hidden[row[&"key"]] = true
		var aggregate: Dictionary = {}
		for existing: Dictionary in model[&"actor_aggregations"]:
			if existing[&"at"] == member[&"badge_at"]:
				aggregate = existing
				break
		if aggregate.is_empty():
			aggregate = {&"members": [], &"drawn_keys": [], &"at": member[&"badge_at"], &"text": ""}
			model[&"actor_aggregations"].append(aggregate)
		aggregate[&"members"].append({&"key": row[&"key"], &"id": people[row[&"key"]][&"id"], &"state": row[&"state"], &"cell": row[&"cell"], &"mode": row[&"mode"]})
		aggregate[&"text"] = "+%d" % aggregate[&"members"].size()
	for group: Array in groups:
		group.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a[&"row"][&"center"].x < b[&"row"][&"center"].x or (a[&"row"][&"center"].x == b[&"row"][&"center"].x and a[&"row"][&"key"] < b[&"row"][&"key"]))
		var base := 1.0
		for member: Dictionary in group:
			base = maxf(base, member[&"row"][&"scale"])
		var fitted := _life_pack(group, base)
		var retained: Array = group
		if fitted.is_empty():
			retained = []
			var identities: Array = []
			var drawn: Array = []
			for member: Dictionary in group:
				var row: Dictionary = member[&"row"]
				identities.append({&"key": row[&"key"], &"id": people[row[&"key"]][&"id"], &"state": row[&"state"], &"cell": row[&"cell"], &"mode": row[&"mode"]})
				var attempt := retained.duplicate()
				attempt.append(member)
				var packed := _life_pack(attempt, 1)
				if not packed.is_empty():
					retained = attempt
					fitted = packed
					drawn.append(row[&"key"])
				else:
					hidden[row[&"key"]] = true
			model[&"actor_aggregations"].append({&"members": identities, &"drawn_keys": drawn, &"at": (group[0][&"bounds"] as Rect2).position, &"text": "+%d" % (group.size()-retained.size())})
		for i: int in fitted.size():
			retained[i][&"row"].merge(fitted[i], true)
	model[&"actor_drawings"] = model[&"actor_drawings"].filter(func(row: Dictionary) -> bool: return not hidden.has(row[&"key"]))
	for cabin: Dictionary in model[&"cabins"]:
		cabin[&"passengers"] = cabin[&"passengers"].filter(func(row: Dictionary) -> bool: return not hidden.has(row[&"key"]))


func _draw_excavation_jobs() -> void:
	var excavation: Variant = _service(&"excavation_service")
	if excavation == null or not excavation.has_method(&"get_jobs"):
		return
	var font := ThemeDB.fallback_font
	for job_value: Variant in excavation.call(&"get_jobs"):
		if not job_value is Dictionary:
			continue
		var job: Dictionary = job_value
		var state := StringName(job.get(&"state", &""))
		if state == &"COMPLETED":
			continue
		var position: Variant = job.get(&"position", null)
		if not position is Vector2i:
			continue
		var cell := Vector3i(position.x, position.y, layer_mode)
		var rect := get_cell_rect(cell).grow(-1.0)
		if rect.size == Vector2.ZERO:
			continue
		var paused := state == &"PAUSED"
		draw_rect(rect, Color("d69a56", 0.26) if paused else Color("4ca5c7", 0.28))
		draw_rect(rect, Color("efbd72") if paused else Color("79d3e7"), false, 2.0)
		if rect.size.x >= 12.0:
			var percent := clampi(int(job.get(&"progress", 0)) / 100, 0, 100)
			draw_string(
				font,
				rect.position + Vector2(3.0, rect.size.y - 3.0),
				"掘 %d%%" % percent,
				HORIZONTAL_ALIGNMENT_LEFT,
				-1,
				clampi(int(rect.size.x * 0.38), 8, 12),
				Color("e5f8fa")
			)


func _draw_build_preview() -> void:
	if build_definition == null or not _hovered_cell is Vector3i:
		return
	var preview_cells := _cells_from_serialized(_last_preview_report.get(&"cells", []))
	if preview_cells.is_empty():
		preview_cells = _fallback_build_cells(_hovered_cell)
	var valid := bool(_last_preview_report.get(&"can_build", false))
	var fill := Color("64d48c", 0.34) if valid else Color("e16262", 0.38)
	var edge := Color("8ff0ab") if valid else Color("ff8a80")
	for cell: Vector3i in preview_cells:
		if cell.z != layer_mode:
			continue
		var rect := get_cell_rect(cell).grow(-1.0)
		draw_rect(rect, fill)
		draw_rect(rect, edge, false, 2.0)

	_draw_build_connection(_last_preview_report.get(&"connection", {}))

func _draw_excavation_preview() -> void:
	if not excavation_mode or not _hovered_cell is Vector3i:
		return
	var preview_cells := _cells_from_serialized(
		_last_excavation_preview_report.get(&"cells", [])
	)
	if preview_cells.is_empty():
		preview_cells = [Vector3i(_hovered_cell.x, _hovered_cell.y, layer_mode)]
	var valid := bool(_last_excavation_preview_report.get(&"ok", false))
	var fill := Color("52bdd0", 0.35) if valid else Color("e16262", 0.38)
	var edge := Color("8ce6ee") if valid else Color("ff8a80")
	for cell: Vector3i in preview_cells:
		if cell.z != layer_mode:
			continue
		var rect := get_cell_rect(cell).grow(-1.0)
		draw_rect(rect, fill)
		draw_rect(rect, edge, false, 2.0)


func _draw_hover() -> void:
	if not _hovered_cell is Vector3i or build_definition != null or excavation_mode:
		return
	var rect := get_cell_rect(_hovered_cell).grow(-1.0)
	draw_rect(rect, Color("d7ebef", 0.75), false, 1.5)


func _draw_map_caption(_map_rect: Rect2) -> void:
	var font := ThemeDB.fallback_font
	var caption := "世界樹の宿 / お客様のフロア" if layer_mode==LayerMode.FRONT else "世界樹の宿 / 従業員と物流"
	draw_rect(Rect2(14,12,244,26),Color("29483d",0.93))
	draw_string(font,Vector2(24,30),caption,HORIZONTAL_ALIGNMENT_LEFT,-1,13,Color("f0e2bd"))
	var status := "表示 %d%% · %s · G マス目%s" % [int(zoom_level*100), "裏層:半透明" if layer_mode == LayerMode.FRONT else "表層:輪郭", " ON" if show_grid else ""]
	var status_width := font.get_string_size(status,HORIZONTAL_ALIGNMENT_LEFT,-1,12).x + 20.0
	draw_rect(Rect2(size.x-status_width-14,12,status_width,26),Color("29483d",0.93))
	draw_string(font,Vector2(size.x-status_width-4,30),status,HORIZONTAL_ALIGNMENT_LEFT,-1,12,Color("f0e2bd"))


func _draw_center_message(message: String) -> void:
	var font := ThemeDB.fallback_font
	draw_string(font, Vector2(0, size.y * 0.5), message, HORIZONTAL_ALIGNMENT_CENTER, size.x, 16, Color("8ca4af"))


func _actor_offset(cell: Vector3i, counts: Dictionary) -> Vector2:
	var count := int(counts.get(cell,0))
	counts[cell] = count+1
	var p := _cell_dimensions().x/16.0
	var slots := [0.0,4.0,-4.0,2.0,-2.0,6.0,-6.0]
	return Vector2(float(slots[count%slots.size()])*p,-float(count%2)*p)


func _facility_color(instance_id: StringName) -> Color:
	var facility_service: Variant = _service(&"facility_service")
	if facility_service == null or not facility_service.has_method(&"get_instance"):
		return Color("6f8b67") if layer_mode == LayerMode.FRONT else Color("427582")
	var instance: Variant = facility_service.call(&"get_instance", instance_id)
	if instance == null:
		return Color("6f8b67") if layer_mode == LayerMode.FRONT else Color("427582")
	match int(_room_presentations.get(instance_id, {}).get(&"state", instance.state)):
		FacilityInstance.State.BROKEN:
			return Color("a24b45")
		FacilityInstance.State.NO_ACCESS, FacilityInstance.State.NO_POWER, FacilityInstance.State.NO_STAFF:
			return Color("a77d3f")
		FacilityInstance.State.CLOSED:
			return Color("6b6258")
		FacilityInstance.State.DEGRADED:
			return Color("89914d")
		_:
			return Color("6f9a55") if layer_mode == LayerMode.FRONT else Color("67518e")


func _fallback_build_cells(origin: Vector3i) -> Array[Vector3i]:
	var result: Array[Vector3i] = []
	if build_definition == null:
		return result
	var footprint: Variant = build_definition.get(&"footprint") if build_definition is Object else null
	if build_definition is Dictionary:
		footprint = build_definition.get(&"footprint", Vector2i.ONE)
	if not footprint is Vector2i:
		footprint = Vector2i.ONE
	var dimensions: Vector2i = footprint
	if build_rotation in [90, 270]:
		dimensions = Vector2i(footprint.y, footprint.x)
	for y_offset in range(dimensions.y):
		for x_offset in range(dimensions.x):
			result.append(origin + Vector3i(x_offset, y_offset, 0))
	return result


func _fallback_cells_are_available(origin: Vector3i) -> bool:
	var world: Variant = _service(&"world_service")
	if world == null:
		return false
	for cell: Vector3i in _fallback_build_cells(origin):
		if world.has_method(&"is_in_bounds") and not bool(world.call(&"is_in_bounds", cell)):
			return false
		if world.has_method(&"get_occupant") and not StringName(world.call(&"get_occupant", cell)).is_empty():
			return false
	return true


func _set_hovered_cell(value: Variant) -> void:
	if value == _hovered_cell:
		return
	_hovered_cell = value
	hovered_cell_changed.emit(_hovered_cell)
	_update_build_preview()
	_update_excavation_preview()
	queue_redraw()


func _update_build_preview() -> void:
	_preview_topology_key = _current_preview_topology()
	_last_preview_report = get_build_preview_report()
	build_preview_changed.emit(_last_preview_report.duplicate(true))


func _update_excavation_preview() -> void:
	_last_excavation_preview_report = (
		get_excavation_preview_report()
		if excavation_mode else INVALID_EXCAVATION_PREVIEW.duplicate(true)
	)
	excavation_preview_changed.emit(_last_excavation_preview_report.duplicate(true))


func _on_mouse_exited() -> void:
	_set_hovered_cell(null)


func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
	_connection_cache_key.clear()
	_connection_cache_report.clear()
	queue_redraw()
	_update_build_preview()
	_update_excavation_preview()


func _connect_runtime() -> void:
	if _runtime == null or not _runtime is Object or not _runtime.has_signal(&"snapshot_published"):
		return
	var notification: StringName = &"state_updated" if _runtime.has_signal(&"state_updated") else &"snapshot_published"
	var callback := Callable(self, "_on_snapshot_published")
	if not _runtime.is_connected(notification, callback):
		_runtime.connect(notification, callback)


func _disconnect_runtime() -> void:
	if _runtime == null or not _runtime is Object or not _runtime.has_signal(&"snapshot_published"):
		return
	var notification: StringName = &"state_updated" if _runtime.has_signal(&"state_updated") else &"snapshot_published"
	var callback := Callable(self, "_on_snapshot_published")
	if _runtime.is_connected(notification, callback):
		_runtime.disconnect(notification, callback)


func _service(property_name: StringName) -> Variant:
	if _runtime == null or not _runtime is Object:
		return null
	if _drawing and _draw_cache.has(property_name):
		return _draw_cache[property_name]
	for property: Dictionary in _runtime.get_property_list():
		if StringName(property.get(&"name", &"")) == property_name:
			var value = _runtime.get(property_name)
			if _drawing:
				_draw_cache[property_name] = value
			return value
	return null


func _world_bounds() -> Dictionary:
	if _drawing and _draw_cache.has(&"bounds"):
		return _draw_cache[&"bounds"]
	var world: Variant = _service(&"world_service")
	if world == null or not world.has_method(&"get_bounds"):
		return {}
	var result: Variant = world.call(&"get_bounds")
	if not result is Dictionary:
		return {}
	for key: StringName in [&"min_x", &"max_x", &"min_y", &"max_y"]:
		if not result.has(key) and not result.has(String(key)):
			return {}
	var result_value = {
		&"min_x": int(result.get(&"min_x", result.get("min_x", 0))),
		&"max_x": int(result.get(&"max_x", result.get("max_x", -1))),
		&"min_y": int(result.get(&"min_y", result.get("min_y", 0))),
		&"max_y": int(result.get(&"max_y", result.get("max_y", -1))),
	}

	if _drawing:
		_draw_cache[&"bounds"] = result_value
	return result_value


func _cells_from_serialized(value: Variant) -> Array[Vector3i]:
	var result: Array[Vector3i] = []
	if not value is Array:
		return result
	for raw_cell: Variant in value:
		if raw_cell is Vector3i:
			result.append(raw_cell)
		elif raw_cell is Array and raw_cell.size() == 3:
			result.append(Vector3i(int(raw_cell[0]), int(raw_cell[1]), int(raw_cell[2])))
	return result


func _serialize_cells(cells: Array[Vector3i]) -> Array[Array]:
	var result: Array[Array] = []
	for cell: Vector3i in cells:
		result.append([cell.x, cell.y, cell.z])
	return result


func _normalized_rotation(value: int) -> int:
	return posmod(value, 360) / 90 * 90


func _rescue_treatment_position(patient_id: StringName, fallback: Vector2, observation: Dictionary = {}) -> Vector2:
	var _room_presentations: Dictionary = observation.get(&"rooms", self._room_presentations)
	var _treated_patients: Dictionary = observation.get(&"treated_patients", self._treated_patients)
	var facility_id: StringName = _treated_patients[patient_id]
	var world: Variant = _service(&"world_service")
	var cells: Array[Vector3i] = world.get_entity_cells(facility_id) if world != null else []
	if cells.is_empty(): return fallback
	var data: Dictionary = _room_presentations.get(facility_id, {})
	var slot := (data.get(&"people", []) as Array).find(patient_id)
	var p := _cell_dimensions().x/16.0
	var anchors := RoomPresentation.slots(_cell_bounds_rect(cells), p, &"infirmary")
	if slot < 0 or slot >= anchors.size(): return Vector2(INF,INF)
	return anchors[slot]+Vector2(0,-2*p-_cell_dimensions().y*0.5)


func _resting_staff_position(staff_id: StringName, fallback: Vector2, observation: Dictionary = {}) -> Vector2:
	var _room_presentations: Dictionary = observation.get(&"rooms", self._room_presentations)
	var _resting_staff: Dictionary = observation.get(&"resting_staff", self._resting_staff)
	var facility_id: StringName = _resting_staff[staff_id]
	var world: Variant = _service(&"world_service")
	var cells: Array[Vector3i] = world.get_entity_cells(facility_id) if world != null else []
	if cells.is_empty(): return fallback
	var data: Dictionary = _room_presentations.get(facility_id, {})
	var slot := (data.get(&"people", []) as Array).find(staff_id)
	var anchors := RoomPresentation.slots(_cell_bounds_rect(cells), _cell_dimensions().x/16.0, &"staff_waiting_room")
	if slot < 0 or slot >= anchors.size(): return Vector2(INF,INF)
	return anchors[slot]+Vector2(0,-_cell_dimensions().y*0.5)


func _can_drop_data(_position: Vector2, data: Variant) -> bool:
	return data is Dictionary and data.get(&"kind", &"") == &"staff_assignment" and data.get(&"staff_id", &"") is StringName


func _drop_data(position: Vector2, data: Variant) -> void:
	if _can_drop_data(position, data):
		staff_assignment_dropped.emit(data, facility_at_canvas(position))


func _current_preview_topology() -> Array:
	var world: Variant = _service(&"world_service")
	var path: Variant = _service(&"path_service")
	return [world.revision if world != null else -1,
		path.graph_revision if path != null else -1,
		path.traffic_revision if path != null else -1]


## Read-only connection preview. Shares ordinary facility access candidates and
## accessible graph edges, but does not issue live path queries or change routing.
## The displayed path proves connectivity; it is not a cheapest-route promise.
func _build_connection_report(cells: Array[Vector3i], origin: Vector3i) -> Dictionary:
	var path: Variant = _service(&"path_service")
	if path == null or _runtime == null or cells.is_empty():
		return {&"status": &"UNAVAILABLE", &"reachable": false, &"path": []}
	var entrance: Variant = _runtime.call(&"_operations_entrance_cell")
	var key: Array = [origin, cells, _current_preview_topology(), entrance]
	if key == _connection_cache_key:
		return _connection_cache_report.duplicate(true)
	var result := {&"status": &"UNAVAILABLE", &"reachable": false,
		&"entrance": entrance, &"origin": origin, &"path": [], &"access_points": []}
	if entrance is Vector3i:
		var future := FacilityInstance.new()
		future.origin = origin
		future.occupied_cells.assign(cells)
		var candidates: Array = _runtime.call(&"_facility_access_candidates", future)
		result[&"access_points"] = candidates
		var targets: Dictionary = {}
		for cell: Vector3i in candidates: targets[cell] = true
		var queue: Array[Vector3i] = [entrance]
		var parents: Dictionary = {}
		parents[entrance] = entrance
		var cursor := 0
		var target: Variant = null
		while cursor < queue.size():
			var current := queue[cursor]
			cursor += 1
			if targets.has(current):
				target = current
				break
			for edge: Dictionary in path.get_neighbors(current):
				var next: Vector3i = edge[&"to"]
				if parents.has(next) or path.get_edge_report(current, next).is_empty():
					continue
				parents[next] = current
				queue.append(next)
		var route: Array[Vector3i] = []
		if target is Vector3i:
			var current: Vector3i = target
			while current != entrance:
				route.push_front(current)
				current = parents[current]
			route.push_front(entrance)
		result[&"reachable"] = not route.is_empty()
		result[&"status"] = &"CONNECTED" if result[&"reachable"] else &"DISCONNECTED"
		result[&"path"] = route
	_connection_cache_key = key.duplicate(true)
	_connection_cache_report = result.duplicate(true)
	return result


func _draw_build_connection(report: Dictionary) -> void:
	if report.is_empty() or not _hovered_cell is Vector3i: return
	var status: StringName = report.get(&"status", &"UNAVAILABLE")
	var color := Color("8ff0ab") if status == &"CONNECTED" else Color("ffd080")
	var target := cell_to_canvas(_hovered_cell)
	var route: Array = report.get(&"path", [])
	if status == &"CONNECTED":
		for i in range(1, route.size()):
			var from: Vector3i = route[i-1]
			var to: Vector3i = route[i]
			if from.z == layer_mode and to.z == layer_mode:
				draw_line(cell_to_canvas(from), cell_to_canvas(to), color, 2.0)
		if not route.is_empty() and (route.back() as Vector3i).z == layer_mode:
			draw_line(cell_to_canvas(route.back()), target, color, 2.0)
	elif report.get(&"entrance") is Vector3i:
		# Dashed straight guide is a warning, never a traversable-path claim.
		draw_dashed_line(cell_to_canvas(report[&"entrance"]), target, color, 2.0, 8.0)
		draw_line(target-Vector2(6,6),target+Vector2(6,6),color,2.0)
		draw_line(target+Vector2(-6,6),target+Vector2(6,-6),color,2.0)
	var message := "入口への経路接続済" if status == &"CONNECTED" else "入口への経路未接続" if status == &"DISCONNECTED" else "入口・経路を確認できません"
	draw_string(ThemeDB.fallback_font, target+Vector2(8,-12), message,
		HORIZONTAL_ALIGNMENT_LEFT, -1, 13, color)

func set_demolition_mode(enabled: bool) -> void:
	demolition_mode = enabled
	if enabled:
		clear_build_definition()
		set_excavation_mode(false)
	queue_redraw()
