Files
modbison/scripts/main_menu.gd
T
claude-checkpoint-tooling 3679e4dbc4 Fix: Login/Register unreachable from the main menu -- no button opened it
Reported directly by the user playtesting the exported exe: Play was
permanently disabled and there was no way to log in or register. Root
cause: main_menu.tscn wired Play and Quit, but nothing was ever
connected to _on_login_button_pressed()/_on_register_button_pressed() --
the AuthPanel, its form, and all its backend-calling logic were fully
correct, just completely unreachable. The script's own top comment even
said "(the existing Play/Quit buttons)" as if Play/Quit somehow doubled
as login triggers, which they never did -- a sign nobody ever actually
clicked through the running scene, only checked things at the API level.

Fix: added a real AuthButtonsRow (LoginButton, RegisterButton) wired to
the existing handlers, which now hide themselves once auth succeeds.
Also fixed grab_focus() targeting the AuthPanel (which can't accept
focus, just a background warning) to focus the username field instead.

Verified via two new smoke tests (smoke_test_main_menu_ui.gd,
smoke_test_main_menu_full_flow.gd) using a *real signal emission* on
the buttons, not a direct method call -- that distinction is exactly
what would have caught the original bug. Full click-through confirmed
against a real backend: open register form, fill it, submit, Play
actually becomes enabled, welcome message shows, auth buttons hide.

Also documents a real headless-testing gotcha found while verifying
this: a synthetic Button.pressed.emit() on an await-using handler
doesn't reliably get driven forward in --script/SceneTree mode the way
real GUI input dispatch does -- confirmed by comparing direct-call vs
emit() behavior side by side. Not a bug in the actual click-driven flow,
a test-harness quirk to route around (call the handler directly,
awaited, instead of emitting the signal).

Full checkpoint suite re-verified clean on this branch: migrations,
68/68 pytest, all 6 Godot smoke tests (including the 2 new ones, picked
up automatically by run_checkpoint_tests.py's glob).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 13:13:38 +00:00

223 lines
8.0 KiB
GDScript

extends Control
## Main menu with login/register UI wired to the backend auth API.
##
## The AuthPanel is hidden by default. When the user clicks the LoginButton or
## RegisterButton (AuthButtonsRow, above the panel), the auth panel slides in.
## The ModeToggle switches between Login and Register modes, showing/hiding
## the email and DOB fields accordingly.
##
## On successful auth the token is stored via ApiClient, AuthButtonsRow hides,
## the welcome label shows the username, and the Play button becomes enabled.
# ── UI references ────────────────────────────────────────────────────────────
var _auth_buttons_row: HBoxContainer
var _login_button: Button
var _register_button: Button
var _auth_panel: PanelContainer
var _auth_mode_label: Label
var _username_edit: LineEdit
var _password_edit: LineEdit
var _email_edit: LineEdit
var _dob_edit: LineEdit
var _mode_toggle: CheckButton
var _auth_button: Button
var _status_label: Label
var _welcome_label: Label
var _play_button: Button
var _quit_button: Button
var _is_register_mode: bool = false
var _is_authenticating: bool = false
# ── Lifecycle ────────────────────────────────────────────────────────────────
func _ready() -> void:
# Cache node references.
_auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow
_login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton
_register_button = $CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton
_auth_panel = $CenterContainer/VBoxContainer/AuthPanel
_auth_mode_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit
_mode_toggle = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
_play_button = $CenterContainer/VBoxContainer/PlayButton
_quit_button = $CenterContainer/VBoxContainer/QuitButton
# Initial state.
_auth_panel.visible = false
_status_label.visible = false
_welcome_label.visible = false
_email_edit.visible = false
_dob_edit.visible = false
_play_button.disabled = true # Gate play behind successful auth.
_update_auth_mode_ui()
# ── Play / Quit ──────────────────────────────────────────────────────────────
func _on_play_button_pressed() -> void:
if _play_button.disabled:
return
# Browse worlds is the first real screen past login -- actual gameplay
# (joining a world) doesn't exist yet, browsing does.
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
func _on_quit_button_pressed() -> void:
get_tree().quit()
# ── Auth panel toggle ────────────────────────────────────────────────────────
func _on_login_button_pressed() -> void:
"""Show the auth panel, called from AuthButtonsRow/LoginButton."""
_auth_panel.visible = true
_username_edit.grab_focus()
func _on_register_button_pressed() -> void:
"""Show the auth panel in register mode, called from AuthButtonsRow/RegisterButton."""
_auth_panel.visible = true
_is_register_mode = true
_mode_toggle.button_pressed = true
_update_auth_mode_ui()
_clear_status()
_username_edit.grab_focus()
# ── Mode toggle (Login / Register) ───────────────────────────────────────────
func _on_mode_toggle_toggled(button_pressed: bool) -> void:
_is_register_mode = button_pressed
_update_auth_mode_ui()
_clear_status()
func _update_auth_mode_ui() -> void:
"""Update labels and field visibility based on login vs register mode."""
if _is_register_mode:
_auth_mode_label.text = "Register"
_auth_button.text = "Register"
_email_edit.visible = true
_dob_edit.visible = true
else:
_auth_mode_label.text = "Login"
_auth_button.text = "Login"
_email_edit.visible = false
_dob_edit.visible = false
# ── Auth submission ──────────────────────────────────────────────────────────
func _on_auth_button_pressed() -> void:
"""Handle login or register button press."""
if _is_authenticating:
return
# Validate inputs.
var username := _username_edit.text.strip_edges()
var password := _password_edit.text
if username == "":
_show_status("Username is required.")
return
if password == "":
_show_status("Password is required.")
return
if _is_register_mode:
var email := _email_edit.text.strip_edges()
var dob := _dob_edit.text.strip_edges()
if email == "":
_show_status("Email is required.")
return
if dob == "":
_show_status("Date of birth is required.")
return
# Basic YYYY-MM-DD format check.
if not _is_valid_date(dob):
_show_status("Date of birth must be in YYYY-MM-DD format.")
return
_auth_button.disabled = true
_auth_button.text = "Registering..."
_is_authenticating = true
_clear_status()
var result := await ApiClient.register(username, email, password, dob)
_on_auth_complete(result)
else:
_auth_button.disabled = true
_auth_button.text = "Logging in..."
_is_authenticating = true
_clear_status()
var result := await ApiClient.login(username, password)
_on_auth_complete(result)
func _on_auth_complete(result: Dictionary) -> void:
"""Handle the result of a login or register call."""
_auth_button.disabled = false
_auth_button.text = "Register" if _is_register_mode else "Login"
_is_authenticating = false
if result.ok:
# Success — show welcome, enable Play, hide the now-redundant
# Login/Register entry points (the auth panel itself stays open,
# showing the welcome message in place of the form).
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
_welcome_label.visible = true
_status_label.visible = false
_play_button.disabled = false
_auth_buttons_row.visible = false
print("Auth successful. Token stored.")
else:
# Failure — show backend error detail.
_show_status(result.detail)
# ── Status helpers ───────────────────────────────────────────────────────────
func _show_status(message: String) -> void:
_status_label.text = message
_status_label.visible = true
func _clear_status() -> void:
_status_label.text = ""
_status_label.visible = false
# ── Validation ───────────────────────────────────────────────────────────────
func _is_valid_date(date_str: String) -> bool:
"""Check if a string looks like a valid YYYY-MM-DD date."""
if date_str.length() != 10:
return false
var parts := date_str.split("-")
if parts.size() != 3:
return false
var year := parts[0].to_int()
var month := parts[1].to_int()
var day := parts[2].to_int()
if month < 1 or month > 12:
return false
if day < 1 or day > 31:
return false
# Rough month-day validation.
var days_in_month := [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
if day > days_in_month[month]:
return false
return true