Address review feedback: Implement Godot dedicated server scene + player spawning #51
@@ -7,14 +7,14 @@ to know what "run the tests" actually checked.
|
|||||||
|
|
||||||
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
||||||
|
|
||||||
Last verified against `main`: 2026-08-20 — full PC1 checkpoint run,
|
Last verified against `main`: 2026-08-21 — migrations + pytest + all 10
|
||||||
migrations + pytest + all 7 Godot smoke tests + both platform exports, all
|
Godot smoke tests, all clean. PC1's real playtest-found bugs (login form
|
||||||
clean. Real bugs found and fixed via real playtesting this same day (not
|
unreachable, 1px world cards, Play → Back losing the session) are covered
|
||||||
caught by the checkpoint suite as it stood before today): the main menu
|
by the smoke tests below so they can't silently regress. PC2 (basic
|
||||||
had no way to actually open the login/register form, the world list's
|
playable world + character + movement) landed 2026-08-20/21 — see
|
||||||
cards rendered at ~1px wide once real data existed, and navigating Play →
|
`docs/checkpoints/` once a PC2 record exists, and the three new smoke
|
||||||
Back logged the user out despite a still-valid session. All three are now
|
tests below (`smoke_test_test_level.gd`, `smoke_test_player.gd`,
|
||||||
covered by the smoke tests below so they can't silently regress.
|
`smoke_test_enter_world.gd`) for what's actually verified.
|
||||||
|
|
||||||
## Backend — `backend/tests/` (pytest)
|
## Backend — `backend/tests/` (pytest)
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ these yet):
|
|||||||
|
|
||||||
## Godot client — `scripts/smoke_test_*.gd`
|
## Godot client — `scripts/smoke_test_*.gd`
|
||||||
|
|
||||||
**7 smoke tests merged into `main`.** Each needs a live backend —
|
**10 smoke tests merged into `main`.** Each needs a live backend —
|
||||||
`AGP_API_BASE_URL` env var, falling back to the
|
`AGP_API_BASE_URL` env var, falling back to the
|
||||||
`application/agp/api_base_url` project setting (default
|
`application/agp/api_base_url` project setting (default
|
||||||
`http://127.0.0.1:8000`) if unset.
|
`http://127.0.0.1:8000`) if unset.
|
||||||
@@ -67,6 +67,9 @@ these yet):
|
|||||||
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. |
|
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. |
|
||||||
| `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. |
|
| `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. |
|
||||||
| `smoke_test_main_menu_back.gd` | Regression test for Play → Back logging the user out even with a valid token still held. Registers a user, loads `main_menu.tscn` (asserts it restores the authenticated state via a real `auth_state_ready` signal wait, not a frame-count guess), clicks Play → `world_list.tscn`, clicks Back → `main_menu.tscn`, and asserts the menu is still authenticated. Checks `WelcomeLabel.is_visible_in_tree()`, not just `.visible` — the label lives inside AuthPanel, so `.visible` alone is a false positive if the panel itself is hidden (a real bug briefly present: `_show_authenticated()` hid AuthPanel while claiming the welcome label was visible, which rendered nothing). |
|
| `smoke_test_main_menu_back.gd` | Regression test for Play → Back logging the user out even with a valid token still held. Registers a user, loads `main_menu.tscn` (asserts it restores the authenticated state via a real `auth_state_ready` signal wait, not a frame-count guess), clicks Play → `world_list.tscn`, clicks Back → `main_menu.tscn`, and asserts the menu is still authenticated. Checks `WelcomeLabel.is_visible_in_tree()`, not just `.visible` — the label lives inside AuthPanel, so `.visible` alone is a false positive if the panel itself is hidden (a real bug briefly present: `_show_authenticated()` hid AuthPanel while claiming the welcome label was visible, which rendered nothing). |
|
||||||
|
| `smoke_test_test_level.gd` | Loads `scenes/worlds/test_level.tscn` directly (the level is player-agnostic and self-contained per `docs/architecture/pc2_test_level.md` §4) and confirms it has real content: 5 props, a `DirectionalLight3D` ("Sun"), and a `WorldEnvironment` — not an empty void. |
|
||||||
|
| `smoke_test_player.gd` | Loads `scenes/game.tscn` and confirms the player exists as a real `CharacterBody3D` with a `CollisionShape3D`+shape, a `CapsuleMesh`, and spawns at the level's `PlayerSpawn` marker position. Also asserts `JUMP_VELOCITY` is positive and a `Camera3D` exists and is `current` — both were real bugs found 2026-08-20/21 (jump velocity was negative, pushing the player down instead of up; no camera existed at all, so the game was unplayable even though the character could technically move). Structural checks only — real movement/jump *direction* correctness was verified separately via a throwaway headless physics simulation during development, not by an automated test that's part of this suite. |
|
||||||
|
| `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. |
|
||||||
|
|
||||||
Run (once, to build the class cache): `godot --headless --path . --import`,
|
Run (once, to build the class cache): `godot --headless --path . --import`,
|
||||||
then for each smoke test: `godot --headless --path . --script
|
then for each smoke test: `godot --headless --path . --script
|
||||||
|
|||||||
+3
-5
@@ -79,13 +79,11 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
_update_input_direction(key)
|
_update_input_direction(key)
|
||||||
elif not key.pressed:
|
elif not key.pressed:
|
||||||
_clear_input_direction(key)
|
_clear_input_direction(key)
|
||||||
# Release the mouse so the player can get back to a menu/cursor.
|
# Escape exits the level back to the world browse list, mirroring
|
||||||
|
# the Back button pattern already used in world_list.gd/main_menu.gd.
|
||||||
if key.pressed and key.keycode == KEY_ESCAPE:
|
if key.pressed and key.keycode == KEY_ESCAPE:
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
elif event is InputEventMouseButton and event.pressed:
|
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
|
||||||
# Clicking back into the viewport re-captures the mouse.
|
|
||||||
if Input.mouse_mode == Input.MOUSE_MODE_VISIBLE and DisplayServer.get_name() != "headless":
|
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
|
||||||
elif event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
elif event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||||
_handle_mouse_look(event as InputEventMouseMotion)
|
_handle_mouse_look(event as InputEventMouseMotion)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Regression/smoke test for the PC2 "Enter World" flow end to end.
|
||||||
|
##
|
||||||
|
## Real user path: register -> land on world_list.tscn -> click "Enter World"
|
||||||
|
## on a real card -> the playable level (scenes/game.tscn) actually loads
|
||||||
|
## with the player spawned in it -> pressing Escape returns to world_list.
|
||||||
|
##
|
||||||
|
## Run with:
|
||||||
|
## godot --headless --path . --script res://scripts/smoke_test_enter_world.gd
|
||||||
|
##
|
||||||
|
## Requires the backend to be running (default base URL
|
||||||
|
## `application/agp/api_base_url` -> http://127.0.0.1:8000, overridable via
|
||||||
|
## AGP_API_BASE_URL) and at least one real world to already exist.
|
||||||
|
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var api := root.get_node("ApiClient")
|
||||||
|
if api == null:
|
||||||
|
printerr("FAIL: ApiClient autoload not found")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Register a real user and seed a world so the browse list has a
|
||||||
|
# real card to click, same pattern as smoke_test_world_list.gd.
|
||||||
|
randomize()
|
||||||
|
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||||
|
var username := "enterworld_%d" % unique_id
|
||||||
|
var email := "enterworld_%d@test.example.com" % unique_id
|
||||||
|
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", true)
|
||||||
|
if not _check("setup: register succeeded", reg_result.ok):
|
||||||
|
print(" detail: ", reg_result.detail)
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
var world_body := JSON.stringify({
|
||||||
|
"name": "EnterWorld Smoke Test World %d" % unique_id,
|
||||||
|
"description": "Seeded by smoke_test_enter_world.gd",
|
||||||
|
"visibility": "public",
|
||||||
|
})
|
||||||
|
var world_result: Dictionary = await api.request(
|
||||||
|
HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true
|
||||||
|
)
|
||||||
|
if not _check("setup: world created", world_result.ok):
|
||||||
|
print(" detail: ", world_result.detail)
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Load world_list.tscn as a real scene and let it fetch worlds.
|
||||||
|
var packed: PackedScene = load("res://scenes/world_list.tscn")
|
||||||
|
var instance: Control = packed.instantiate()
|
||||||
|
root.add_child(instance)
|
||||||
|
# _ready() kicks off an async _refresh_worlds() -- wait real frames for
|
||||||
|
# the HTTP round trip rather than guessing a fixed count.
|
||||||
|
var world_list_node: VBoxContainer = instance.get_node("VBoxContainer/ScrollContainer/WorldList")
|
||||||
|
var settled := false
|
||||||
|
for i in 120:
|
||||||
|
await process_frame
|
||||||
|
if world_list_node.get_child_count() > 0:
|
||||||
|
settled = true
|
||||||
|
break
|
||||||
|
if not _check("world list populated with at least one card", settled):
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. Click "Enter World" on the first real card -- find the button by
|
||||||
|
# walking the card's children rather than assuming an index, so this
|
||||||
|
# doesn't silently pass if _create_world_card's structure changes.
|
||||||
|
var card: Control = world_list_node.get_child(0)
|
||||||
|
var enter_button: Button = _find_button(card, "Enter World")
|
||||||
|
if not _check("Enter World button exists on the card", enter_button != null):
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
enter_button.pressed.emit()
|
||||||
|
await _wait_for_scene_change(instance, 60)
|
||||||
|
|
||||||
|
var game_scene := current_scene
|
||||||
|
if not _check("game.tscn became current_scene after Enter World", game_scene != null and game_scene != instance):
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 4. Verify the player actually exists and spawned at the level's
|
||||||
|
# PlayerSpawn marker -- not just that *some* scene loaded.
|
||||||
|
var player: Node = game_scene.get_node_or_null("Player")
|
||||||
|
_check("Player node exists in the loaded game scene", player is CharacterBody3D)
|
||||||
|
|
||||||
|
var spawn_marker: Node = game_scene.get_node_or_null("Level/PlayerSpawn")
|
||||||
|
_check("PlayerSpawn marker exists in the loaded level", spawn_marker is Marker3D)
|
||||||
|
|
||||||
|
if player is CharacterBody3D and spawn_marker is Marker3D:
|
||||||
|
var dist := (player as CharacterBody3D).global_position.distance_to((spawn_marker as Marker3D).global_position)
|
||||||
|
_check("player spawned at (or very near) the PlayerSpawn marker", dist < 0.5)
|
||||||
|
|
||||||
|
# 5. Escape should return to world_list.tscn (real key event, not a
|
||||||
|
# direct method call -- proves the actual input binding works).
|
||||||
|
var esc_event := InputEventKey.new()
|
||||||
|
esc_event.keycode = KEY_ESCAPE
|
||||||
|
esc_event.pressed = true
|
||||||
|
Input.parse_input_event(esc_event)
|
||||||
|
await _wait_for_scene_change(game_scene, 60)
|
||||||
|
|
||||||
|
var back_to_menu := current_scene
|
||||||
|
_check("Escape returned to world_list.tscn", back_to_menu != null and back_to_menu.scene_file_path == "res://scenes/world_list.tscn")
|
||||||
|
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
|
func _find_button(node: Node, text: String) -> Button:
|
||||||
|
if node is Button and (node as Button).text == text:
|
||||||
|
return node
|
||||||
|
for child in node.get_children():
|
||||||
|
var found := _find_button(child, text)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func _wait_for_scene_change(previous_scene: Node, max_frames: int) -> void:
|
||||||
|
for i in max_frames:
|
||||||
|
await process_frame
|
||||||
|
if current_scene != previous_scene and current_scene != null:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
func _check(description: String, condition: bool) -> bool:
|
||||||
|
if condition:
|
||||||
|
print(" PASS: ", description)
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
printerr(" FAIL: ", description)
|
||||||
|
failed += 1
|
||||||
|
return condition
|
||||||
|
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||||
|
quit(0 if failed == 0 else 1)
|
||||||
@@ -135,8 +135,23 @@ func _create_world_card(world_dict: Dictionary) -> Control:
|
|||||||
meta_label.add_theme_font_size_override("font_size", 12)
|
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))
|
meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0))
|
||||||
|
|
||||||
|
# PC2: the only thing a world actually loads into right now is the one
|
||||||
|
# shared playable test level (scenes/game.tscn) -- there's no per-world
|
||||||
|
# content yet, so "Enter" doesn't need the world's id for anything.
|
||||||
|
# Real per-world level loading is a later concern once worlds actually
|
||||||
|
# have their own content to load.
|
||||||
|
var enter_button := Button.new()
|
||||||
|
enter_button.text = "Enter World"
|
||||||
|
enter_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||||
|
enter_button.pressed.connect(_on_enter_world_pressed)
|
||||||
|
|
||||||
card.add_child(name_label)
|
card.add_child(name_label)
|
||||||
card.add_child(desc_label)
|
card.add_child(desc_label)
|
||||||
card.add_child(meta_label)
|
card.add_child(meta_label)
|
||||||
|
card.add_child(enter_button)
|
||||||
|
|
||||||
return panel
|
return panel
|
||||||
|
|
||||||
|
|
||||||
|
func _on_enter_world_pressed() -> void:
|
||||||
|
get_tree().change_scene_to_file("res://scenes/game.tscn")
|
||||||
|
|||||||
Reference in New Issue
Block a user