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)