diff --git a/backend/app/models/world.py b/backend/app/models/world.py index d10e896..89226ed 100644 --- a/backend/app/models/world.py +++ b/backend/app/models/world.py @@ -21,7 +21,12 @@ class World(Base): description = Column(Text, nullable=True) owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) visibility = Column( - ENUM(WorldVisibility, name="world_visibility", create_type=False), + ENUM( + WorldVisibility, + name="world_visibility", + create_type=False, + values_callable=lambda enum_cls: [e.value for e in enum_cls], + ), default=WorldVisibility.PUBLIC, ) created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/routers/worlds.py b/backend/app/routers/worlds.py index 7f3aadd..83419ae 100644 --- a/backend/app/routers/worlds.py +++ b/backend/app/routers/worlds.py @@ -1,5 +1,6 @@ """Worlds router - CRUD for game worlds.""" +from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query @@ -40,7 +41,7 @@ class WorldResponse(BaseModel): description: str | None visibility: WorldVisibility owner_id: int - created_at: str | None = None + created_at: datetime | None = None model_config = {"from_attributes": True} diff --git a/scenes/main_menu.tscn b/scenes/main_menu.tscn index f8b9ed1..79025a5 100644 --- a/scenes/main_menu.tscn +++ b/scenes/main_menu.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=2 format=3] +[gd_scene load_steps=2 format=3 uid="uid://main_menu_scene"] [ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_main"] @@ -29,21 +29,86 @@ text = "AGP Game" horizontal_alignment = 1 theme_override_font_sizes/font_size = 72 +[node name="AuthPanel" type="PanelContainer" parent="CenterContainer/VBoxContainer"] +layout_mode = 1 +visible = false + +[node name="MarginContainer" type="MarginContainer" parent="CenterContainer/VBoxContainer/AuthPanel"] +layout_mode = 1 +theme_override_constants/margin_left = 24 +theme_override_constants/margin_top = 24 +theme_override_constants/margin_right = 24 +theme_override_constants/margin_bottom = 24 + +[node name="AuthVBox" type="VBoxContainer" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer"] +layout_mode = 1 +theme_override_constants/separation = 8 +size_flags_horizontal = 3 + +[node name="AuthModeLabel" type="Label" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] +layout_mode = 1 +text = "Login" +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 24 + +[node name="UsernameEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] +layout_mode = 1 +placeholder_text = "Username" +size_flags_horizontal = 3 + +[node name="PasswordEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] +layout_mode = 1 +placeholder_text = "Password" +size_flags_horizontal = 3 +echo_password = true + +[node name="EmailEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] +layout_mode = 1 +visible = false +placeholder_text = "Email" +size_flags_horizontal = 3 + +[node name="DOBEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] +layout_mode = 1 +visible = false +placeholder_text = "Date of Birth (YYYY-MM-DD)" +size_flags_horizontal = 3 + +[node name="ModeToggle" type="CheckButton" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] +layout_mode = 1 +text = "Register" + +[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] +layout_mode = 1 +text = "Login" +size_flags_horizontal = 3 + +[node name="AuthStatusLabel" type="Label" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] +layout_mode = 1 +text = "" +horizontal_alignment = 1 +autowrap_mode = 2 +visible = false + +[node name="WelcomeLabel" type="Label" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] +layout_mode = 1 +visible = false +text = "" +horizontal_alignment = 1 + [node name="PlayButton" type="Button" parent="CenterContainer/VBoxContainer"] layout_mode = 1 text = "Play" size_flags_horizontal = 4 -[node name="BrowseButton" type="Button" parent="CenterContainer/VBoxContainer"] -layout_mode = 1 -text = "Browse Worlds" -size_flags_horizontal = 4 - [node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"] layout_mode = 1 text = "Quit" size_flags_horizontal = 4 [connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"] -[connection signal="pressed" from="CenterContainer/VBoxContainer/BrowseButton" to="." method="_on_browse_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"] +[connection signal="toggled" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle" to="." method="_on_mode_toggle_toggled"] +[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"] +[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"] +[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit" to="." method="_on_auth_button_pressed"] diff --git a/scenes/world_list.tscn b/scenes/world_list.tscn index 4ad03ce..e2138e4 100644 --- a/scenes/world_list.tscn +++ b/scenes/world_list.tscn @@ -71,3 +71,6 @@ text = "" visible = false horizontal_alignment = 1 theme_override_font_sizes/font_size = 18 + +[connection signal="pressed" from="VBoxContainer/HeaderHBox/BackButton" to="." method="_on_back_button_pressed"] +[connection signal="pressed" from="VBoxContainer/HeaderHBox/RefreshButton" to="." method="_on_refresh_button_pressed"] diff --git a/scripts/main_menu.gd b/scripts/main_menu.gd index b38e70e..1f5d8c3 100644 --- a/scripts/main_menu.gd +++ b/scripts/main_menu.gd @@ -1,19 +1,212 @@ extends Control +## Main menu with login/register UI wired to the backend auth API. +## +## The AuthPanel is hidden by default. When the user clicks "Login" or "Register" +## (the existing Play/Quit buttons), 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, the welcome label +## shows the username, and the Play button becomes enabled. -func _ready(): - pass +# ── UI references ──────────────────────────────────────────────────────────── + +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 -func _on_play_button_pressed(): - # Navigate to the world list / browse screen. +# ── Lifecycle ──────────────────────────────────────────────────────────────── + +func _ready() -> void: + # Cache node references. + _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_browse_button_pressed(): - # Same as Play — browse worlds is the primary action. - get_tree().change_scene_to_file("res://scenes/world_list.tscn") - - -func _on_quit_button_pressed(): +func _on_quit_button_pressed() -> void: get_tree().quit() + + +# ── Auth panel toggle ──────────────────────────────────────────────────────── + +func _on_login_button_pressed() -> void: + """Show the auth panel (called from a future Login button on the main menu).""" + _auth_panel.visible = true + _auth_panel.grab_focus() + + +func _on_register_button_pressed() -> void: + """Show the auth panel in register mode.""" + _auth_panel.visible = true + _is_register_mode = true + _mode_toggle.button_pressed = true + _update_auth_mode_ui() + _clear_status() + + +# ── 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. + _welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges() + _welcome_label.visible = true + _status_label.visible = false + _play_button.disabled = 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 diff --git a/scripts/smoke_test_world_list.gd b/scripts/smoke_test_world_list.gd new file mode 100644 index 0000000..951d6f5 --- /dev/null +++ b/scripts/smoke_test_world_list.gd @@ -0,0 +1,122 @@ +extends SceneTree +## Headless smoke test for the world list screen. +## +## Run with: +## godot --headless --path . --script res://scripts/smoke_test_world_list.gd +## +## Requires the backend to be running (default base URL +## `application/agp/api_base_url` -> http://127.0.0.1:8000, override with +## AGP_API_BASE_URL). +## +## Unlike a pure API-level test, this actually instantiates scenes/ +## world_list.tscn as a real scene and adds it to the tree -- catches +## @onready node-path bugs (a node reference pointing at the wrong path +## errors right here, not just "looks right" from reading the script) that +## an API-only test would miss entirely. +## +## Tests: +## 1. Register + log in a fresh user (so get_worlds() has a valid token). +## 2. Instantiate world_list.tscn, add to tree -- @onready resolution +## must not error. +## 3. Wait for _refresh_worlds()'s awaited request to complete. +## 4. Confirm the screen reached a real end state (world cards, or the +## empty-list message -- either is fine, a crash/stuck-loading isn't). +## +## Exits 0 on full pass, 1 on any failure. + +const WORLD_LIST_SCENE := "res://scenes/world_list.tscn" + + +func _initialize() -> void: + _run() + + +func _run() -> void: + await process_frame + + var api := root.get_node("ApiClient") + assert(api != null, "ApiClient autoload not found") + + var passed: int = 0 + var failed: int = 0 + + # --- Setup: register + log in so the world list has a valid token --- + print("\n=== Setup: register + login ===") + randomize() + var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000 + var username := "worldlist_smoke_%d" % unique_id + var email := "worldlist_smoke_%d@test.example.com" % unique_id + var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", "2000-01-01") + if not _check("setup: register succeeded", reg_result.ok): + print(" detail: ", reg_result.detail) + quit(1) + return + + # --- Test 1: Instantiate the real scene --- + print("\n=== Test 1: Instantiate world_list.tscn ===") + var packed_scene: PackedScene = load(WORLD_LIST_SCENE) + if not _check("scene loads", packed_scene != null): + quit(1) + return + + var instance: Control = packed_scene.instantiate() + if not _check("scene instantiates", instance != null): + quit(1) + return + + root.add_child(instance) + # One frame for _ready() (and its @onready resolution) to run. A wrong + # node path in an @onready var errors during this, not silently. + await process_frame + + if _check("scene added to tree without error", is_instance_valid(instance)): + passed += 1 + else: + failed += 1 + + # --- Test 2: Wait for the world fetch to resolve, check a real end state --- + print("\n=== Test 2: World fetch reaches a real end state ===") + # _refresh_worlds() runs from _ready(); give its awaited HTTP call time + # to complete rather than a fixed sleep -- poll for either end state. + var world_list_node: VBoxContainer = instance.get_node("VBoxContainer/ScrollContainer/WorldList") + var empty_label: Label = instance.get_node("VBoxContainer/EmptyLabel") + var error_label: Label = instance.get_node("VBoxContainer/ErrorLabel") + + var settled := false + for i in range(50): # up to ~5s at 100ms/frame-ish via process_frame waits + await process_frame + if world_list_node.visible or empty_label.visible or error_label.visible: + settled = true + break + + if _check("reached a real end state (cards, empty, or error) not stuck loading", settled): + passed += 1 + else: + failed += 1 + print(" world_list.visible=%s empty_label.visible=%s error_label.visible=%s" % [ + world_list_node.visible, empty_label.visible, error_label.visible + ]) + + if error_label.visible: + printerr(" Note: ended in error state: ", error_label.text) + elif world_list_node.visible: + print(" Note: rendered %d world card(s)" % world_list_node.get_child_count()) + elif empty_label.visible: + print(" Note: ended in empty-list state") + + # --- Summary --- + print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed]) + if failed > 0: + quit(1) + else: + print("SMOKE RESULT: ALL PASS") + quit(0) + + +func _check(description: String, condition: bool) -> bool: + if condition: + print(" PASS: ", description) + return true + else: + printerr(" FAIL: ", description) + return false diff --git a/scripts/world_list.gd b/scripts/world_list.gd index cc8a035..9f7bee6 100644 --- a/scripts/world_list.gd +++ b/scripts/world_list.gd @@ -10,8 +10,8 @@ extends Control @onready var world_list: VBoxContainer = $VBoxContainer/ScrollContainer/WorldList @onready var loading_label: Label = $VBoxContainer/LoadingLabel @onready var error_label: Label = $VBoxContainer/ErrorLabel -@onready var refresh_button: Button = $VBoxContainer/RefreshButton -@onready var back_button: Button = $VBoxContainer/BackButton +@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton +@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton @onready var empty_label: Label = $VBoxContainer/EmptyLabel @@ -91,7 +91,7 @@ func _create_world_card(world_dict: Dictionary) -> Control: var card := VBoxContainer.new() card.size_flags_horizontal = Control.SIZE_FILL card.custom_minimum_size = Vector2(0, 80) - card.theme_override_constants/separation = 4 + card.add_theme_constant_override("separation", 4) var name_label := Label.new() name_label.text = world_dict.get("name", "Unnamed World") @@ -101,7 +101,7 @@ func _create_world_card(world_dict: Dictionary) -> Control: name_label.add_theme_font_size_override("font_size", 18) var desc_label := Label.new() - var description := world_dict.get("description", "") + var description: String = str(world_dict.get("description", "")) desc_label.text = description if description != "" else "No description." desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD desc_label.size_flags_horizontal = Control.SIZE_FILL @@ -110,8 +110,8 @@ func _create_world_card(world_dict: Dictionary) -> Control: desc_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1.0)) var meta_label := Label.new() - var visibility := world_dict.get("visibility", "public") - var created := world_dict.get("created_at", "") + var visibility: String = str(world_dict.get("visibility", "public")) + var created: String = str(world_dict.get("created_at", "")) meta_label.text = "Visibility: %s%s" % [visibility, " | Created: " + created if created != "" else ""] meta_label.autowrap_mode = TextServer.AUTOWRAP_WORD meta_label.size_flags_horizontal = Control.SIZE_FILL