From b60480798f2f6312dafe1299de9db266a9381d08 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Fri, 21 Aug 2026 00:43:45 +0000 Subject: [PATCH 1/2] WIP checkpoint: PC2: Wire "Enter World" from the browse list to the playable level --- scripts/player.gd | 8 +++----- scripts/world_list.gd | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/scripts/player.gd b/scripts/player.gd index ff37c6e..402767a 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -79,13 +79,11 @@ func _unhandled_input(event: InputEvent) -> void: _update_input_direction(key) elif not key.pressed: _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: Input.mouse_mode = Input.MOUSE_MODE_VISIBLE - elif event is InputEventMouseButton and event.pressed: - # 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 + get_tree().change_scene_to_file("res://scenes/world_list.tscn") elif event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: _handle_mouse_look(event as InputEventMouseMotion) diff --git a/scripts/world_list.gd b/scripts/world_list.gd index 44f2cb1..7e7b5b0 100644 --- a/scripts/world_list.gd +++ b/scripts/world_list.gd @@ -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_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(desc_label) card.add_child(meta_label) + card.add_child(enter_button) return panel + + +func _on_enter_world_pressed() -> void: + get_tree().change_scene_to_file("res://scenes/game.tscn") From 92c10fa6573e4f0cc3da9b8dc7300ed738a7cf4f Mon Sep 17 00:00:00 2001 From: claude-checkpoint-tooling Date: Fri, 21 Aug 2026 00:53:55 +0000 Subject: [PATCH 2/2] Add smoke_test_enter_world.gd + doc update that never made it into PR #50 Both were verified (9/9 passing) before PR #50 merged but got lost in the handoff between my push and the concurrent live agent session reusing the same workspace -- PR #50's actual merged diff only had the world_list.gd/ player.gd code changes, missing the new test file and the doc update. Caught while auditing everything feeding PC2 for real completeness. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN --- docs/testing/test_inventory.md | 21 +++-- scripts/smoke_test_enter_world.gd | 146 ++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 scripts/smoke_test_enter_world.gd diff --git a/docs/testing/test_inventory.md b/docs/testing/test_inventory.md index 6e89420..f4f7f68 100644 --- a/docs/testing/test_inventory.md +++ b/docs/testing/test_inventory.md @@ -7,14 +7,14 @@ to know what "run the tests" actually checked. Run everything below in one shot with `scripts/run_checkpoint_tests.py`. -Last verified against `main`: 2026-08-20 — full PC1 checkpoint run, -migrations + pytest + all 7 Godot smoke tests + both platform exports, all -clean. Real bugs found and fixed via real playtesting this same day (not -caught by the checkpoint suite as it stood before today): the main menu -had no way to actually open the login/register form, the world list's -cards rendered at ~1px wide once real data existed, and navigating Play → -Back logged the user out despite a still-valid session. All three are now -covered by the smoke tests below so they can't silently regress. +Last verified against `main`: 2026-08-21 — migrations + pytest + all 10 +Godot smoke tests, all clean. PC1's real playtest-found bugs (login form +unreachable, 1px world cards, Play → Back losing the session) are covered +by the smoke tests below so they can't silently regress. PC2 (basic +playable world + character + movement) landed 2026-08-20/21 — see +`docs/checkpoints/` once a PC2 record exists, and the three new smoke +tests below (`smoke_test_test_level.gd`, `smoke_test_player.gd`, +`smoke_test_enter_world.gd`) for what's actually verified. ## Backend — `backend/tests/` (pytest) @@ -53,7 +53,7 @@ these yet): ## 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 `application/agp/api_base_url` project setting (default `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_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_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`, then for each smoke test: `godot --headless --path . --script diff --git a/scripts/smoke_test_enter_world.gd b/scripts/smoke_test_enter_world.gd new file mode 100644 index 0000000..d0377d8 --- /dev/null +++ b/scripts/smoke_test_enter_world.gd @@ -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)