OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / menu_main.gd

1898 lines · gd ref:
extends Control

## Menu overlay — main/settings/network panels on top of the 3D world.
## Escape toggles visibility. Mouse is captured when menu is hidden.
##
## Networking: native HyperswarmMultiplayerPeer mesh mode.
## All peers are equal (no server). Authority determined by Keet room roles.
## Discovery via DHT topic = SHA256("hyper-game:room:{roomId}").

@onready var overlay: ColorRect = $Overlay
@onready var main_panel: VBoxContainer = $MainPanel
@onready var version_label: Label = $MainPanel/VersionLabel
@onready var home_button: Button = $MainPanel/HomeButton
@onready var settings_panel: VBoxContainer = $SettingsPanel
@onready var network_panel: VBoxContainer = $NetworkPanel

# Network panel nodes
@onready var identity_label: Label = $NetworkPanel/IdentityLabel
@onready var name_input: LineEdit = $NetworkPanel/NameInput
@onready var status_label: Label = $NetworkPanel/StatusLabel
@onready var daemon_status: Label = $NetworkPanel/DaemonStatus
@onready var room_list: VBoxContainer = $NetworkPanel/RoomScroll/RoomList
@onready var join_invite_input: LineEdit = $NetworkPanel/RoomActions/JoinInviteInput
@onready var current_room_label: Label = $NetworkPanel/CurrentRoomLabel
@onready var peer_list: VBoxContainer = $NetworkPanel/PeerScroll/PeerList
@onready var host_button: Button = $NetworkPanel/HostButton
@onready var contact_list: VBoxContainer = $NetworkPanel/ContactScroll/ContactList
@onready var quick_connect_input: LineEdit = $NetworkPanel/QuickConnectRow/QuickConnectInput
@onready var quick_connect_btn: Button = $NetworkPanel/QuickConnectRow/QuickConnectBtn
@onready var debug_toggle: Button = $NetworkPanel/DebugToggle
@onready var debug_panel: VBoxContainer = $NetworkPanel/DebugPanel
@onready var debug_stats: RichTextLabel = $NetworkPanel/DebugPanel/DebugStats

# Settings rows are GENERATED from Settings.SCHEMA at runtime — see _build_settings_ui.
# key -> {type, buttons: Array[Button]} or {type, check: CheckButton}
var _setting_rows: Dictionary = {}
var _game_started := false
var _mesh_active := false  # True when swarm_join is active
var _has_hyperswarm := false
var _selected_room_id: String = ""
var _pending_auto_room: String = ""  # Room name from --room cmdline, joins when daemon ready
var _pending_auto_room_id: String = ""  # Exact room ID from --room-id cmdline
var _pending_list_rooms := false
var _pending_export_config := false  # --export-config: save config and exit
var _identity_ready := false
## HOME (docs/home-world-design.md §3): every identity's own keet room, which the
## Home button (was Resume) takes you to. Only the graphical game asks for one --
## a headless / dedicated peer joins the room it was told to and must not create
## a home room for its identity -- so _ready turns this on at its very end, past
## every dedicated-mode return. Gates set it directly.
var home_enabled := false
var _daemon_state := "connecting"   # KeetService.daemon_status_changed
var _home_error := ""               # home.get's error, shown on the disabled button
var _log_file: FileAccess = null
var _diag_path: String = ""  # --diag-path: write JSON diagnostics here
var _headless_config: Dictionary = {}  # loaded from --config file
var _diag_timer: float = 0.0
var _benchmark_mode: bool = false

# Native mesh networking state
var _peer: Object = null  # HyperswarmMultiplayerPeer instance
var _dht_kp: Dictionary = {}  # {public_key: PackedByteArray, secret_key: PackedByteArray}
var _my_pk_hex: String = ""  # Our DHT public key hex
var _peer_registry: Dictionary = {}  # pk_hex → {peer_id: int, role: String}
var _authority_id: int = 0

const KeetNames := preload("res://addons/keet_social/keet_names.gd")
const IdentityFlow := preload("res://scenes/game/identity_flow.gd")
## First-run naming + vault, back up, recovery phrase, restore, rename (#89).
var _identity_flow: Node = null


func _log(msg: String) -> void:
	print(msg)
	if not _log_file:
		if FileAccess.file_exists("/tmp/hypertps-menu.log"):
			_log_file = FileAccess.open("/tmp/hypertps-menu.log", FileAccess.READ_WRITE)
			if _log_file:
				_log_file.seek_end()
		else:
			_log_file = FileAccess.open("/tmp/hypertps-menu.log", FileAccess.WRITE)
	if _log_file:
		_log_file.seek_end()
		_log_file.store_line("%s %s" % [Time.get_datetime_string_from_system(), msg])
		_log_file.flush()


func _ready() -> void:
	_has_hyperswarm = ClassDB.class_exists(&"HyperswarmMultiplayerPeer")

	# Connect KeetService signals
	KeetService.daemon_status_changed.connect(_on_daemon_status)
	KeetService.room_mgr.rooms_loaded.connect(_on_rooms_loaded)
	KeetService.room_selected.connect(_on_room_selected)
	KeetService.role_changed.connect(_on_role_changed)
	KeetService.identity.identity_loaded.connect(_on_identity_loaded)
	_setup_identity_flow()
	KeetService.peer_identity.names_changed.connect(_rebuild_peer_list)

	# Connect multiplayer signals (mesh mode — no connected_to_server)
	multiplayer.peer_connected.connect(_on_multiplayer_peer_connected)
	multiplayer.peer_disconnected.connect(_on_multiplayer_peer_disconnected)

	if _has_hyperswarm:
		status_label.text = "Select a room to start networking..."
	else:
		status_label.text = "Multiplayer unavailable (GDExtension not loaded)"
		host_button.visible = false

	# Set version label
	var vf := FileAccess.open("res://version.txt", FileAccess.READ)
	if vf:
		version_label.text = "build: %s" % vf.get_as_text().strip_edges()

	# Show main panel, hide others
	_show_main()
	_highlight_settings_buttons()
	_refresh_contacts()

	# Menu starts visible
	show_menu()
	_update_home_button()

	# Dedicated peer: auto-connect via cmdline args
	# --list-rooms       print rooms and exit
	# --room <name>      join by room title (name lookup)
	# --room-id <id>     join by exact room ID (same as GUI click)
	# --role <R>         keet role (admin/mod/peer)
	# --config <path>    load headless config JSON
	# --diag-path <path> write diagnostic JSON to this file every ~1s
	# --export-config    after room discovery, write config JSON and exit
	# --headless         run in headless mode (also passed to Godot engine)
	var user_args := OS.get_cmdline_user_args()

	# Parse --diag-path, --config, --benchmark first (they apply to all modes)
	for i in user_args.size():
		if user_args[i] == "--diag-path" and i + 1 < user_args.size():
			_diag_path = user_args[i + 1]
		if user_args[i] == "--config" and i + 1 < user_args.size():
			_headless_config = _load_config(user_args[i + 1])
		if user_args[i] == "--benchmark":
			_benchmark_mode = true

	if "--export-config" in user_args:
		_pending_export_config = true

	if "--list-rooms" in user_args:
		_pending_list_rooms = true
		return

	# If --config provided, use it for room/role (overrides --room/--room-id)
	if not _headless_config.is_empty():
		var cfg_room_id: String = _headless_config.get("room_id", "")
		var cfg_room_name: String = _headless_config.get("room_name", "")
		var cfg_role: String = _headless_config.get("role", "peer")
		KeetService.current_role = cfg_role
		if not cfg_room_id.is_empty():
			_pending_auto_room_id = cfg_room_id
			_log("[Headless] config: room_id=%s role=%s" % [cfg_room_id, cfg_role])
		elif not cfg_room_name.is_empty():
			_pending_auto_room = cfg_room_name
			_log("[Headless] config: room=%s role=%s" % [cfg_room_name, cfg_role])
		# Use diag_path from config if not set via CLI
		if _diag_path.is_empty():
			_diag_path = _headless_config.get("diag_path", "")
		return

	var has_room := false
	for i in user_args.size():
		if (user_args[i] == "--room" or user_args[i] == "--room-id") and i + 1 < user_args.size():
			var bot_role := "peer"
			var is_room_id := user_args[i] == "--room-id"
			for j in user_args.size():
				if user_args[j] == "--role" and j + 1 < user_args.size():
					bot_role = user_args[j + 1]
			# Player name always comes from Keet identity (via _on_identity_loaded)
			KeetService.current_role = bot_role
			if is_room_id:
				_pending_auto_room_id = user_args[i + 1]
				_log("[Dedicated] will auto-join room_id=%s role=%s (waiting for daemon + identity)" % [_pending_auto_room_id, bot_role])
			else:
				_pending_auto_room = user_args[i + 1]
				_log("[Dedicated] will auto-join room=%s role=%s (waiting for daemon + identity)" % [_pending_auto_room, bot_role])
			has_room = true
			break
	if has_room:
		return

	# Every dedicated / auto-room mode has returned above. A headless run with none
	# of them (a bare --daemon-only peer) still wants no home room.
	home_enabled = DisplayServer.get_name() != "headless"
	_update_home_button()


## Auto-join an existing Keet room by title, or create a new one.
## Does a fresh list_rooms() RPC to avoid stale cache causing duplicates.
func _auto_join_or_create_room(room_name: String) -> void:
	_log("[Dedicated] looking for room: %s (fresh query)" % room_name)
	KeetService.room_mgr.list_rooms(func(_rooms_arr):
		# Cache is now updated via rooms_loaded signal
		var rooms := KeetService.get_rooms()
		_log("[Dedicated] daemon has %d rooms:" % rooms.size())
		var matches: Array[String] = []
		for rid: String in rooms:
			var title: String = rooms[rid]
			var is_match: bool = title == room_name
			_log("[Dedicated]   %s = \"%s\"%s" % [rid, title, " <-- MATCH" if is_match else ""])
			if is_match:
				matches.append(rid)
		if matches.size() > 1:
			matches.sort()
			_log("[Dedicated] WARNING: %d rooms match — picking first: %s" % [matches.size(), matches[0]])
		if not matches.is_empty():
			_log("[Dedicated] joining room: %s" % matches[0])
			KeetService.select_room(matches[0])
			_print_invite(matches[0])
			return
		_log("[Dedicated] creating new room: %s" % room_name)
		KeetService.room_mgr.create_room(room_name, func(rid):
			if rid is String and not rid.is_empty():
				_log("[Dedicated] created room: %s" % rid)
				KeetService.select_room(rid)
				_print_invite(rid)
			else:
				_log("[Dedicated] ERROR: room creation returned empty id")
		)
	)


func _print_invite(room_id: String) -> void:
	KeetService.room_mgr.create_invite(room_id, func(token):
		if token is String and not token.is_empty():
			print("")
			print("========================================")
			print("[Dedicated] INVITE: %s" % token)
			print("[Dedicated] Paste this on another machine to join")
			print("========================================")
			print("")
	)


func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
		if visible:
			hide_menu()
		else:
			show_menu()
		get_viewport().set_input_as_handled()


func show_menu() -> void:
	visible = true
	Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
	_show_main()


func hide_menu() -> void:
	if not _game_started:
		return  # Don't hide until game is active
	visible = false
	# Defer mouse capture to avoid X11 grab errors during shader compilation
	if DisplayServer.get_name() == "headless":
		return
	_capture_mouse.call_deferred()


func _capture_mouse() -> void:
	if not visible and _game_started:
		Input.mouse_mode = Input.MOUSE_MODE_CAPTURED


# ---- Panel Navigation ----

func _show_main() -> void:
	main_panel.visible = true
	settings_panel.visible = false
	network_panel.visible = false


func _show_settings() -> void:
	main_panel.visible = false
	settings_panel.visible = true
	network_panel.visible = false
	_build_settings_ui()
	_highlight_settings_buttons()
	_display_snapshot = {"window_mode": Settings.get_value("window_mode"), "resolution": Settings.get_value("resolution")}


func _show_network() -> void:
	main_panel.visible = false
	settings_panel.visible = false
	network_panel.visible = true
	_refresh_contacts()
	_refresh_room_list()


# ---- Main Panel Buttons ----

## HOME (was Resume): this identity's own room. Selecting it goes through
## KeetService.select_room like any room -- which drops the previous world -- and
## _on_room_selected starts it on the offline peer. Already home: just close.
func _on_home_pressed() -> void:
	var home: String = KeetService.home_room_id
	if home_button.disabled or home == "":
		return
	if KeetService.get_current_room() == home and _game_started:
		hide_menu()
		return
	_log("[Menu] Home -> %s" % home.left(16))
	KeetService.select_room(home)


## Ask the daemon for this identity's home room (keet-daemon home.get). Once per
## identity: an identity swap clears it and the next identity_loaded asks again.
func _fetch_home() -> void:
	if not home_enabled or KeetService.home_room_id != "":
		_update_home_button()
		return
	_home_error = ""
	_update_home_button()
	KeetService.room_mgr.get_home(func(r: Dictionary) -> void:
		if r.has("roomId"):
			KeetService.home_room_id = String(r["roomId"])
			_log("[Menu] home room %s (%s)" % [KeetService.home_room_id.left(16),
					"created" if bool(r.get("created", false)) else "existing"])
		else:
			_home_error = String(r.get("error", "home.get failed"))
			_log("[Menu] home.get FAILED: %s" % _home_error)
		_update_home_button()
		_refresh_room_list())


## The Home button says why it is not available rather than sitting grey: no daemon
## means no durable state at all (the daemon always runs -- dev-run / dev-editor
## start it), and a home.get error (HOME_ROOM_MISSING) is something to look at.
func _update_home_button() -> void:
	if home_button == null:
		return
	var text := "Home"
	var tip := ""
	var can_go := false
	if not home_enabled:
		text = "Home (not available in this mode)"
	elif _daemon_state == "disconnected":
		text = "Home — keet-daemon not running"
		tip = "No keet-daemon on the socket. scripts/dev-run.sh and scripts/dev-editor.sh start one."
	elif _daemon_state != "running":
		text = "Home (waiting for keet-daemon…)"
	elif not _identity_ready:
		text = "Home (starting…)"
	elif _home_error != "":
		text = "Home — unavailable"
		tip = _home_error
	elif KeetService.home_room_id == "":
		text = "Home (opening…)"
	else:
		can_go = true
	home_button.text = text
	home_button.tooltip_text = tip
	home_button.disabled = not can_go


func _on_settings_pressed() -> void:
	_show_settings()


func _on_network_pressed() -> void:
	_show_network()


func _on_quit_pressed() -> void:
	get_tree().quit()


# ---- Settings Panel ----

## Build the settings panel from Settings.SCHEMA. Adding an option is ONE schema
## entry — no scene row, no [connection], no handler here.
##
## LAYOUT (settings page audit, docs/settings-page-audit.md). The old panel was a
## fixed 400x320 VBox, one label line + one button row per setting: 1175 px tall in
## an 873 px window, so Apply/Cancel were off screen. Now:
##   title / TABS (each a scroll page) / help line / Apply-Cancel pinned below,
##   sized to the window; one line per setting (label left, control right); the
##   widget follows the kind; rows with a `group` start in a collapsed section;
##   `depends` rows grey out; the preset row says Custom when nothing matches.
const _SET_LABEL_W := 230
const _SET_CONTROL_W := 340
var _settings_help: Label = null
var _settings_tabs: TabContainer = null
var _preset_state: Label = null


func _build_settings_ui() -> void:
	if not _setting_rows.is_empty():
		return
	var title := settings_panel.get_node_or_null("SettingsTitle") as Label
	if title != null:
		title.text = "Settings"
	var tail: Array[Node] = []          # spacer + Apply/Cancel, kept last
	for n in [settings_panel.get_node_or_null("SettingsSpacer"),
			  settings_panel.get_node_or_null("SettingsButtonRow")]:
		if n != null:
			settings_panel.remove_child(n)
			tail.append(n)

	_settings_tabs = TabContainer.new()
	_settings_tabs.name = "SettingsTabs"
	_settings_tabs.size_flags_vertical = Control.SIZE_EXPAND_FILL
	settings_panel.add_child(_settings_tabs)
	var pages := {}                      # tab -> VBox
	var groups := {}                     # "tab/group" -> [header Button, body VBox]
	# Two passes per tab: the visible rows first, then the collapsed groups below them —
	# schema order alone put "More graphics options" between Shadows and Render scale.
	for tab: String in Settings.TABS:
		for opt: Dictionary in _settings_rows_in_order():
			if String(opt.get("tab", "Graphics")) != tab or not Settings.is_available(String(opt["key"])):
				continue
			if not pages.has(tab):
				var scroll := ScrollContainer.new()
				scroll.name = tab
				scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
				var page := VBoxContainer.new()
				page.size_flags_horizontal = Control.SIZE_EXPAND_FILL
				page.add_theme_constant_override("separation", 8)
				scroll.add_child(page)
				_settings_tabs.add_child(scroll)
				pages[tab] = page
			var parent: VBoxContainer = pages[tab]
			var group := String(opt.get("group", ""))
			var header: Button = null
			if group != "":
				var gk := tab + "/" + group
				if not groups.has(gk):
					var h := Button.new()
					h.flat = true
					h.alignment = HORIZONTAL_ALIGNMENT_LEFT
					h.text = "▸  " + group
					var body := VBoxContainer.new()
					body.visible = false
					body.add_theme_constant_override("separation", 8)
					h.pressed.connect(_toggle_settings_group.bind(h, body, group))
					parent.add_child(h)
					parent.add_child(body)
					groups[gk] = [h, body]
				header = groups[gk][0]
				parent = groups[gk][1]
			_add_setting_row(parent, opt, header)

	_settings_help = Label.new()
	_settings_help.name = "SettingsHelp"
	_settings_help.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	_settings_help.custom_minimum_size = Vector2(0, 46)
	_settings_help.modulate = Color(0.85, 0.9, 1.0)
	_settings_help.text = "Hover or focus a setting to see what it does."
	settings_panel.add_child(_settings_help)
	for n in tail:
		settings_panel.add_child(n)

	settings_panel.draw.connect(_draw_settings_bg)
	if not get_viewport().size_changed.is_connected(_fit_settings_panel):
		get_viewport().size_changed.connect(_fit_settings_panel)
	_fit_settings_panel()


## SCHEMA with ungrouped rows before grouped ones, each half keeping schema order.
static func _settings_rows_in_order() -> Array:
	var top: Array = []
	var grouped: Array = []
	for opt: Dictionary in Settings.SCHEMA:
		(grouped if String(opt.get("group", "")) != "" else top).append(opt)
	return top + grouped


## Sized to the window, centred, never taller than it — the bug this layout fixes.
func _fit_settings_panel() -> void:
	var vp := get_viewport_rect().size
	var w := minf(860.0, vp.x - 48.0)
	var h := minf(680.0, vp.y - 48.0)
	settings_panel.set_anchors_preset(Control.PRESET_CENTER)
	settings_panel.offset_left = -w * 0.5
	settings_panel.offset_right = w * 0.5
	settings_panel.offset_top = -h * 0.5
	settings_panel.offset_bottom = h * 0.5
	settings_panel.queue_redraw()


## A real background, so rows do not float over the 3D scene, HUD and chat.
func _draw_settings_bg() -> void:
	var sb := StyleBoxFlat.new()
	sb.bg_color = Color(0.07, 0.08, 0.11, 0.94)
	sb.border_color = Color(0.3, 0.4, 0.55, 0.8)
	sb.set_border_width_all(1)
	sb.set_corner_radius_all(6)
	settings_panel.draw_style_box(sb, Rect2(Vector2(-16, -12), settings_panel.size + Vector2(32, 24)))


func _toggle_settings_group(header: Button, body: Control, group: String) -> void:
	body.visible = not body.visible
	header.text = ("▾  " if body.visible else "▸  ") + group


## The widget a row gets: the schema's override, else by kind and choice count.
static func _widget_for(opt: Dictionary) -> String:
	if opt.has("widget"):
		return String(opt["widget"])
	if opt["type"] == "bool":
		return "toggle"
	if opt["type"] == "range":
		return "range"
	return "segmented" if (opt.get("choices", []) as Array).size() <= 3 else "dropdown"


func _add_setting_row(parent: Container, opt: Dictionary, group_button: Button) -> void:
	var key: String = opt["key"]
	var row := HBoxContainer.new()
	row.add_theme_constant_override("separation", 12)
	var lbl := Label.new()
	lbl.text = String(opt["label"])
	lbl.custom_minimum_size = Vector2(_SET_LABEL_W, 0)
	lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	lbl.mouse_filter = Control.MOUSE_FILTER_PASS
	row.add_child(lbl)
	var entry := {"widget": "", "label": lbl, "group_button": group_button, "disabled": false}
	var hoverables: Array[Control] = [lbl]
	var choices: Array = opt.get("choices", [])
	match _widget_for(opt):
		"toggle":
			var cb := CheckButton.new()
			cb.toggled.connect(_on_setting_toggled.bind(key))
			row.add_child(cb)
			entry["widget"] = "CheckButton"
			entry["control"] = cb
			hoverables.append(cb)
		"dropdown":
			var ob := OptionButton.new()
			for i in choices.size():
				ob.add_item(String(choices[i]), i)
			ob.custom_minimum_size = Vector2(_SET_CONTROL_W, 0)
			ob.item_selected.connect(_on_setting_choice.bind(key))
			row.add_child(ob)
			entry["widget"] = "OptionButton"
			entry["control"] = ob
			hoverables.append(ob)
		"range":
			# A CONTINUOUS value (sensitivity, FOV, volume): min / max / step from the schema.
			var rbox := HBoxContainer.new()
			rbox.custom_minimum_size = Vector2(_SET_CONTROL_W, 0)
			var rs := HSlider.new()
			rs.min_value = float(opt.get("min", 0.0))
			rs.max_value = float(opt.get("max", 1.0))
			rs.step = float(opt.get("step", 0.01))
			rs.size_flags_horizontal = Control.SIZE_EXPAND_FILL
			rs.size_flags_vertical = Control.SIZE_SHRINK_CENTER
			var rv := Label.new()
			rv.custom_minimum_size = Vector2(72, 0)
			rv.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
			rs.value_changed.connect(_on_setting_range.bind(key))
			rbox.add_child(rs)
			rbox.add_child(rv)
			row.add_child(rbox)
			entry["widget"] = "HSlider"
			entry["control"] = rs
			entry["value_label"] = rv
			hoverables.append(rs)
		"slider":
			var box := HBoxContainer.new()
			box.custom_minimum_size = Vector2(_SET_CONTROL_W, 0)
			var sl := HSlider.new()
			sl.min_value = 0
			sl.max_value = choices.size() - 1
			sl.step = 1
			sl.tick_count = choices.size()
			sl.ticks_on_borders = true
			sl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
			sl.size_flags_vertical = Control.SIZE_SHRINK_CENTER
			var val := Label.new()
			val.custom_minimum_size = Vector2(64, 0)
			val.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
			sl.value_changed.connect(_on_setting_slider.bind(key))
			box.add_child(sl)
			box.add_child(val)
			row.add_child(box)
			entry["widget"] = "HSlider"
			entry["control"] = sl
			entry["value_label"] = val
			hoverables.append(sl)
		_:   # segmented
			var seg := HBoxContainer.new()
			seg.custom_minimum_size = Vector2(_SET_CONTROL_W, 0)
			seg.add_theme_constant_override("separation", 2)
			var bg := ButtonGroup.new()
			var buttons: Array[Button] = []
			for i in choices.size():
				var b := Button.new()
				b.text = String(choices[i])
				b.toggle_mode = true
				b.button_group = bg
				b.size_flags_horizontal = Control.SIZE_EXPAND_FILL
				# The SELECTED choice must be unmistakable (audit A5: dark vs darker).
				var on := StyleBoxFlat.new()
				on.bg_color = Color(0.22, 0.45, 0.78)
				on.set_corner_radius_all(3)
				b.add_theme_stylebox_override("pressed", on)
				b.add_theme_stylebox_override("hover_pressed", on)
				# Unselected choices still have to read as BUTTONS, not plain text.
				var off := StyleBoxFlat.new()
				off.bg_color = Color(0.16, 0.18, 0.23)
				off.set_corner_radius_all(3)
				var hov := off.duplicate() as StyleBoxFlat
				hov.bg_color = Color(0.22, 0.25, 0.32)
				b.add_theme_stylebox_override("normal", off)
				b.add_theme_stylebox_override("hover", hov)
				b.pressed.connect(_on_setting_choice.bind(i, key))
				seg.add_child(b)
				buttons.append(b)
				hoverables.append(b)
			row.add_child(seg)
			entry["widget"] = "segmented"
			entry["control"] = seg
			entry["buttons"] = buttons
			if key == "preset":
				_preset_state = Label.new()
				_preset_state.custom_minimum_size = Vector2(70, 0)
				_preset_state.modulate = Color(1.0, 0.85, 0.5)
				row.add_child(_preset_state)
	for h: Control in hoverables:
		h.mouse_entered.connect(_show_setting_help.bind(key))
		h.focus_entered.connect(_show_setting_help.bind(key))
	parent.add_child(row)
	_setting_rows[key] = entry


func _show_setting_help(key: String) -> void:
	if _settings_help == null:
		return
	var opt: Dictionary = Settings.schema_for(key)
	var text := String(opt.get("help", ""))
	var dep := String(opt.get("depends", ""))
	if dep != "" and not bool(Settings.get_value(dep)):
		text += "  (Needs %s on.)" % String(Settings.schema_for(dep).get("label", dep))
	_settings_help.text = "%s — %s" % [String(opt.get("label", key)), text]


func _on_setting_choice(index: int, key: String) -> void:
	Settings.set_value(key, index)
	_highlight_settings_buttons()


func _on_setting_slider(value: float, key: String) -> void:
	Settings.set_value(key, int(value))
	_highlight_settings_buttons()


func _on_setting_range(value: float, key: String) -> void:
	Settings.set_value(key, value)
	_highlight_settings_buttons()


func _on_setting_toggled(pressed: bool, key: String) -> void:
	Settings.set_value(key, pressed)
	_highlight_settings_buttons()


## Reflect effective values (override > map default > schema default), so a setting
## the map supplied reads correctly even though the player never touched it. Also
## greys `depends` rows and names the preset state.
func _highlight_settings_buttons() -> void:
	for key: String in _setting_rows:
		var row: Dictionary = _setting_rows[key]
		var opt: Dictionary = Settings.schema_for(key)
		var dep := String(opt.get("depends", ""))
		var off := dep != "" and not bool(Settings.get_value(dep))
		row["disabled"] = off
		(row["label"] as Label).modulate = Color(1, 1, 1, 0.45 if off else 1.0)
		match String(row["widget"]):
			"CheckButton":
				var cb: CheckButton = row["control"]
				cb.set_pressed_no_signal(bool(Settings.get_value(key)))
				cb.disabled = off
			"OptionButton":
				var ob: OptionButton = row["control"]
				ob.select(int(Settings.get_value(key)))
				ob.disabled = off
			"HSlider":
				var sl: HSlider = row["control"]
				sl.editable = not off
				if String(opt.get("type", "")) == "range":
					var fv := float(Settings.get_value(key))
					sl.set_value_no_signal(fv)
					var dec := 0 if float(opt.get("step", 1.0)) >= 1.0 else 2
					(row["value_label"] as Label).text = "%s %s" % [String.num(fv, dec), String(opt.get("unit", ""))]
				else:
					var i := int(Settings.get_value(key))
					sl.set_value_no_signal(i)
					var ch: Array = opt.get("choices", [])
					(row["value_label"] as Label).text = String(ch[i]) if i >= 0 and i < ch.size() else ""
			_:
				var cur := int(Settings.get_value(key))
				var buttons: Array = row["buttons"]
				for i in buttons.size():
					(buttons[i] as Button).set_pressed_no_signal(i == cur)
					(buttons[i] as Button).disabled = off
	if _preset_state != null:
		_preset_state.text = "Custom" if settings_preset_state() == "Custom" else ""


# ---- Settings panel: read-outs for gates (gate_settings_panel) ----

func settings_row(key: String) -> Dictionary:
	return _setting_rows.get(key, {})


func settings_tabs() -> PackedStringArray:
	var out := PackedStringArray()
	if _settings_tabs != null:
		for c in _settings_tabs.get_children():
			out.append(String(c.name))
	return out


func settings_preset_state() -> String:
	var i := int(Settings.get_value("preset"))
	var ch: Array = Settings.schema_for("preset").get("choices", [])
	return String(ch[i]) if i >= 0 and i < ch.size() else "Custom"


func settings_help_text() -> String:
	return _settings_help.text if _settings_help != null else ""


func _on_apply_pressed() -> void:
	Settings.apply_settings()
	Settings.save_settings()
	# A new window mode / size could leave the player with no usable window: ask, and
	# revert on its own after 15 s (settings audit §7, research item 1).
	var prev := _display_snapshot
	if prev.get("window_mode") != Settings.get_value("window_mode") or prev.get("resolution") != Settings.get_value("resolution"):
		_ask_keep_display(prev)
	_show_main()


## What the display settings were when the panel opened — the revert target.
var _display_snapshot: Dictionary = {}
const _KEEP_DISPLAY_S := 15


## "Keep these display settings?" — OK keeps them; Cancel, closing, or 15 s with no
## answer restores `prev`, re-applies and saves. Returned for gates.
## Named methods + member state, NOT inline multi-line lambdas: those hang headless
## runs (memory: feedback_gdscript-lambda-hangs-headless).
var _keep_dlg: ConfirmationDialog = null
var _keep_prev: Dictionary = {}
var _keep_left := 0


func _ask_keep_display(prev: Dictionary) -> ConfirmationDialog:
	if _keep_dlg != null and is_instance_valid(_keep_dlg):
		_keep_dlg.queue_free()
	_keep_prev = prev.duplicate()
	_keep_left = _KEEP_DISPLAY_S
	_keep_dlg = ConfirmationDialog.new()
	_keep_dlg.title = "Display settings"
	_keep_dlg.ok_button_text = "Keep"
	_keep_dlg.cancel_button_text = "Revert"
	_keep_dlg.dialog_text = _keep_text()
	_keep_dlg.confirmed.connect(_keep_display)
	_keep_dlg.canceled.connect(_revert_display)
	add_child(_keep_dlg)
	var timer := Timer.new()
	timer.wait_time = 1.0
	timer.timeout.connect(_keep_tick)
	_keep_dlg.add_child(timer)
	_keep_dlg.popup_centered()
	timer.start()
	return _keep_dlg


func _keep_text() -> String:
	return "Keep these display settings?\nReverting in %d s." % _keep_left


func _keep_tick() -> void:
	_keep_left -= 1
	if _keep_left <= 0:
		_revert_display()
	elif _keep_dlg != null and is_instance_valid(_keep_dlg):
		_keep_dlg.dialog_text = _keep_text()


func _keep_display() -> void:
	_display_snapshot = {"window_mode": Settings.get_value("window_mode"), "resolution": Settings.get_value("resolution")}
	_close_keep_dialog()


func _revert_display() -> void:
	if _keep_dlg == null:
		return                   # already answered
	for k: String in _keep_prev:
		Settings.set_value(k, _keep_prev[k])
	Settings.apply_settings()
	Settings.save_settings()
	_display_snapshot = _keep_prev.duplicate()
	_close_keep_dialog()


func _close_keep_dialog() -> void:
	if _keep_dlg != null and is_instance_valid(_keep_dlg):
		_keep_dlg.queue_free()
	_keep_dlg = null


func _on_settings_cancel_pressed() -> void:
	Settings.load_settings()
	_show_main()


# ---- Room Selection ----

func _setup_identity_flow() -> void:
	_identity_flow = IdentityFlow.new()
	_identity_flow.name = "IdentityFlow"
	_identity_flow.client = KeetService.client
	add_child(_identity_flow)
	_identity_flow.identity_changed.connect(KeetService.identity.get_identity)
	_identity_flow.identity_swapping.connect(_on_identity_swapping)
	name_input.text_submitted.connect(_identity_flow.rename)

	var row := HBoxContainer.new()
	row.name = "IdentityActions"
	for spec: Array in [["Back up…", _identity_flow.backup],
			["Recovery phrase…", _identity_flow.show_phrase],
			["Restore…", _identity_flow.restore]]:
		var b := Button.new()
		b.text = spec[0]
		b.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		b.pressed.connect(spec[1])
		row.add_child(b)
	network_panel.add_child(row)
	network_panel.move_child(row, identity_label.get_index() + 1)


## Restoring switches the identity: leave the room and the mesh first, the same
## as a room switch (each identity has its own rooms).
func _on_identity_swapping() -> void:
	if _mesh_active:
		_disconnect_mesh()
	KeetService.deselect_room()
	# Each identity has its own home; the next identity_loaded asks again.
	KeetService.home_room_id = ""
	_home_error = ""
	_update_home_button()


func _on_identity_loaded(ident: Dictionary) -> void:
	_identity_ready = true
	# Player name always comes from Keet identity. An unnamed identity shows
	# anon-<4 of member id> (never "Player") until the first-run step names it.
	var name := KeetNames.clean(ident.get("displayName"))
	if name.is_empty():
		name = KeetNames.clean(ident.get("username"))
	if name.is_empty():
		name = KeetNames.display(null, ident.get("memberId"))
	GameManager.player_name = name
	identity_label.text = "Playing as: %s" % name
	name_input.placeholder_text = name
	_log("[Dedicated] identity loaded: %s" % GameManager.player_name)
	if _identity_flow:
		_identity_flow.check_first_run(ident)
	_fetch_home()
	_try_auto_join()


func _try_auto_join() -> void:
	if _pending_auto_room.is_empty():
		return
	if not _identity_ready:
		_log("[Dedicated] waiting for identity before auto-join...")
		return
	if KeetService.get_rooms().is_empty():
		_log("[Dedicated] waiting for rooms before auto-join...")
		return
	var room_name := _pending_auto_room
	_pending_auto_room = ""
	_auto_join_or_create_room(room_name)


func _on_daemon_status(status: String) -> void:
	_daemon_state = status
	_update_home_button()
	daemon_status.text = "Daemon: %s" % status
	if status == "running":
		daemon_status.add_theme_color_override("font_color", Color(0.4, 0.8, 0.4))
	elif status == "disconnected":
		daemon_status.add_theme_color_override("font_color", Color(0.8, 0.4, 0.4))
	else:
		daemon_status.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5))


func _on_rooms_loaded(_rooms: Array) -> void:
	_refresh_room_list()

	# --export-config: save config for each room and exit
	if _pending_export_config:
		_pending_export_config = false
		var rooms := KeetService.get_rooms()
		print("[ExportConfig] %d rooms:" % rooms.size())
		for rid: String in rooms:
			print("  %s  %s" % [rid, rooms[rid]])
		# Export config for the first room, or --room if specified
		var target_room := _pending_auto_room
		var target_rid := _pending_auto_room_id
		if target_rid.is_empty() and target_room.is_empty():
			# No room specified — export first room
			for rid: String in rooms:
				target_rid = rid
				break
		if not target_room.is_empty():
			for rid: String in rooms:
				if rooms[rid] == target_room:
					target_rid = rid
					break
		if not target_rid.is_empty():
			var config := {
				"room_id": target_rid,
				"room_name": rooms.get(target_rid, ""),
				"role": KeetService.current_role,
				"diag_path": "/tmp/hypertps-diag.json"
			}
			var config_path := OS.get_environment("HOME") + "/.config/hyper-tps/headless.json"
			DirAccess.make_dir_recursive_absolute(config_path.get_base_dir())
			var f := FileAccess.open(config_path, FileAccess.WRITE)
			if f:
				f.store_string(JSON.stringify(config, "  "))
				f.close()
				print("[ExportConfig] Wrote: %s" % config_path)
				print(JSON.stringify(config, "  "))
			else:
				print("[ExportConfig] ERROR: cannot write %s" % config_path)
		else:
			print("[ExportConfig] ERROR: no room found to export")
		get_tree().quit()
		return

	# --list-rooms: print and exit
	if _pending_list_rooms:
		var rooms := KeetService.get_rooms()
		print("[Rooms] %d rooms:" % rooms.size())
		for rid: String in rooms:
			print("  %s  %s" % [rid, rooms[rid]])
		get_tree().quit()
		return

	# --room-id: exact ID join (no identity wait needed — name broadcasts later).
	# Accepts a PREFIX so the truncated .left(16) form printed elsewhere in the logs
	# (e.g. "_start_mesh room=ar5jd5tc3od5hbxx") resolves — this makes deterministic
	# headless two-machine mesh testing reliable (hyper-fleeky-dev #93): both peers
	# get driven to _start_mesh without a GUI, from the same short id seen in a log.
	if not _pending_auto_room_id.is_empty():
		var rid := _pending_auto_room_id
		_pending_auto_room_id = ""
		var rooms := KeetService.get_rooms()
		var match_id := ""
		if rooms.has(rid):
			match_id = rid
		else:
			# Unique-prefix fallback. Ambiguous prefix → bail rather than guess.
			for cand: String in rooms:
				if cand.begins_with(rid):
					if match_id != "":
						_log("[Dedicated] ERROR: room_id prefix=%s is AMBIGUOUS — pass a longer id" % rid)
						match_id = ""
						break
					match_id = cand
		if match_id != "":
			_log("[Dedicated] joining room_id=%s title=%s" % [match_id, rooms[match_id]])
			KeetService.select_room(match_id)
			_print_invite(match_id)
		else:
			_log("[Dedicated] ERROR: room_id=%s not found among %d rooms: %s" % [rid, rooms.size(), str(rooms.keys())])
		return

	# --room: name lookup — defer until identity also ready
	_try_auto_join()


func _refresh_room_list() -> void:
	for child in room_list.get_children():
		child.queue_free()

	var rooms := KeetService.get_rooms()
	if rooms.is_empty():
		var label := Label.new()
		label.text = "No rooms yet — create or join one"
		label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
		label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
		room_list.add_child(label)
		return

	for rid: String in rooms:
		var title: String = rooms[rid]
		var is_home: bool = KeetService.is_home(rid)
		if is_home:
			title = "Home"
		var row := HBoxContainer.new()
		row.size_flags_horizontal = Control.SIZE_EXPAND_FILL

		# Room name is a clickable button (flat style)
		var name_btn := Button.new()
		name_btn.text = title
		name_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		name_btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
		name_btn.flat = rid != _selected_room_id
		if rid == _selected_room_id:
			name_btn.disabled = true
			name_btn.text = "> " + title
		else:
			name_btn.pressed.connect(_on_select_room.bind(rid))
		row.add_child(name_btn)

		var invite_btn := Button.new()
		invite_btn.text = "Invite"
		invite_btn.custom_minimum_size.x = 60
		invite_btn.pressed.connect(_on_get_invite.bind(rid))
		row.add_child(invite_btn)

		# NO PURGE FOR HOME. Purging leaves the room; the daemon would then answer
		# HOME_ROOM_MISSING for this identity, by design, and Home would be gone.
		if not is_home:
			var purge_btn := Button.new()
			purge_btn.text = "Purge"
			purge_btn.custom_minimum_size.x = 60
			purge_btn.pressed.connect(_on_purge_room.bind(rid))
			row.add_child(purge_btn)

		room_list.add_child(row)


func _on_select_room(room_id: String) -> void:
	KeetService.select_room(room_id)


## Purge leaves the keet room (keet-daemon room.purge → core.leaveRoom), so
## getting back in needs a new invite. Ask first; the purge runs only on OK.
func _on_purge_room(room_id: String) -> void:
	var title: String = KeetService.get_rooms().get(room_id, room_id.left(16))
	var dlg := ConfirmationDialog.new()
	dlg.title = "Purge room"
	dlg.dialog_text = "Leave \"%s\" and delete its local data?\n\nYou will need a new invite to rejoin." % title
	dlg.ok_button_text = "Purge"
	dlg.confirmed.connect(func():
		dlg.queue_free()
		_purge_room_confirmed(room_id))
	dlg.canceled.connect(dlg.queue_free)
	add_child(dlg)
	dlg.popup_centered()


func _purge_room_confirmed(room_id: String) -> void:
	if room_id == _selected_room_id:
		if _mesh_active:
			_disconnect_mesh()
		_selected_room_id = ""
		current_room_label.text = "No room selected"
	KeetService.room_mgr.purge_room(room_id, func(_result):
		_refresh_room_list()
	)


func _on_room_selected(room_id: String, title: String) -> void:
	# Disconnect existing mesh when switching rooms
	if _mesh_active:
		_disconnect_mesh()
	elif _game_started and KeetService.is_home(_selected_room_id) and room_id != _selected_room_id:
		# LEAVING HOME. Home runs on the offline peer with no mesh, so the branch above
		# never fires for it -- and _finish_mesh_join's _start_game() returns early
		# while _game_started is true, so the next room would be played inside the
		# home session. End it the way _disconnect_mesh ends a meshed one.
		_end_home_session()

	_selected_room_id = room_id
	if KeetService.is_home(room_id):
		# HOME: the offline peer, no mesh, no DHT. The mesh path starts the game only
		# after a DHT bootstrap, which never finishes offline (10 s timeout, and then
		# no game at all) -- and Home is single-player until you invite someone.
		current_room_label.text = "Room: Home"
		_peer_registry.clear()
		_rebuild_peer_list()
		_refresh_room_list()
		if not _game_started:
			_start_offline()
		hide_menu()
		return
	if room_id.is_empty():
		current_room_label.text = "No room selected"
		multiplayer.multiplayer_peer = null
	else:
		current_room_label.text = "Room: %s" % title
		# Start native mesh discovery for this room
		_start_mesh(room_id)
	_peer_registry.clear()
	_rebuild_peer_list()
	_refresh_room_list()


func _on_create_room_pressed() -> void:
	KeetService.room_mgr.create_room("HyperTPS Game Room", func(rid):
		if rid is String and not rid.is_empty():
			KeetService.select_room(rid)
	)


func _on_join_invite_pressed() -> void:
	var invite := join_invite_input.text.strip_edges()
	if invite.is_empty():
		status_label.text = "Paste an invite link first"
		return
	status_label.text = "Joining room..."
	KeetService.room_mgr.join_room(invite, func(rid):
		if rid is String and not rid.is_empty():
			KeetService.select_room(rid)
		else:
			status_label.text = "Pairing started — room will appear when ready"
		join_invite_input.text = ""
	)


func _on_get_invite(room_id: String) -> void:
	KeetService.room_mgr.create_invite(room_id, func(token):
		if token is String and not token.is_empty():
			DisplayServer.clipboard_set(token)
			status_label.text = "Invite copied to clipboard"
	)


# ---- Native Mesh Networking ----

## Stable per-identity DHT keypair. Reuse one persisted key across restarts so we
## don't announce a NEW ephemeral key on the room topic every launch — fresh keys
## pile up (each lingers the 20min DHT TTL after the process dies), pollute topic
## discovery, and cause two live peers to mis-route holepunch among the ghosts
## (spec/next-fixes.md A1). Keyed by Keet memberId so two identities on one machine
## get distinct keys (no collision). Falls back to a fresh key if identity isn't
## loaded yet, or if the persisted key is missing/corrupt.
func _stable_or_new_dht_keypair() -> Dictionary:
	var member_id: String = KeetService.current_member_id if KeetService else ""
	if member_id == "":
		_log("[Menu] DHT key: no memberId yet — ephemeral key this session")
		return _peer.generate_dht_keypair()
	var path := "user://dht_key_%s.hex" % member_id
	if FileAccess.file_exists(path):
		var f := FileAccess.open(path, FileAccess.READ)
		if f != null:
			var pk: PackedByteArray = f.get_line().strip_edges().hex_decode()
			var sk: PackedByteArray = f.get_line().strip_edges().hex_decode()
			f.close()
			if pk.size() == 32 and sk.size() > 0:
				_log("[Menu] DHT key: reusing stable key for %s" % member_id.left(8))
				return {"public_key": pk, "secret_key": sk}
			_log("[Menu] DHT key: persisted key invalid — regenerating")
	var kp: Dictionary = _peer.generate_dht_keypair()
	var fw := FileAccess.open(path, FileAccess.WRITE)
	if fw != null:
		fw.store_line((kp["public_key"] as PackedByteArray).hex_encode())
		fw.store_line((kp["secret_key"] as PackedByteArray).hex_encode())
		fw.close()
		_log("[Menu] DHT key: generated + persisted stable key for %s" % member_id.left(8))
	return kp


## Start mesh discovery for a room. All peers are equal — no server.
## Authority is determined by Keet room roles via _elect_authority().
func _start_mesh(room_id: String) -> void:
	_log("[Menu] _start_mesh room=%s" % room_id.left(16))
	if not _has_hyperswarm:
		status_label.text = "Multiplayer unavailable (GDExtension not loaded)"
		return

	_peer = ClassDB.instantiate(&"HyperswarmMultiplayerPeer")

	# DHT keypair: stable per-identity (persisted) so restarts reuse ONE announce
	# instead of littering a fresh ephemeral key on the room topic every launch.
	# Stale ephemeral announces (20min DHT TTL) pollute discovery and break
	# two-peer meshing — see spec/next-fixes.md A1. Keyed by Keet memberId so two
	# identities on the same machine never collide.
	_dht_kp = _stable_or_new_dht_keypair()
	_my_pk_hex = (_dht_kp["public_key"] as PackedByteArray).hex_encode()
	_log("[Menu] DHT identity: %s" % _my_pk_hex.left(16))
	# Publish the dialable address into durable presence. Without this a peer is
	# discoverable ONLY through topic announce: game.gd could dial a peer it had
	# already met this session and nothing else, so a restarted peer came up
	# unable to dial anyone (hyper-games #77 item 2). Presence re-announces on its
	# own once identity and room land, so calling this before either is fine.
	if Presence:
		Presence.set_dht_public_key(_my_pk_hex)
	# Prove to every peer we meet that this mesh key is our keet member (#89 §3.3).
	KeetService.peer_identity.set_my_dht_key(_my_pk_hex)
	# (Phase 2.5 of #32: Presence auto-announces when KeetIdentity loads
	# the stable memberId — no need to manually announce on DHT keygen.)

	# Connect swarm_connection signal for peer discovery
	_peer.swarm_connection.connect(_on_swarm_connection)

	# Asset transfer rides this same connection. Attaching here — before join_dht,
	# let alone before the mesh forms — is deliberate and safe: the overlay replays
	# peers that already exist and picks up ones that arrive later, so there is no
	# ordering to get right (hyper-games #15).
	if ContentOverlay and ContentOverlay.is_ready():
		ContentOverlay.attach_peer(_peer)

	# Join the DHT network (async — thread starts bootstrapping)
	var err: int = _peer.join_dht(_dht_kp["public_key"], _dht_kp["secret_key"])
	if err != OK:
		status_label.text = "Failed to join DHT: %s" % error_string(err)
		return

	# DHT init is async (runs on transport thread). Poll until active, then join swarm.
	var topic_str := "hyper-game:room:%s" % room_id
	var topic: PackedByteArray = topic_str.sha256_buffer()
	_log("[Menu] topic: %s" % topic.hex_encode().left(16))
	_pending_swarm_topic = topic
	_wait_for_dht_and_join()


var _pending_swarm_topic: PackedByteArray = PackedByteArray()
var _dht_poll_count: int = 0


func _wait_for_dht_and_join() -> void:
	if _peer == null or _pending_swarm_topic.is_empty():
		return

	var status: Dictionary = _peer.get_dht_status()
	if status.get("bootstrapped", false):
		_log("[Menu] DHT bootstrapped after %d polls (~%dms), joining swarm..." % [_dht_poll_count, _dht_poll_count * 50])
		_finish_mesh_join()
		return

	_dht_poll_count += 1
	if _dht_poll_count > 200:  # 10 seconds max (200 * 50ms)
		_log("[Menu] ERROR: DHT failed to bootstrap after 10s")
		status_label.text = "DHT bootstrap timed out"
		return

	# Poll again in 50ms
	get_tree().create_timer(0.05).timeout.connect(_wait_for_dht_and_join)


func _finish_mesh_join() -> void:
	var topic := _pending_swarm_topic
	_pending_swarm_topic = PackedByteArray()
	_dht_poll_count = 0

	var err: int = _peer.swarm_join(topic, true, true)
	if err != OK:
		_log("[Menu] swarm_join failed: %s" % error_string(err))
		status_label.text = "Failed to join swarm: %s" % error_string(err)
		return

	multiplayer.multiplayer_peer = _peer
	_mesh_active = true
	host_button.text = "Disconnect"
	status_label.text = "Mesh active — discovering peers..."

	# Elect authority BEFORE starting game — on_game_started() checks
	# is_multiplayer_authority() to decide whether to spawn the local player.
	_peer_registry.clear()
	KeetService.register_peer_role(_my_pk_hex, KeetService.current_role)
	_elect_authority()

	# Now start the game — authority is set, so self-spawn works
	_start_game()


## End a Home session (offline peer) so another room can start fresh -- the game
## half of _disconnect_mesh, which Home has no mesh for.
func _end_home_session() -> void:
	multiplayer.multiplayer_peer = null
	_game_started = false
	var game := get_tree().root.get_node_or_null("Game")
	if game and game.has_method("reset_for_rejoin"):
		game.reset_for_rejoin()


## Disconnect from mesh and clean up.
func _disconnect_mesh() -> void:
	_mesh_active = false
	_peer_registry.clear()
	_authority_id = 0
	host_button.text = "Connect"
	multiplayer.multiplayer_peer = null
	# BEFORE the reference goes, and this is the only place that can do it: the
	# overlay is the last holder of the peer, so without this the transport thread,
	# its socket and its announces on the room's topic outlive the room — until the
	# next _start_mesh() happens to attach a replacement. Detaching here also tears
	# the overlay channels down on the loop that owns them, which is the ordering
	# HyperOverlay wants (hyperswarm-peer eb8f547).
	if ContentOverlay and ContentOverlay.is_ready():
		ContentOverlay.detach_peer()
	_peer = null
	_game_started = false
	var game := get_tree().root.get_node_or_null("Game")
	if game and game.has_method("reset_for_rejoin"):
		game.reset_for_rejoin()
	status_label.text = "Disconnected"
	show_menu()


## Called when a new peer connects via swarm topic discovery.
## peer_id = Godot multiplayer ID, pk = Ed25519 DHT public key.
func _on_swarm_connection(peer_id: int, pk: PackedByteArray, is_client: bool) -> void:
	var pk_hex := pk.hex_encode()
	_log("[Menu] swarm_connection peer_id=%d pk=%s is_client=%s" % [peer_id, pk_hex.left(16), is_client])

	# Register peer with default role (updated by RPC exchange)
	_peer_registry[pk_hex] = {"peer_id": peer_id, "role": "peer"}
	# The handshake-authenticated mesh key: PeerIdentity verifies this peer's
	# member claim against it (#89 §3.3).
	KeetService.peer_identity.set_peer_key(peer_id, pk_hex)
	_rebuild_peer_list()
	status_label.text = "Mesh active — %d peers connected" % _peer_registry.size()
	# (Phase 2.5 of #32: Presence now writes ONLY its own entry, keyed
	# on Keet memberId — auto-announced via KeetIdentity.identity_loaded.
	# Nothing to do here for remote peers; they write their own entries.)


## Multiplayer peer_connected — fires after Godot handshake completes.
## Exchange roles via RPC now that the multiplayer channel is up.
func _on_multiplayer_peer_connected(id: int) -> void:
	_log("[Menu] multiplayer peer_connected id=%d" % id)
	# Announce our role to the new peer
	_announce_role.rpc_id(id, _my_pk_hex, KeetService.current_role)
	if not _game_started and _mesh_active:
		_start_game()
		hide_menu()


## RPC: A peer announces their DHT public key and Keet role.
## The announced role is treated as a HINT — authority verifies from daemon.
@rpc("any_peer", "reliable")
func _announce_role(pk_hex: String, role: String) -> void:
	var sender_id := multiplayer.get_remote_sender_id()
	_log("[Menu] role announced: peer_id=%d pk=%s claimed_role=%s" % [sender_id, pk_hex.left(12), role])

	if _peer_registry.has(pk_hex):
		_peer_registry[pk_hex]["role"] = role
	else:
		_peer_registry[pk_hex] = {"peer_id": sender_id, "role": role}

	# Use claimed role as initial hint for fast authority election
	KeetService.register_peer_role(pk_hex, role)
	_rebuild_peer_list()
	_elect_authority()

	# Authority verifies claimed role from daemon member list (source of truth)
	if multiplayer.get_unique_id() == _authority_id:
		_verify_peer_role(pk_hex)


## Multiplayer peer_disconnected — remove from registry and re-elect authority.
func _on_multiplayer_peer_disconnected(id: int) -> void:
	_log("[Menu] multiplayer peer_disconnected id=%d" % id)
	for pk_hex in _peer_registry:
		if _peer_registry[pk_hex]["peer_id"] == id:
			_peer_registry.erase(pk_hex)
			break
	_rebuild_peer_list()
	_elect_authority()
	if _mesh_active:
		status_label.text = "Mesh active — %d peers connected" % _peer_registry.size()


## Keet role changed — refresh authority election.
func _on_role_changed(_room_id: String, _role: String) -> void:
	_elect_authority()


## Deterministic authority election: sort by (role_priority, pk), first wins.
## All peers run the same sort → all agree on authority without communication.
func _elect_authority() -> void:
	var candidates: Array = []

	# Include self
	var my_role := KeetService.current_role
	if my_role != "read-only" and not _my_pk_hex.is_empty():
		candidates.append({
			"pk": _my_pk_hex,
			"peer_id": multiplayer.get_unique_id(),
			"role_priority": _role_priority(my_role)
		})

	# Include all connected peers
	for pk_hex in _peer_registry:
		var entry: Dictionary = _peer_registry[pk_hex]
		if entry["role"] != "read-only":
			candidates.append({
				"pk": pk_hex,
				"peer_id": entry["peer_id"],
				"role_priority": _role_priority(entry["role"])
			})

	# Sort: lowest role_priority first (admin=1), then lowest pk (deterministic)
	candidates.sort_custom(func(a, b):
		if a["role_priority"] != b["role_priority"]:
			return a["role_priority"] < b["role_priority"]
		return a["pk"] < b["pk"])

	if candidates.is_empty():
		return  # Only spectators — no authority

	var new_authority: int = candidates[0]["peer_id"]
	if new_authority != _authority_id:
		_authority_id = new_authority
		_log("[Menu] authority elected: peer_id=%d (role=%d, pk=%s)" % [
			new_authority, candidates[0]["role_priority"], candidates[0]["pk"].left(12)])
		var game := get_tree().root.get_node_or_null("Game")
		if game:
			# Non-recursive: don't overwrite per-player authorities under Players/
			game.set_multiplayer_authority(new_authority, false)
			var spawner := game.get_node_or_null("PlayerSpawner")
			if spawner:
				spawner.set_multiplayer_authority(new_authority, false)
			# New authority must spawn any missing players (including self)
			if new_authority == multiplayer.get_unique_id() and game.has_method("ensure_all_spawned"):
				game.ensure_all_spawned()
		# Bots (and the creatures they now are) change simulator with it (#114 phase 5).
		var reg := get_tree().root.get_node_or_null("BotRegistry")
		if reg != null:
			reg.on_authority_changed()


static func _role_priority(role: String) -> int:
	match role:
		"admin": return 1
		"mod": return 2
		"peer": return 3
		_: return 99  # read-only / unknown


## Verify a peer's claimed role against the daemon's member list.
## Only the authority runs this — corrects false claims and re-elects if needed.
func _verify_peer_role(pk_hex: String) -> void:
	if not KeetService or not KeetService.room_mgr:
		return
	var room_id: String = KeetService.current_room_id
	if room_id.is_empty():
		return
	KeetService.room_mgr.list_members(room_id, func(members: Array):
		var verified_role: String = "peer"  # Default if not found in member list
		for member in members:
			if member is Dictionary:
				# Match by public key if available, or by memberId
				var member_pk: String = member.get("publicKey", "")
				if member_pk == pk_hex:
					var role_int: int = member.get("role", 3)
					match role_int:
						1: verified_role = "admin"
						2: verified_role = "mod"
						3: verified_role = "peer"
						4: verified_role = "read-only"
					break

		if not _peer_registry.has(pk_hex):
			return

		var claimed: String = _peer_registry[pk_hex].get("role", "peer")
		if claimed != verified_role:
			_log("[Menu] ROLE CORRECTION: pk=%s claimed=%s verified=%s" % [pk_hex.left(12), claimed, verified_role])
			_peer_registry[pk_hex]["role"] = verified_role
			KeetService.register_peer_role(pk_hex, verified_role)
			_rebuild_peer_list()
			_elect_authority()
	)


func _on_host_pressed() -> void:
	# Repurposed as disconnect/reconnect toggle
	if _mesh_active:
		_disconnect_mesh()
	elif not _selected_room_id.is_empty():
		_start_mesh(_selected_room_id)


## Typing no longer sets a local-only name (it forked the game from keet, #89
## defect 3). Enter renames in keet: NameInput.text_submitted → IdentityFlow.rename.
func _on_name_changed(_new_text: String) -> void:
	pass


func _on_quick_connect_pressed() -> void:
	var room_name := quick_connect_input.text.strip_edges()
	if room_name.is_empty():
		status_label.text = "Enter a room name first"
		return
	if _mesh_active:
		_disconnect_mesh()
	status_label.text = "Creating room: %s..." % room_name
	_auto_join_or_create_room(room_name)
	quick_connect_input.text = ""


func _on_network_back_pressed() -> void:
	_show_main()


func _on_save_contact(public_key: String, info: Dictionary) -> void:
	Contacts.save_contact(public_key, info.get("name", "Unknown"), info.get("memberId", ""))
	_rebuild_peer_list()
	_refresh_contacts()


# ---- Peer List UI ----

func _rebuild_peer_list() -> void:
	for child in peer_list.get_children():
		child.queue_free()

	if _peer_registry.is_empty():
		var label := Label.new()
		label.text = "No peers connected yet..."
		label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
		label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
		peer_list.add_child(label)
		return

	for pk_hex in _peer_registry:
		var entry: Dictionary = _peer_registry[pk_hex]
		var row := HBoxContainer.new()
		row.size_flags_horizontal = Control.SIZE_EXPAND_FILL

		var name_label := Label.new()
		# The verified keet name (or anon-<4>), not a raw key (#89 §3.3).
		name_label.text = KeetService.peer_identity.name_for_peer(int(entry["peer_id"]))
		name_label.tooltip_text = pk_hex
		name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		row.add_child(name_label)

		var role_label := Label.new()
		role_label.text = entry["role"]
		role_label.custom_minimum_size.x = 60
		if entry["role"] == "admin":
			role_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2))
		elif entry["role"] == "mod":
			role_label.add_theme_color_override("font_color", Color(0.3, 0.8, 1.0))
		else:
			role_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
		row.add_child(role_label)

		var is_authority: bool = (entry["peer_id"] == _authority_id)
		var auth_label := Label.new()
		if is_authority:
			auth_label.text = "AUTHORITY"
			auth_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.3))
		else:
			auth_label.text = ""
		auth_label.custom_minimum_size.x = 80
		row.add_child(auth_label)

		var save_btn := Button.new()
		if Contacts.is_contact(pk_hex):
			save_btn.text = "Saved"
			save_btn.disabled = true
		else:
			save_btn.text = "Save"
			save_btn.pressed.connect(_on_save_contact.bind(pk_hex, {
				"name": name_label.text,
				"memberId": KeetService.peer_identity.member_of(int(entry["peer_id"]))}))
		save_btn.custom_minimum_size.x = 60
		row.add_child(save_btn)

		peer_list.add_child(row)


# ---- Contact List ----

func _refresh_contacts() -> void:
	for child in contact_list.get_children():
		child.queue_free()

	var all := Contacts.get_all()
	if all.is_empty():
		var label := Label.new()
		label.text = "No saved contacts"
		label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
		label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
		contact_list.add_child(label)
		return

	for pk in all:
		var info: Dictionary = all[pk]
		var row := HBoxContainer.new()
		row.size_flags_horizontal = Control.SIZE_EXPAND_FILL

		var name_label := Label.new()
		name_label.text = info.get("name", "Unknown")
		name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		row.add_child(name_label)

		var is_online := _peer_registry.has(pk)
		var online_label := Label.new()
		if is_online:
			online_label.text = "Online"
			online_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.3))
		else:
			online_label.text = "Offline"
			online_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5))
		online_label.custom_minimum_size.x = 60
		row.add_child(online_label)

		var remove_btn := Button.new()
		remove_btn.text = "Remove"
		remove_btn.custom_minimum_size.x = 70
		remove_btn.pressed.connect(_on_remove_contact.bind(pk))
		row.add_child(remove_btn)

		contact_list.add_child(row)


func _on_remove_contact(public_key: String) -> void:
	Contacts.remove_contact(public_key)
	_refresh_contacts()


# ---- Debug Panel ----

func _on_debug_toggle_pressed() -> void:
	debug_panel.visible = not debug_panel.visible
	debug_toggle.text = "Hide Debug Info" if debug_panel.visible else "Show Debug Info"
	if debug_panel.visible:
		_refresh_debug_stats()


func _on_refresh_stats_pressed() -> void:
	_refresh_debug_stats()


func _on_copy_stats_pressed() -> void:
	_refresh_debug_stats()
	# Strip BBCode for plain text
	var plain := debug_stats.text
	while plain.find("[") != -1:
		var open := plain.find("[")
		var close := plain.find("]", open)
		if close == -1:
			break
		plain = plain.substr(0, open) + plain.substr(close + 1)
	# Add device identifier
	plain = "Device: %s\n%s" % [OS.get_unique_id().left(8), plain]
	DisplayServer.clipboard_set(plain)
	status_label.text = "Debug stats copied to clipboard"


func _on_copy_invite_pressed() -> void:
	if _selected_room_id.is_empty():
		status_label.text = "No room selected"
		return
	_on_get_invite(_selected_room_id)


func _on_interest_toggled(enabled: bool) -> void:
	Settings.interest_management = enabled
	Settings.save_settings()
	var game := get_tree().root.get_node_or_null("Game")
	if game:
		game.interest_enabled = enabled


func _refresh_debug_stats() -> void:
	var lines := PackedStringArray()

	# Identity
	lines.append("[color=#66ccff]Identity[/color]")
	if _my_pk_hex.is_empty():
		lines.append("  DHT Key: (not started)")
	else:
		lines.append("  DHT Key: %s..." % _my_pk_hex.left(24))
	lines.append("  Name: %s" % GameManager.player_name)
	lines.append("  Keet Role: %s" % KeetService.current_role)

	# Room info
	if _selected_room_id != "":
		lines.append("")
		lines.append("[color=#66ccff]Room[/color]")
		lines.append("  ID: %s..." % _selected_room_id.left(16))

	# GDExtension stats
	if _has_hyperswarm and _peer != null:
		lines.append("")
		lines.append("[color=#66ccff]DHT[/color]")
		if _peer.has_method("get_dht_status"):
			var dht: Dictionary = _peer.get_dht_status()
			lines.append("  Active: %s" % ("yes" if dht.get("active", false) else "no"))
			lines.append("  Port: %d" % dht.get("bound_port", 0))
			var punches: Dictionary = dht.get("punches", {})
			if not punches.is_empty():
				lines.append("  Punches: %d total, %d open" % [punches.get("total", 0), punches.get("open", 0)])

		lines.append("")
		lines.append("[color=#66ccff]Swarm[/color]")
		if _peer.has_method("get_swarm_status"):
			var sw: Dictionary = _peer.get_swarm_status()
			lines.append("  Connected: %d  Known: %d" % [sw.get("peers_connected", 0), sw.get("peers_known", 0)])
			lines.append("  Connecting: %d  Queue: %d" % [sw.get("connecting", 0), sw.get("queue_size", 0)])

		if _peer.has_method("get_stats"):
			lines.append("")
			lines.append("[color=#66ccff]UDX Transport[/color]")
			var gs: Dictionary = _peer.get_stats()
			lines.append("  Unique ID: %d" % gs.get("unique_id", 0))
			lines.append("  Port: %d" % gs.get("bound_port", 0))
			lines.append("  Queued: %d" % gs.get("incoming_packets", 0))

		lines.append("  Peers: %s" % str(multiplayer.get_peers()))

		# Per-connection diagnostics
		if _peer.has_method("get_connection_diagnostics"):
			var conns: Array = _peer.get_connection_diagnostics()
			if not conns.is_empty():
				lines.append("")
				lines.append("[color=#66ccff]Connections (%d)[/color]" % conns.size())
				for c: Dictionary in conns:
					var state: String = c.get("state", "?")
					var color := "white"
					match state:
						"CONNECTED": color = "#66ff66"
						"FAILED": color = "#ff6666"
						"PUNCHING", "HOLEPUNCHING": color = "#ffcc66"
						_: color = "#cccccc"
					lines.append("  [color=%s]#%d %s[/color] init=%s pk=%s" % [
						color, c.get("id", 0), state,
						"yes" if c.get("is_initiator", false) else "no",
						c.get("remote_pk", "?")])
					lines.append("    relay=%s server=%s" % [
						c.get("relay", "-"), c.get("server", "-")])
					lines.append("    punch=%s addr=%s round=%d" % [
						"yes" if c.get("punched", false) else "no",
						c.get("punched_address", "-"),
						c.get("holepunch_round", 0)])
					lines.append("    fw: local=%d remote=%d elapsed=%dms" % [
						c.get("firewall_local", 0),
						c.get("firewall_remote", 0),
						c.get("elapsed_ms", 0)])
					var addrs: Array = c.get("remote_addresses", [])
					if not addrs.is_empty():
						lines.append("    remote_addrs=%s" % str(addrs))
					var err_str: String = c.get("error", "")
					if not err_str.is_empty():
						lines.append("    [color=#ff6666]error: %s[/color]" % err_str)
	else:
		lines.append("")
		lines.append("[color=#ff6666]Swarm: not started[/color]")

	# Authority
	lines.append("")
	lines.append("[color=#66ccff]Authority[/color]")
	lines.append("  Current: peer_id=%d" % _authority_id)
	lines.append("  Is me: %s" % ("yes" if _authority_id == multiplayer.get_unique_id() else "no"))
	lines.append("  Registry: %d peers" % _peer_registry.size())

	# Keet daemon
	if KeetService.client.is_connected:
		lines.append("Keet: [color=#66ff66]ok[/color]")
	else:
		lines.append("Keet: [color=#ff6666]offline[/color]")

	debug_stats.text = "\n".join(lines)


# ---- Game Start ----

func _start_offline() -> void:
	var peer := OfflineMultiplayerPeer.new()
	multiplayer.multiplayer_peer = peer
	_start_game()


func _start_game() -> void:
	if _game_started:
		return
	_game_started = true
	var game := get_tree().root.get_node_or_null("Game")
	if game and game.has_method("on_game_started"):
		game.on_game_started()
	if _benchmark_mode and game:
		game.start_benchmark()
		# Defer benchmark input activation so the player node is ready
		_activate_benchmark_input.call_deferred()


func _activate_benchmark_input() -> void:
	var game: Node = get_tree().root.get_node_or_null("Game")
	if not game:
		return
	var my_player: Node = game.players_node.get_node_or_null(str(multiplayer.get_unique_id()))
	if my_player:
		var input_node: Node = my_player.get_node_or_null("InputSync")
		if input_node:
			input_node.benchmark = true
			_log("[Benchmark] activated for player %d" % multiplayer.get_unique_id())


# ---- Helpers ----

func _get_peer_display_name(public_key: String) -> String:
	if Contacts.is_contact(public_key):
		return Contacts.get_contact_name(public_key)
	return public_key.left(8)


# ---- Headless Config + Diagnostics ----

func _load_config(path: String) -> Dictionary:
	var f := FileAccess.open(path, FileAccess.READ)
	if not f:
		_log("[Headless] ERROR: cannot read config: %s" % path)
		return {}
	var text := f.get_as_text()
	f.close()
	var json := JSON.new()
	var err := json.parse(text)
	if err != OK:
		_log("[Headless] ERROR: invalid JSON in config: %s" % path)
		return {}
	if json.data is Dictionary:
		_log("[Headless] loaded config: %s" % path)
		return json.data
	return {}


func _process(delta: float) -> void:
	if _diag_path.is_empty():
		return
	_diag_timer += delta
	if _diag_timer < 1.0:
		return
	_diag_timer = 0.0
	_write_diagnostics()


func _write_diagnostics() -> void:
	var diag := {}
	diag["ts"] = int(Time.get_unix_time_from_system())
	diag["mesh_active"] = _mesh_active
	diag["room_id"] = _selected_room_id
	diag["my_pk"] = _my_pk_hex.left(16)
	diag["authority_id"] = _authority_id
	diag["peers_in_registry"] = _peer_registry.size()
	diag["godot_peers"] = Array(multiplayer.get_peers())

	# Player spawn count (how many player nodes in the world)
	var game_node := get_tree().root.get_node_or_null("Game")
	if game_node and game_node.has_node("Players"):
		diag["players_spawned"] = game_node.get_node("Players").get_child_count()
	else:
		diag["players_spawned"] = 0

	# Swarm status
	var swarm := {}
	if _has_hyperswarm and _peer != null and _peer.has_method("get_swarm_status"):
		var sw: Dictionary = _peer.get_swarm_status()
		swarm["peers_connected"] = sw.get("peers_connected", 0)
		swarm["peers_known"] = sw.get("peers_known", 0)
		swarm["connecting"] = sw.get("connecting", 0)
		swarm["queue_size"] = sw.get("queue_size", 0)
		swarm["flushed"] = sw.get("flushed", false)
	diag["swarm"] = swarm

	# DHT status
	var dht := {}
	if _has_hyperswarm and _peer != null and _peer.has_method("get_dht_status"):
		var ds: Dictionary = _peer.get_dht_status()
		dht["bootstrapped"] = ds.get("bootstrapped", false)
		dht["bound_port"] = ds.get("bound_port", 0)
		var punches: Dictionary = ds.get("punches", {})
		if not punches.is_empty():
			dht["punches_total"] = punches.get("total", 0)
			dht["punches_open"] = punches.get("open", 0)
			dht["punches_timeouts"] = punches.get("timeouts", 0)
	diag["dht"] = dht

	# Per-connection diagnostics
	var connections: Array = []
	if _has_hyperswarm and _peer != null and _peer.has_method("get_connection_diagnostics"):
		connections = _peer.get_connection_diagnostics()
	diag["connections"] = connections

	# Benchmark stats
	if _benchmark_mode and game_node and game_node.has_method("get_benchmark_summary"):
		diag["benchmark"] = game_node.get_benchmark_summary()

	# Write atomically (write to .tmp, rename)
	var tmp_path := _diag_path + ".tmp"
	var f := FileAccess.open(tmp_path, FileAccess.WRITE)
	if f:
		f.store_string(JSON.stringify(diag, "  "))
		f.close()
		DirAccess.rename_absolute(tmp_path, _diag_path)