Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ffcd9cd3f | |||
| aeccc78143 | |||
| 1c64dd4967 | |||
| 4dd6c7c8af | |||
| e90b394f03 | |||
| 33b0c3f1fb | |||
| 8f1a1110e2 |
@@ -0,0 +1,194 @@
|
|||||||
|
# PC2 Test Level & Player Spawn — Design Note
|
||||||
|
|
||||||
|
> **Status:** Decided (PC2)
|
||||||
|
> **Owner:** Architecture
|
||||||
|
> **Consumers:** the two PC2 implementation tasks — (1) build the test level,
|
||||||
|
> (2) build the player & spawn it. Both must be able to follow this note
|
||||||
|
> without further design decisions.
|
||||||
|
|
||||||
|
## 1. Decision: static scene, not chunk streaming
|
||||||
|
|
||||||
|
**The PC2 playable test level is a single, hand-authored, static Godot 3D
|
||||||
|
scene.** It is **not** built through the chunk streaming system
|
||||||
|
(`scripts/chunk_stream_manager.gd`, `chunk_scene.gd`, `chunk.gd`,
|
||||||
|
`chunk_data_decoder.gd`).
|
||||||
|
|
||||||
|
This is a deliberate decision, not a default:
|
||||||
|
|
||||||
|
- **The streaming system has never been exercised with real content.** It is a
|
||||||
|
faithful port of the `docs/architecture/world.md` format spec, but nothing in
|
||||||
|
the project produces or serves chunk payloads for it to consume. `scenes/main.tscn`
|
||||||
|
wires `chunk_stream_manager.gd` onto a bare `Node3D` with no `player` reference
|
||||||
|
and no `_server_rpc`, so at runtime it does nothing. There is no server-side
|
||||||
|
chunk authoring/streaming endpoint to integrate against.
|
||||||
|
- **PC2 scope is single-player, local-only, no networking.** The value of chunk
|
||||||
|
streaming is server-authoritative streaming around a moving player across a
|
||||||
|
network boundary. None of those preconditions exist in PC2, so exercising the
|
||||||
|
pipeline here would mean building a chunk *authoring + serving* path first —
|
||||||
|
clearly out of checkpoint scope.
|
||||||
|
- **A static scene is the lowest-risk way to get "spawn a player in a real 3D
|
||||||
|
level and walk around" working**, which is the actual PC2 outcome being tested.
|
||||||
|
|
||||||
|
Chunk streaming is **not removed and not broken by this decision** — it stays as
|
||||||
|
an optional, later milestone. The static level is the PC2 proof of the *player*
|
||||||
|
and *camera/physics* half of the problem; chunk streaming remains the later proof
|
||||||
|
of the *world data* half.
|
||||||
|
|
||||||
|
## 2. Where things live
|
||||||
|
|
||||||
|
| Artifact | Path | Owned by |
|
||||||
|
|---|---|---|
|
||||||
|
| Test level scene | `scenes/worlds/test_level.tscn` | level task |
|
||||||
|
| Player scene | `scenes/player.tscn` | player task |
|
||||||
|
| Player controller script | `scripts/player.gd` | player task |
|
||||||
|
| Game composition scene | `scenes/game.tscn` | player task |
|
||||||
|
|
||||||
|
- **`scenes/worlds/`** is the convention for all level scenes (it does not exist
|
||||||
|
yet; the level task creates it). Future levels follow `scenes/worlds/<name>.tscn`.
|
||||||
|
- `scenes/player.tscn` is **reusable** across levels — it must not hard-code any
|
||||||
|
reference to `test_level`; it discovers the spawn point at runtime (see §3).
|
||||||
|
|
||||||
|
## 3. Player spawn convention
|
||||||
|
|
||||||
|
The level scene **must** contain a `Marker3D` named exactly **`PlayerSpawn`**.
|
||||||
|
That node's world transform is the spawn point.
|
||||||
|
|
||||||
|
- `Marker3D` is a plain `Node3D` that shows a cross gizmo in the editor and
|
||||||
|
carries no gameplay behavior — it is the established Godot idiom for "position
|
||||||
|
hint." (Confirmed present in Godot 4.6.2.)
|
||||||
|
- The marker's **position** is authoritative; its rotation is ignored by the
|
||||||
|
player (spawn yaw is the player's concern, default forward is fine).
|
||||||
|
- Coordinate system: **Y-up, units = meters, 1 unit = 1 m.** The marker must sit
|
||||||
|
at least **1.0 m above the ground's top surface** (recommended `y = 1.0` where
|
||||||
|
the ground top is `y = 0`) so the player capsule does not spawn intersecting
|
||||||
|
the floor and get popped out by the physics solver.
|
||||||
|
- There is exactly **one** `PlayerSpawn` per level. (If a level ever needs
|
||||||
|
multiple spawns, that is a later convention — not PC2.)
|
||||||
|
|
||||||
|
**Lookup rule (implemented by `player.gd`, not by the level):**
|
||||||
|
|
||||||
|
```
|
||||||
|
# in player.gd _ready()
|
||||||
|
var spawn := get_tree().current_scene.find_child("PlayerSpawn", true, false)
|
||||||
|
if spawn is Node3D:
|
||||||
|
global_position = spawn.global_position
|
||||||
|
else:
|
||||||
|
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
||||||
|
global_position = Vector3(0, 1.0, 0) # fallback, do not crash
|
||||||
|
```
|
||||||
|
|
||||||
|
The lookup runs from the current scene root so it finds the marker whether it is
|
||||||
|
a direct child of the level instance or nested under a `Level` wrapper. The
|
||||||
|
fallback keeps the player runnable in a scene that forgot its marker, but a
|
||||||
|
**missing `PlayerSpawn` is a level bug** and the level task's smoke test must fail
|
||||||
|
on it (see §6).
|
||||||
|
|
||||||
|
## 4. Composition (who puts level + player together)
|
||||||
|
|
||||||
|
`scenes/game.tscn` is the PC2 playable entry point:
|
||||||
|
|
||||||
|
```
|
||||||
|
Game (Node3D)
|
||||||
|
├── Level (instance of res://scenes/worlds/test_level.tscn)
|
||||||
|
└── Player (instance of res://scenes/player.tscn)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Game` is a plain `Node3D` with **no script** — composition is done by scene
|
||||||
|
instantiation, not code, so there is nothing to keep in sync.
|
||||||
|
- The **level scene is self-contained and has zero dependency on the player** —
|
||||||
|
it loads and is smoke-testable on its own. The level task does not instance or
|
||||||
|
reference `player.tscn`.
|
||||||
|
- The **player scene is self-contained and has zero dependency on any specific
|
||||||
|
level** — it only requires that the *current scene* contains a `PlayerSpawn`.
|
||||||
|
- Ownership of `game.tscn` goes to the **player task**, which depends on the
|
||||||
|
level task having landed `test_level.tscn` first (chain with `depends_on`).
|
||||||
|
|
||||||
|
## 5. Level content requirements (level task)
|
||||||
|
|
||||||
|
`scenes/worlds/test_level.tscn`, root node `TestLevel` (`Node3D`). Required
|
||||||
|
children:
|
||||||
|
|
||||||
|
| Node | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Ground` | `StaticBody3D` | `BoxShape3D` (e.g. `40 × 1 × 40`) centered at `(0, -0.5, 0)` so the **top face is `y = 0`**; matching `MeshInstance3D` (`BoxMesh`) so it is visible and collidable. Use a `StaticBody3D` rather than a bare `PlaneMesh` so collision is explicit and reliable. |
|
||||||
|
| `Props` | `Node3D` | 3–5 simple props as `StaticBody3D` + `MeshInstance3D` using built-in primitives only (`BoxMesh`, `CylinderMesh`, `SphereMesh`). `assets/` is empty and PC2 must not require imported content. Positioned on the ground surface; scale freely. |
|
||||||
|
| `Sun` | `DirectionalLight3D` | Pitch it downward (~ -45°), enable shadows if the compatibility renderer allows; shadows are optional. |
|
||||||
|
| `WorldEnvironment` | `WorldEnvironment` | Ambient light and/or a simple background color. Keep it plain — the project uses `gl_compatibility`. |
|
||||||
|
| `PlayerSpawn` | `Marker3D` | Per §3. Recommended `(0, 1.0, 0)`. |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- **Do not** add any `script` to the level. It is pure content. Any per-level
|
||||||
|
behavior belongs to the player (or a future game-mode script), not the level.
|
||||||
|
- Keep the whole level on the XZ plane around the origin; nothing off at extreme
|
||||||
|
coordinates, no thin walls the capsule can tunnel through.
|
||||||
|
|
||||||
|
## 6. Player contract (player task)
|
||||||
|
|
||||||
|
`scenes/player.tscn`, root `Player` (`CharacterBody3D`) + `scripts/player.gd`.
|
||||||
|
|
||||||
|
Minimum for PC2:
|
||||||
|
|
||||||
|
- **Body:** `CharacterBody3D` with a `CollisionShape3D` using `CapsuleShape3D`
|
||||||
|
(~`radius 0.3`, ~`height 1.8`) and a matching `CapsuleMesh` for visibility.
|
||||||
|
- **Camera:** a `Camera3D` child. First-person (camera at capsule head height) is
|
||||||
|
the lowest-risk choice; third-person is fine too but is more work.
|
||||||
|
- **Spawn:** per §3 — read `PlayerSpawn` from the current scene, fall back
|
||||||
|
gracefully.
|
||||||
|
- **Movement:** gravity + `move_and_slide()` so the player stands on `Ground`.
|
||||||
|
WASD/arrow locomotion is the hard requirement. Mouse-look is recommended but
|
||||||
|
may be split into its own task if the controller grows.
|
||||||
|
- **No networking, no avatar UI, no chunk code** — PC2 is local-only. Do not add
|
||||||
|
`MultiplayerAPI`, `ChunkStreamManager`, or any avatar customization here.
|
||||||
|
|
||||||
|
The player must work when instanced under `Game` alongside a level; it must not
|
||||||
|
assume it is the only node in the scene.
|
||||||
|
|
||||||
|
## 7. Entry point / how a human plays it
|
||||||
|
|
||||||
|
- `scenes/game.tscn` is the PC2 playable scene. For playtesting, load it directly
|
||||||
|
(e.g. `godot --path . scenes/game.tscn`), or temporarily set
|
||||||
|
`application/run/main_scene` to it.
|
||||||
|
- **Wiring "Play → Join a world" in the existing `world_list` UI is out of scope
|
||||||
|
for this note** and out of scope for the level/player tasks. The current menu
|
||||||
|
flow ends at browse-worlds (PC1). If a checkpoint wants a clickable path into
|
||||||
|
the test level, that navigation wiring is its own small task — do not fold it
|
||||||
|
into level or player.
|
||||||
|
|
||||||
|
## 8. Validation (both tasks must run this before "done")
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# one-time, first checkout only — builds the class/import cache
|
||||||
|
godot --headless --path . --import
|
||||||
|
|
||||||
|
# then, to catch parse/load errors:
|
||||||
|
godot --headless --path . --quit
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Level task:** additionally load `test_level.tscn` headlessly and assert it
|
||||||
|
(a) loads without error and (b) contains a `Marker3D` named `PlayerSpawn`
|
||||||
|
positioned ≥ 1.0 m above the ground top. A smoke script following the existing
|
||||||
|
`scripts/smoke_test_*.gd` pattern is the right vehicle.
|
||||||
|
- **Player task:** a smoke test that instantiates `game.tscn`, waits a frame,
|
||||||
|
and asserts the player's global position equals the `PlayerSpawn` position
|
||||||
|
(within a small epsilon). This is the check that proves the spawn convention
|
||||||
|
actually wired through — not just "the scene loads."
|
||||||
|
|
||||||
|
## 9. Explicitly out of scope for PC2
|
||||||
|
|
||||||
|
- Networking / multiplayer / prediction (single-player, local-only).
|
||||||
|
- Chunk streaming integration and server-side chunk authoring/serving.
|
||||||
|
- Avatar customization UI and glTF avatar loading.
|
||||||
|
- Join-a-world navigation from the world list.
|
||||||
|
- Any content moderation / age-gating logic in the level.
|
||||||
|
|
||||||
|
## 10. Summary
|
||||||
|
|
||||||
|
| Concern | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Level construction | Static hand-authored Godot 3D scene (chunk streaming deferred, not removed) |
|
||||||
|
| Level location | `scenes/worlds/test_level.tscn` |
|
||||||
|
| Spawn convention | `Marker3D` named `PlayerSpawn`, ≥ 1.0 m above ground, looked up by the player |
|
||||||
|
| Player | `scenes/player.tscn` (`CharacterBody3D`) + `scripts/player.gd`, reusable across levels |
|
||||||
|
| Composition | `scenes/game.tscn` instances `Level` + `Player`; no script |
|
||||||
|
| Entry point | Load `scenes/game.tscn` directly; UI join-wiring deferred |
|
||||||
@@ -8,11 +8,12 @@ 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-20 — full PC1 checkpoint run,
|
||||||
migrations + pytest + all 6 Godot smoke tests + both platform exports, all
|
migrations + pytest + all 7 Godot smoke tests + both platform exports, all
|
||||||
clean. Two real bugs found and fixed via real playtesting this same day
|
clean. Real bugs found and fixed via real playtesting this same day (not
|
||||||
(not caught by the checkpoint suite as it stood before today): the main
|
caught by the checkpoint suite as it stood before today): the main menu
|
||||||
menu had no way to actually open the login/register form, and the world
|
had no way to actually open the login/register form, the world list's
|
||||||
list's cards rendered at ~1px wide once real data existed. Both are now
|
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.
|
covered by the smoke tests below so they can't silently regress.
|
||||||
|
|
||||||
## Backend — `backend/tests/` (pytest)
|
## Backend — `backend/tests/` (pytest)
|
||||||
@@ -52,7 +53,7 @@ these yet):
|
|||||||
|
|
||||||
## Godot client — `scripts/smoke_test_*.gd`
|
## Godot client — `scripts/smoke_test_*.gd`
|
||||||
|
|
||||||
**6 smoke tests merged into `main`.** Each needs a live backend —
|
**7 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.
|
||||||
@@ -65,6 +66,7 @@ these yet):
|
|||||||
| `smoke_test_world_list.gd` | Registers a user, **seeds a real world via `POST /api/worlds/`** (without this, `run_checkpoint_tests.py`'s always-fresh DB means the populated-card render path never actually runs in CI, only the empty-list path would), instantiates `world_list.tscn` as a real scene, waits for `_refresh_worlds()` to complete, confirms it reached the populated (not empty) state, and asserts **each rendered card's real computed width is >= 200px**. That last check is the one that matters: 2026-08-20, a real bug shipped where every card existed in the tree with a non-zero child count and no crash, but rendered at 1.0px wide (`ScrollContainer`'s `size_flags_horizontal`/`vertical` used the raw integer `4` = `Control.SIZE_SHRINK_CENTER` instead of `3` = `SIZE_EXPAND_FILL`) — invisible on screen, and a child-count-only check would never have caught it. Confirmed by deliberately reintroducing the bug: card width check correctly fails at 25px. |
|
| `smoke_test_world_list.gd` | Registers a user, **seeds a real world via `POST /api/worlds/`** (without this, `run_checkpoint_tests.py`'s always-fresh DB means the populated-card render path never actually runs in CI, only the empty-list path would), instantiates `world_list.tscn` as a real scene, waits for `_refresh_worlds()` to complete, confirms it reached the populated (not empty) state, and asserts **each rendered card's real computed width is >= 200px**. That last check is the one that matters: 2026-08-20, a real bug shipped where every card existed in the tree with a non-zero child count and no crash, but rendered at 1.0px wide (`ScrollContainer`'s `size_flags_horizontal`/`vertical` used the raw integer `4` = `Control.SIZE_SHRINK_CENTER` instead of `3` = `SIZE_EXPAND_FILL`) — invisible on screen, and a child-count-only check would never have caught it. Confirmed by deliberately reintroducing the bug: card width check correctly fails at 25px. |
|
||||||
| `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). |
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
+83
-5
@@ -6,6 +6,24 @@ extends Control
|
|||||||
## The Login and Register buttons on the AuthButtonsRow directly set the panel's
|
## The Login and Register buttons on the AuthButtonsRow directly set the panel's
|
||||||
## mode, so the user can switch between Login and Register at any time while
|
## mode, so the user can switch between Login and Register at any time while
|
||||||
## the panel is open.
|
## the panel is open.
|
||||||
|
##
|
||||||
|
## On successful auth the token is stored via ApiClient, AuthButtonsRow hides,
|
||||||
|
## the welcome label shows the username, and the Play button becomes enabled.
|
||||||
|
## The auth form fields are hidden, leaving only the welcome message visible.
|
||||||
|
##
|
||||||
|
## When returning from another scene (e.g. world_list), _ready() checks whether
|
||||||
|
## ApiClient already holds a valid token. If so, it restores the authenticated
|
||||||
|
## UI state (Play enabled, welcome message visible) instead of forcing the user
|
||||||
|
## back to the login form.
|
||||||
|
|
||||||
|
|
||||||
|
## Emitted once _ready()'s session check (has_token() + the awaited
|
||||||
|
## get_me() call) has actually resolved, whichever way -- lets a test (or
|
||||||
|
## anything else) know the UI state is trustworthy now, rather than
|
||||||
|
## guessing from a frame count or from UI properties that are ambiguous
|
||||||
|
## mid-await (e.g. AuthButtonsRow.visible is true both before the check
|
||||||
|
## resolves AND in a genuine logged-out end state).
|
||||||
|
signal auth_state_ready
|
||||||
|
|
||||||
# ─── UI references ────────────────────────────────────────────────────────────
|
# ─── UI references ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -47,7 +65,63 @@ func _ready() -> void:
|
|||||||
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
||||||
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
||||||
|
|
||||||
# Initial state.
|
# Default to Play being gated. When a token exists, the async session
|
||||||
|
# check below may take several frames (a real get_me() round-trip);
|
||||||
|
# keeping the button disabled until validation succeeds prevents a fast
|
||||||
|
# click from navigating to world_list with an expired/invalid token.
|
||||||
|
_play_button.disabled = true
|
||||||
|
|
||||||
|
# Check whether a valid session already exists (e.g. user logged in,
|
||||||
|
# navigated to world_list, then clicked Back). If so, restore the
|
||||||
|
# authenticated UI state instead of forcing the login form again.
|
||||||
|
if ApiClient.has_token():
|
||||||
|
var me_result: Dictionary = await ApiClient.get_me()
|
||||||
|
if me_result.ok and me_result.data is Dictionary:
|
||||||
|
var username: String = str((me_result.data as Dictionary).get("username", ""))
|
||||||
|
if username != "":
|
||||||
|
_show_authenticated(username)
|
||||||
|
auth_state_ready.emit()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Token missing or invalid — fall through to logged-out state.
|
||||||
|
_reset_to_logged_out()
|
||||||
|
auth_state_ready.emit()
|
||||||
|
|
||||||
|
|
||||||
|
## Restore the UI to the authenticated state (Play enabled, welcome message
|
||||||
|
## visible, auth buttons hidden). AuthPanel itself stays visible -- the
|
||||||
|
## welcome label lives inside it, so hiding the panel hides the label too
|
||||||
|
## even when the label's own `visible` is true (real bug, 2026-08-20: a
|
||||||
|
## review caught the panel being hidden here while the label claimed to be
|
||||||
|
## visible, which is a no-op UI-wise since Control.visible doesn't override
|
||||||
|
## an ancestor's hidden state).
|
||||||
|
func _show_authenticated(username: String) -> void:
|
||||||
|
_auth_panel.visible = true
|
||||||
|
_status_label.visible = false
|
||||||
|
_welcome_label.text = "Welcome, %s!" % username
|
||||||
|
_welcome_label.visible = true
|
||||||
|
# Hide the same full set of form fields _on_auth_complete() hides right
|
||||||
|
# after a fresh login/register -- this path (session restore on scene
|
||||||
|
# reload, e.g. after Play -> Back) used to only hide EmailEdit/
|
||||||
|
# Over18Check, leaving UsernameEdit/PasswordEdit/AuthButton/
|
||||||
|
# AuthModeLabel visible on top of the welcome message. Real bug: Play
|
||||||
|
# was enabled and the welcome label was correct, so it wasn't caught by
|
||||||
|
# checks for those alone -- the login fields being back on screen is
|
||||||
|
# what a user actually sees as "it's making me log in again."
|
||||||
|
_username_edit.visible = false
|
||||||
|
_password_edit.visible = false
|
||||||
|
_email_edit.visible = false
|
||||||
|
_over_18_check.visible = false
|
||||||
|
_auth_button.visible = false
|
||||||
|
_auth_mode_label.visible = false
|
||||||
|
_play_button.disabled = false
|
||||||
|
_auth_buttons_row.visible = false
|
||||||
|
print("Restored authenticated session for: ", username)
|
||||||
|
|
||||||
|
|
||||||
|
## Reset the UI to the logged-out state (Play disabled, auth panel hidden,
|
||||||
|
## login/register buttons visible).
|
||||||
|
func _reset_to_logged_out() -> void:
|
||||||
_auth_panel.visible = false
|
_auth_panel.visible = false
|
||||||
_status_label.visible = false
|
_status_label.visible = false
|
||||||
_welcome_label.visible = false
|
_welcome_label.visible = false
|
||||||
@@ -158,12 +232,16 @@ func _on_auth_complete(result: Dictionary) -> void:
|
|||||||
_is_authenticating = false
|
_is_authenticating = false
|
||||||
|
|
||||||
if result.ok:
|
if result.ok:
|
||||||
# Success — show welcome, enable Play, hide the now-redundant
|
# Success — hide the auth form fields, show only the welcome label.
|
||||||
# Login/Register entry points (the auth panel itself stays open,
|
_username_edit.visible = false
|
||||||
# showing the welcome message in place of the form).
|
_password_edit.visible = false
|
||||||
|
_email_edit.visible = false
|
||||||
|
_over_18_check.visible = false
|
||||||
|
_auth_button.visible = false
|
||||||
|
_auth_mode_label.visible = false
|
||||||
|
_status_label.visible = false
|
||||||
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
||||||
_welcome_label.visible = true
|
_welcome_label.visible = true
|
||||||
_status_label.visible = false
|
|
||||||
_play_button.disabled = false
|
_play_button.disabled = false
|
||||||
_auth_buttons_row.visible = false
|
_auth_buttons_row.visible = false
|
||||||
print("Auth successful. Token stored.")
|
print("Auth successful. Token stored.")
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Regression test for the Play -> Back navigation bug.
|
||||||
|
##
|
||||||
|
## The reported bug: after logging in and clicking Play, the user lands on
|
||||||
|
## world_list.tscn; clicking Back returns to main_menu.tscn, but the menu
|
||||||
|
## reset to a fully logged-out state (Play disabled, login form showing)
|
||||||
|
## even though ApiClient still held a valid token. main_menu.gd's _ready()
|
||||||
|
## used to unconditionally reset the UI on every scene load.
|
||||||
|
##
|
||||||
|
## This test drives the real navigation flow (change_scene_to_file) against
|
||||||
|
## a live backend:
|
||||||
|
## 1. Register a unique user so ApiClient holds a valid token.
|
||||||
|
## 2. Load main_menu.tscn and assert it restores the authenticated state.
|
||||||
|
## 3. Click Play -> world_list.tscn.
|
||||||
|
## 4. Click Back -> main_menu.tscn.
|
||||||
|
## 5. Assert the menu is STILL authenticated (Play enabled, welcome label
|
||||||
|
## actually visible in the tree, login/register buttons hidden).
|
||||||
|
##
|
||||||
|
## Run with:
|
||||||
|
## godot --headless --path . --script res://scripts/smoke_test_main_menu_back.gd
|
||||||
|
##
|
||||||
|
## Requires the backend to be running (default base URL
|
||||||
|
## `application/agp/api_base_url` -> http://127.0.0.1:8000).
|
||||||
|
|
||||||
|
const USERNAME := "backnav_%d"
|
||||||
|
const EMAIL := "backnav_%d@test.example.com"
|
||||||
|
const PASSWORD := "BackNav123!"
|
||||||
|
|
||||||
|
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. Establish a real, valid session exactly the way a user would.
|
||||||
|
randomize()
|
||||||
|
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||||
|
var username := USERNAME % unique_id
|
||||||
|
var email := EMAIL % unique_id
|
||||||
|
print("\n=== Setup: register ", username, " ===")
|
||||||
|
var register_result: Dictionary = await api.register(username, email, PASSWORD, true)
|
||||||
|
if not _check("register succeeds", register_result.ok):
|
||||||
|
print(" detail: ", register_result.detail)
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
_check("ApiClient stores token after register", api.has_token())
|
||||||
|
|
||||||
|
# 2. First visit to the main menu: should already show the authenticated
|
||||||
|
# state (no login form, Play enabled).
|
||||||
|
change_scene_to_file("res://scenes/main_menu.tscn")
|
||||||
|
# change_scene_to_file defers the actual swap -- a couple frames is
|
||||||
|
# plenty for the scene object itself to exist (unlike the real
|
||||||
|
# get_me() network round-trip afterward, which is what auth_state_ready
|
||||||
|
# actually waits for below).
|
||||||
|
for i in 5:
|
||||||
|
await process_frame
|
||||||
|
if current_scene is Control:
|
||||||
|
break
|
||||||
|
|
||||||
|
var menu: Control = current_scene
|
||||||
|
if menu == null or not menu is Control:
|
||||||
|
printerr("FAIL: main_menu.tscn did not become current_scene")
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
await menu.auth_state_ready
|
||||||
|
_check("first main menu visit restores authenticated state", _is_authenticated(menu))
|
||||||
|
|
||||||
|
# 3. Play -> world_list.tscn (real button press).
|
||||||
|
var play_button: Button = menu.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||||
|
play_button.pressed.emit()
|
||||||
|
await _wait_for_scene_change(menu)
|
||||||
|
|
||||||
|
var world_list_scene := current_scene
|
||||||
|
if world_list_scene == null or world_list_scene == menu:
|
||||||
|
printerr("FAIL: world_list.tscn did not become current_scene after Play")
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 4. Back -> main_menu.tscn (real button press).
|
||||||
|
var back_button: Button = world_list_scene.get_node("VBoxContainer/HeaderHBox/BackButton")
|
||||||
|
back_button.pressed.emit()
|
||||||
|
await _wait_for_scene_change(world_list_scene)
|
||||||
|
|
||||||
|
menu = current_scene
|
||||||
|
if menu == null or not menu is Control:
|
||||||
|
printerr("FAIL: main_menu.tscn did not become current_scene after Back")
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# The new main_menu instance's own _ready() is *also* awaiting a real
|
||||||
|
# get_me() call -- _wait_for_scene_change only confirms the scene
|
||||||
|
# object exists, not that its authenticated-state fields have been
|
||||||
|
# set yet. Wait for the real signal, not a guess.
|
||||||
|
await menu.auth_state_ready
|
||||||
|
|
||||||
|
# 5. THE regression assertion: still logged in after returning.
|
||||||
|
_check("main menu stays authenticated after Play -> Back", _is_authenticated(menu))
|
||||||
|
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
|
func _is_authenticated(menu: Control) -> bool:
|
||||||
|
var play_button: Button = menu.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||||
|
var welcome_label: Label = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
||||||
|
var auth_buttons_row: HBoxContainer = menu.get_node("CenterContainer/VBoxContainer/AuthButtonsRow")
|
||||||
|
var auth_panel: PanelContainer = menu.get_node("CenterContainer/VBoxContainer/AuthPanel")
|
||||||
|
var username_edit: LineEdit = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit")
|
||||||
|
var password_edit: LineEdit = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit")
|
||||||
|
var auth_button: Button = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
|
||||||
|
var auth_mode_label: Label = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
|
||||||
|
|
||||||
|
_check("Play enabled", not play_button.disabled)
|
||||||
|
# WelcomeLabel lives inside AuthPanel, so `.visible` alone is a
|
||||||
|
# false-positive: the label can have visible=true while its parent is
|
||||||
|
# hidden and it renders as nothing. `is_visible_in_tree()` is the
|
||||||
|
# check that proves the user can actually see the welcome message.
|
||||||
|
_check("Welcome label visible in tree", welcome_label.is_visible_in_tree())
|
||||||
|
_check("Auth buttons row hidden", not auth_buttons_row.visible)
|
||||||
|
_check("Auth panel visible (it contains the welcome label)", auth_panel.visible)
|
||||||
|
# Real bug, 2026-08-20: the session-restore path (this one, after
|
||||||
|
# Play -> Back) hid EmailEdit/Over18Check but never the rest of the
|
||||||
|
# login form -- Play enabled + welcome label visible were both still
|
||||||
|
# true, so a check that stopped there passed while the user was
|
||||||
|
# looking at the login fields again on top of the welcome message.
|
||||||
|
_check("Username field hidden", not username_edit.visible)
|
||||||
|
_check("Password field hidden", not password_edit.visible)
|
||||||
|
_check("Auth button hidden", not auth_button.visible)
|
||||||
|
_check("Auth mode label hidden", not auth_mode_label.visible)
|
||||||
|
|
||||||
|
return (not play_button.disabled) \
|
||||||
|
and welcome_label.is_visible_in_tree() \
|
||||||
|
and not auth_buttons_row.visible \
|
||||||
|
and auth_panel.visible \
|
||||||
|
and not username_edit.visible \
|
||||||
|
and not password_edit.visible \
|
||||||
|
and not auth_button.visible \
|
||||||
|
and not auth_mode_label.visible
|
||||||
|
|
||||||
|
|
||||||
|
func _wait_for_scene_change(previous_scene: Node) -> void:
|
||||||
|
for i in 20:
|
||||||
|
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)
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
extends SceneTree
|
extends SceneTree
|
||||||
## Full click-through simulation of the real user flow: press Register,
|
## Full click-through simulation of the real user flow: press Register,
|
||||||
## fill the form exactly as a human would, submit, confirm Play actually
|
## fill the form exactly as a human would, submit, confirm Play actually
|
||||||
## becomes clickable. This is the check that would have caught the
|
## becomes clickable. Also verifies that after successful auth the auth
|
||||||
## original bug (no way to even open the form) and would catch a repeat
|
## form fields are hidden, leaving only the welcome message visible.
|
||||||
## of it.
|
|
||||||
|
|
||||||
var passed := 0
|
var passed := 0
|
||||||
var failed := 0
|
var failed := 0
|
||||||
@@ -28,6 +27,7 @@ func _run() -> void:
|
|||||||
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
|
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
|
||||||
var over_18_check: CheckBox = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check")
|
var over_18_check: CheckBox = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check")
|
||||||
var auth_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
|
var auth_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
|
||||||
|
var auth_mode_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
|
||||||
var welcome_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
var welcome_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
||||||
var play_button: Button = instance.get_node("CenterContainer/VBoxContainer/PlayButton")
|
var play_button: Button = instance.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||||
|
|
||||||
@@ -60,9 +60,19 @@ func _run() -> void:
|
|||||||
await instance._on_auth_button_pressed()
|
await instance._on_auth_button_pressed()
|
||||||
|
|
||||||
_check("Play became enabled after real register submission", not play_button.disabled)
|
_check("Play became enabled after real register submission", not play_button.disabled)
|
||||||
_check("Welcome label visible", welcome_label.visible)
|
_check("Welcome label visible in tree", welcome_label.is_visible_in_tree())
|
||||||
_check("Auth buttons row hid itself after success", not auth_buttons_row.visible)
|
_check("Auth buttons row hid itself after success", not auth_buttons_row.visible)
|
||||||
|
|
||||||
|
# Verify the auth form fields are hidden after successful auth,
|
||||||
|
# leaving only the welcome message visible within the panel.
|
||||||
|
_check("Username edit hidden after auth", not username_edit.visible)
|
||||||
|
_check("Password edit hidden after auth", not password_edit.visible)
|
||||||
|
_check("Email edit hidden after auth", not email_edit.visible)
|
||||||
|
_check("Over18 checkbox hidden after auth", not over_18_check.visible)
|
||||||
|
_check("Auth button hidden after auth", not auth_button.visible)
|
||||||
|
_check("Auth mode label hidden after auth", not auth_mode_label.visible)
|
||||||
|
_check("Welcome label contains username", welcome_label.text.begins_with("Welcome, mainmenu_e2e_"))
|
||||||
|
|
||||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||||
quit(0 if failed == 0 else 1)
|
quit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user