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/world_list.tscn b/scenes/world_list.tscn new file mode 100644 index 0000000..e2138e4 --- /dev/null +++ b/scenes/world_list.tscn @@ -0,0 +1,76 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/world_list.gd" id="1_wl"] + +[node name="WorldList" type="Control"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_wl") + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +layout_mode = 3 +theme_override_constants/separation = 8 +size_flags_horizontal = 4 +size_flags_vertical = 4 + +[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"] +layout_mode = 3 +size_flags_horizontal = 4 +theme_override_constants/separation = 16 + +[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"] +layout_mode = 3 +text = "< Back" +size_flags_horizontal = 0 + +[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"] +layout_mode = 3 +text = "Browse Worlds" +horizontal_alignment = 1 +size_flags_horizontal = 4 +theme_override_font_sizes/font_size = 28 + +[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"] +layout_mode = 3 +text = "Refresh" +size_flags_horizontal = 0 + +[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"] +layout_mode = 3 +size_flags_horizontal = 4 +size_flags_vertical = 4 +horizontal_mode = 2 +vertical_mode = 2 + +[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"] +layout_mode = 3 +size_flags_horizontal = 4 +size_flags_vertical = 4 +theme_override_constants/separation = 8 + +[node name="LoadingLabel" type="Label" parent="VBoxContainer"] +layout_mode = 3 +text = "Loading worlds..." +visible = false +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 18 + +[node name="ErrorLabel" type="Label" parent="VBoxContainer"] +layout_mode = 3 +text = "" +visible = false +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 18 + +[node name="EmptyLabel" type="Label" parent="VBoxContainer"] +layout_mode = 3 +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 08a0487..1f5d8c3 100644 --- a/scripts/main_menu.gd +++ b/scripts/main_menu.gd @@ -61,8 +61,9 @@ func _ready() -> void: func _on_play_button_pressed() -> void: if _play_button.disabled: return - # TODO: transition to the worlds / gameplay scene once it exists. - print("Play pressed — transitioning to game") + # 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: 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 new file mode 100644 index 0000000..9f7bee6 --- /dev/null +++ b/scripts/world_list.gd @@ -0,0 +1,135 @@ +extends Control +## World list screen — browses public worlds via GET /api/worlds. +## +## Displays a scrollable list of world cards (name + description). +## Handles loading, empty list, errors, and refresh. + + +# UI references +@onready var scroll_container: ScrollContainer = $VBoxContainer/ScrollContainer +@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/HeaderHBox/RefreshButton +@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton +@onready var empty_label: Label = $VBoxContainer/EmptyLabel + + +func _ready() -> void: + refresh_button.disabled = false + back_button.disabled = false + loading_label.visible = false + error_label.visible = false + empty_label.visible = false + world_list.visible = false + _refresh_worlds() + + +func _on_refresh_button_pressed() -> void: + _refresh_worlds() + + +func _on_back_button_pressed() -> void: + get_tree().change_scene_to_file("res://scenes/main_menu.tscn") + + +## Fetch the world list from the backend and update the UI. +func _refresh_worlds() -> void: + # Reset UI state + loading_label.visible = true + error_label.visible = false + empty_label.visible = false + world_list.visible = false + refresh_button.disabled = true + + # Clear previous world cards + for child in world_list.get_children(): + child.queue_free() + + var result := await ApiClient.get_worlds() + + loading_label.visible = false + refresh_button.disabled = false + + if not result.ok: + # Show error message (401, network failure, etc.) + error_label.text = "Error: %s" % result.detail + error_label.visible = true + empty_label.visible = false + world_list.visible = false + return + + var data = result.data + if not data is Array: + error_label.text = "Unexpected response format." + error_label.visible = true + empty_label.visible = false + world_list.visible = false + return + + if data.is_empty(): + empty_label.text = "No worlds yet. Check back later!" + empty_label.visible = true + error_label.visible = false + world_list.visible = false + return + + # Render world cards + world_list.visible = true + empty_label.visible = false + error_label.visible = false + + for world_dict in data: + if not world_dict is Dictionary: + continue + var card := _create_world_card(world_dict) + world_list.add_child(card) + + +## Create a visual card for a single world entry. +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.add_theme_constant_override("separation", 4) + + var name_label := Label.new() + name_label.text = world_dict.get("name", "Unnamed World") + name_label.autowrap_mode = TextServer.AUTOWRAP_WORD + name_label.size_flags_horizontal = Control.SIZE_FILL + name_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN + name_label.add_theme_font_size_override("font_size", 18) + + var desc_label := Label.new() + 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 + desc_label.size_flags_vertical = Control.SIZE_EXPAND_FILL + desc_label.add_theme_font_size_override("font_size", 14) + desc_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1.0)) + + var meta_label := Label.new() + 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 + meta_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN + meta_label.add_theme_font_size_override("font_size", 12) + meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0)) + + card.add_child(name_label) + card.add_child(desc_label) + card.add_child(meta_label) + + # Subtle background for the card + var bg := ColorRect.new() + bg.color = Color(0.15, 0.15, 0.15, 1.0) + bg.size_flags_horizontal = Control.SIZE_FILL + bg.size_flags_vertical = Control.SIZE_FILL + bg.custom_minimum_size = Vector2(0, 80) + card.add_child(bg) + card.move_child(bg, 0) + + return card