Compare commits

..

12 Commits

Author SHA1 Message Date
coder-bot e51c4d80b8 WIP checkpoint: Address review feedback: Fix: navigating Play -> Back logs the user out (should stay logged in) 2026-08-20 16:47:47 +00:00
architect-bot 00cdab0452 WIP checkpoint: Address review feedback: Fix: navigating Play -> Back logs the user out (should stay logged in) 2026-08-20 16:40:56 +00:00
coder-bot c287202b9d Fix flaky regression test for Play->Back session persistence
The actual fix (main_menu.gd's _ready() checking ApiClient.has_token()
and restoring authenticated state via get_me()) was already correct --
verified this directly by instrumenting frame-by-frame state and seeing
it resolve properly in ~5 frames. The test wrapping it was the real
problem: _wait_for_menu_ready() only waited 2 frames total before
checking assertions, nowhere near enough for a real HTTP round-trip, so
every run failed on a fix that actually worked.

Root fix: added a real auth_state_ready signal to main_menu.gd, emitted
once _ready()'s session check has actually resolved (either branch --
authenticated or logged-out). The test now awaits that signal directly
instead of guessing from a frame count or from UI properties that are
ambiguous mid-await (AuthButtonsRow.visible is true both before the
check resolves AND in a genuine logged-out end state, which is exactly
what made an earlier polling-based fix attempt here also flaky).

Also fixed a second real bug in the test itself: unique_id used
Time.get_ticks_msec() (process-relative, not wall-clock), colliding
with a previous run's still-present data -- same flakiness class fixed
elsewhere this session, same fix (wall-clock ms + random suffix).

Verified: 12/12 passing across 3 consecutive runs against a real
backend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:12:16 +00:00
coder-bot c01f9de586 WIP checkpoint: Fix: navigating Play -> Back logs the user out (should stay logged in) 2026-08-20 15:04:19 +00:00
coder-bot 0505cd7ff1 WIP checkpoint: Fix: navigating Play -> Back logs the user out (should stay logged in) 2026-08-20 14:03:53 +00:00
claude-checkpoint-tooling 8fc9d378e4 Fix: world cards render at ~1px wide once real data exists
Reported directly by the user after seeding a test world through the
browse-worlds screen: "screen is blank now". Two compounding layout
bugs, both real:

1. world_list.tscn's root VBoxContainer had no anchors at all, only
   size_flags -- but its parent (the scene's root node) is a plain
   Control, not a Container, so size_flags on a Control's direct child
   are meaningless there. Without anchors it never actually filled the
   screen, so it (and everything under it) collapsed toward zero size.

2. size_flags_horizontal/vertical used the raw integer 4 everywhere
   expand-fill was clearly intended (ScrollContainer, its WorldList
   child, HeaderHBox, TitleLabel) -- but 4 is Control.SIZE_SHRINK_CENTER
   in Godot 4, not SIZE_EXPAND_FILL (which is 3). ScrollContainer with
   SHRINK_CENTER takes only its own minimum size instead of expanding,
   cascading a ~1px width down through every card and every label.
   Confirmed the actual enum values by asking Godot directly rather
   than assuming.

Both are exactly the kind of bug a headless smoke test checking only
"did cards get added to the tree" can't catch -- child_count was
correctly non-zero, nothing crashed, the content was just rendered
invisibly thin. Verified the fix is real by deliberately reintroducing
bug #2 and confirming the test now correctly fails (card width 25px,
below the 200px sanity threshold) before restoring it.

Also replaced the card background: it was a ColorRect manually moved to
child index 0 of a VBoxContainer, which doesn't work as an overlay --
a VBoxContainer lays out ALL its children in sequence, so the "moved to
front" ColorRect rendered as an empty dark bar ABOVE the text, not
behind it. Now a proper PanelContainer with a StyleBoxFlat background,
the idiomatic way to get a colored panel behind auto-laid-out content
in Godot.

smoke_test_world_list.gd strengthened to actually catch this class of
bug going forward: seeds a real world via POST /api/worlds/ first (the
checkpoint runner's fresh DB meant the populated-card path never ran in
CI at all before this, only empty-list did), and asserts each card's
real computed width, not just that cards exist.

Full checkpoint suite re-verified clean on this branch: migrations,
68/68 pytest, all 6 Godot smoke tests, both platform exports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 13:35:46 +00:00
claude-checkpoint-tooling 3679e4dbc4 Fix: Login/Register unreachable from the main menu -- no button opened it
Reported directly by the user playtesting the exported exe: Play was
permanently disabled and there was no way to log in or register. Root
cause: main_menu.tscn wired Play and Quit, but nothing was ever
connected to _on_login_button_pressed()/_on_register_button_pressed() --
the AuthPanel, its form, and all its backend-calling logic were fully
correct, just completely unreachable. The script's own top comment even
said "(the existing Play/Quit buttons)" as if Play/Quit somehow doubled
as login triggers, which they never did -- a sign nobody ever actually
clicked through the running scene, only checked things at the API level.

Fix: added a real AuthButtonsRow (LoginButton, RegisterButton) wired to
the existing handlers, which now hide themselves once auth succeeds.
Also fixed grab_focus() targeting the AuthPanel (which can't accept
focus, just a background warning) to focus the username field instead.

Verified via two new smoke tests (smoke_test_main_menu_ui.gd,
smoke_test_main_menu_full_flow.gd) using a *real signal emission* on
the buttons, not a direct method call -- that distinction is exactly
what would have caught the original bug. Full click-through confirmed
against a real backend: open register form, fill it, submit, Play
actually becomes enabled, welcome message shows, auth buttons hide.

Also documents a real headless-testing gotcha found while verifying
this: a synthetic Button.pressed.emit() on an await-using handler
doesn't reliably get driven forward in --script/SceneTree mode the way
real GUI input dispatch does -- confirmed by comparing direct-call vs
emit() behavior side by side. Not a bug in the actual click-driven flow,
a test-harness quirk to route around (call the handler directly,
awaited, instead of emitting the signal).

Full checkpoint suite re-verified clean on this branch: migrations,
68/68 pytest, all 6 Godot smoke tests (including the 2 new ones, picked
up automatically by run_checkpoint_tests.py's glob).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 13:13:38 +00:00
claude-checkpoint-tooling c33b10ac46 Embed the .pck into the exported binary instead of a separate file
export_presets.cfg had binary_format/embed_pck=false on both presets,
so every export produced two files (agp-game.exe + agp-game.pck) that
have to stay together and correctly named or Godot refuses to start:
"Couldn't load project data ... Is the .pck missing?" -- hit for real
running a downloaded PC1 playtest exe today (only the .exe got copied
over, not the .pck).

Verified both platforms now produce a single self-contained binary:
Windows PE32+ (104,580,904 bytes, no separate .pck) and Linux ELF64
(71,140,152 bytes, no separate .pck).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 13:03:19 +00:00
claude-checkpoint-tooling 8c54129bcd Update PC1 playtest instructions for the new dashboard controls
Replaces the manual multi-step shell commands (docker compose, alembic,
uvicorn with env vars, manual teardown) with the one-click dashboard
flow at /checkpoint/PC1 in ~/agp (commit 261c45f): Start Backend / Stop
& Clean Up, and Build / Download a pre-configured Windows .exe. No
terminal commands needed on either machine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 12:51:27 +00:00
reviewer-bot 409e78baa5 Merge pull request 'Update test_inventory.md to reflect PC1 checkpoint reality' (#40) from agent/398bf1a22a2d into main 2026-08-20 01:37:26 +00:00
agent-bot f8e5de9293 Merge pull request 'Add PC1 checkpoint record + playtest instructions' (#41) from docs/pc1-checkpoint-record into main 2026-08-20 01:17:30 +00:00
claude-checkpoint-tooling 3a7b2ab306 Add PC1 checkpoint record + playtest instructions
Validation results from the 2026-08-20 checkpoint run (fresh clone,
commit 927f9bd: migrations, 68/68 pytest, all 4 Godot smoke tests, both
platform exports) plus manual playtest instructions for backend on
cloudbison / client on pinkbison. First of docs/checkpoints/PC<N>.md,
one per Package Checkpoint going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 01:17:18 +00:00
11 changed files with 563 additions and 57 deletions
+72
View File
@@ -0,0 +1,72 @@
# Package Checkpoint 1 (PC1)
Validated 2026-08-20 against `main` commit `927f9bd`, from a genuinely fresh
clone (not a reused agent workspace).
## Automated validation
Run with `scripts/run_checkpoint_tests.py` — see
`docs/testing/test_inventory.md` for exactly what each suite covers.
| Check | Result |
|---|---|
| Alembic migrations, fresh DB | PASS |
| Backend pytest | 68/68 PASS |
| Godot smoke tests (4 files) | ALL PASS |
| Linux export (`agp-game.x86_64`) | Builds, verified as a real ELF64 binary |
| Windows export (`agp-game.exe`) | Builds, verified as a real PE32+ binary |
## Problems found during this checkpoint (fixed, tracked as tasks)
- Roadmap showed the Godot Engine Scaffold feature amber despite 6/6 tasks
done — a historical data gap (a review-feedback follow-up task's
supersession link was never recorded, predating when that field started
being tracked). Backfilled in the PM database; confirmed green.
- `docs/testing/test_inventory.md` was stale (said 38 backend tests,
actually 68; said no integration tests existed, 4 real ones had landed).
Fixed via task `398bf1a22a2d` / PR #40.
## What's actually testable right now
Login/register/browse-worlds only — there's no joinable gameplay yet. That's
the real scope of PC1; don't expect more when playtesting.
- Register a new account (18+ only, rejected otherwise)
- Log out, log back in with the same credentials
- Play → Browse Worlds — loads a (currently empty) world list with no errors
## Manual playtest: backend on cloudbison, client on pinkbison
As of 2026-08-20 this is dashboard-controlled — no terminal commands, no
env vars, no editing files. Foolproof enough to hand to someone else.
### 1. On cloudbison — the AGP dashboard
Open the dashboard (`http://cloudbison:7070`, or its Tailscale address) and
go to **PC1** in the nav bar (`/checkpoint/PC1`). Click **Start Backend** —
it pulls the latest `main`, stands up Postgres, runs migrations, and starts
the API server, all in one step. The page shows the LAN address it's
reachable at once it's up (under a minute).
When you're done playtesting, click **Stop & Clean Up** on the same page —
it tears down everything that step started (API server, Postgres
container, its volume and network). Nothing is left running or lying
around afterward, even if the dashboard itself gets restarted in between.
### 2. On pinkbison — download and run the .exe
Same dashboard page: click **Build .exe** the first time (or **Rebuild** to
pick up newer changes) — this pulls the latest `main` and exports a Windows
binary with cloudbison's backend address already baked in. Click
**Download agp-game.exe**, copy it to pinkbison, and just run it. No
environment variable, no config file, no build step on pinkbison at all.
### 3. Tear down when done
**Stop & Clean Up** on the dashboard page. That's it.
## Status
`config/checkpoint_focus_feature_ids` in the AGP framework (`~/agp`) still
restricts agent claiming to the 5 PC1-scope features. Agents will not move
on to post-PC1 work until that's widened.
+18 -8
View File
@@ -7,9 +7,13 @@ 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 (commit `927f9bd`) — full PC1 Last verified against `main`: 2026-08-20 — full PC1 checkpoint run,
checkpoint run, fresh clone, migrations + pytest + all 4 Godot smoke tests + migrations + pytest + all 6 Godot smoke tests + both platform exports, all
both platform exports, all clean. clean. Two 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, and the world
list's cards rendered at ~1px wide once real data existed. Both are now
covered by the smoke tests below so they can't silently regress.
## Backend — `backend/tests/` (pytest) ## Backend — `backend/tests/` (pytest)
@@ -48,7 +52,7 @@ these yet):
## Godot client — `scripts/smoke_test_*.gd` ## Godot client — `scripts/smoke_test_*.gd`
**4 smoke tests merged into `main`.** Each needs a live backend — **6 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.
@@ -58,7 +62,9 @@ these yet):
| `smoke_test_api.gd` | Exercises `ApiClient.health_check()` against a live backend. Verifies the real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, result-dictionary contract). Exits 1 on failure. | | `smoke_test_api.gd` | Exercises `ApiClient.health_check()` against a live backend. Verifies the real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, result-dictionary contract). Exits 1 on failure. |
| `smoke_test_auth.gd` | Tests the full auth flow via `ApiClient` autoload: register unique user (201), duplicate username (409), under-18 registration (400), correct login (200), wrong password (401), nonexistent user (401). | | `smoke_test_auth.gd` | Tests the full auth flow via `ApiClient` autoload: register unique user (201), duplicate username (409), under-18 registration (400), correct login (200), wrong password (401), nonexistent user (401). |
| `smoke_test_e2e.gd` | End-to-end flow: health check → register → login → worlds list. Verifies the full request chain with a fresh user, including token storage and `get_worlds()` with auth header. | | `smoke_test_e2e.gd` | End-to-end flow: health check → register → login → worlds list. Verifies the full request chain with a fresh user, including token storage and `get_worlds()` with auth header. |
| `smoke_test_world_list.gd` | Instantiates `world_list.tscn` as a real scene, adds to tree (catches `@onready` node-path bugs), waits for `_refresh_worlds()` to complete, confirms the screen reaches a real end state (world cards, empty-list message, or error label — not stuck loading). | | `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_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. |
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
@@ -66,14 +72,18 @@ res://scripts/smoke_test_<name>.gd` — **no `--quit`**, it kills these before
their awaited HTTP calls finish. their awaited HTTP calls finish.
**Known gaps:** **Known gaps:**
- No smoke tests for the main menu scene (`main_menu.tscn`).
- No smoke tests for chunk loading/streaming (`chunk.gd`, `chunk_scene.gd`). - No smoke tests for chunk loading/streaming (`chunk.gd`, `chunk_scene.gd`).
- No smoke tests for dynamic/static object nodes. - No smoke tests for dynamic/static object nodes.
- All smoke tests require a live backend; no offline/unit smoke tests exist. - All smoke tests require a live backend; no offline/unit smoke tests exist.
- `smoke_test_world_list.gd` is the only one that exercises a real `.tscn` - `smoke_test_world_list.gd` and `smoke_test_main_menu_*.gd` are the only
scene instantiation — others only test the `ApiClient` autoload. ones that exercise a real `.tscn` scene instantiation — others only test
the `ApiClient` autoload directly.
- `get_me()` (`GET /auth/me`) is implemented in `scripts/api_client.gd` but - `get_me()` (`GET /auth/me`) is implemented in `scripts/api_client.gd` but
has no smoke coverage yet. has no smoke coverage yet.
- The Login path (as opposed to Register) through `main_menu.gd` isn't
separately smoke-tested — it shares the same `_on_auth_button_pressed()`/
`_on_auth_complete()` code path already exercised for Register, but that's
inference, not a direct check.
## Client export ## Client export
+2 -2
View File
@@ -21,7 +21,7 @@ script_export_mode=2
custom_template/debug="" custom_template/debug=""
custom_template/release="" custom_template/release=""
debug/export_console_wrapper=1 debug/export_console_wrapper=1
binary_format/embed_pck=false binary_format/embed_pck=true
texture_format/bptc=true texture_format/bptc=true
texture_format/s3tc=true texture_format/s3tc=true
texture_format/etc=false texture_format/etc=false
@@ -52,7 +52,7 @@ script_export_mode=2
custom_template/debug="" custom_template/debug=""
custom_template/release="" custom_template/release=""
debug/export_console_wrapper=1 debug/export_console_wrapper=1
binary_format/embed_pck=false binary_format/embed_pck=true
texture_format/bptc=true texture_format/bptc=true
texture_format/s3tc=true texture_format/s3tc=true
texture_format/etc=false texture_format/etc=false
+17
View File
@@ -29,6 +29,21 @@ text = "AGP Game"
horizontal_alignment = 1 horizontal_alignment = 1
theme_override_font_sizes/font_size = 72 theme_override_font_sizes/font_size = 72
[node name="AuthButtonsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 1
theme_override_constants/separation = 16
alignment = 1
[node name="LoginButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
layout_mode = 1
text = "Login"
size_flags_horizontal = 3
[node name="RegisterButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
layout_mode = 1
text = "Register"
size_flags_horizontal = 3
[node name="AuthPanel" type="PanelContainer" parent="CenterContainer/VBoxContainer"] [node name="AuthPanel" type="PanelContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 1 layout_mode = 1
visible = false visible = false
@@ -106,6 +121,8 @@ layout_mode = 1
text = "Quit" text = "Quit"
size_flags_horizontal = 4 size_flags_horizontal = 4
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_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="toggled" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle" to="." method="_on_mode_toggle_toggled"]
+11 -9
View File
@@ -11,14 +11,17 @@ grow_vertical = 2
script = ExtResource("1_wl") script = ExtResource("1_wl")
[node name="VBoxContainer" type="VBoxContainer" parent="."] [node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 3 layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 8 theme_override_constants/separation = 8
size_flags_horizontal = 4
size_flags_vertical = 4
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"] [node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 3 layout_mode = 3
size_flags_horizontal = 4 size_flags_horizontal = 3
theme_override_constants/separation = 16 theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"] [node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
@@ -30,7 +33,7 @@ size_flags_horizontal = 0
layout_mode = 3 layout_mode = 3
text = "Browse Worlds" text = "Browse Worlds"
horizontal_alignment = 1 horizontal_alignment = 1
size_flags_horizontal = 4 size_flags_horizontal = 3
theme_override_font_sizes/font_size = 28 theme_override_font_sizes/font_size = 28
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"] [node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
@@ -40,15 +43,14 @@ size_flags_horizontal = 0
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"] [node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
layout_mode = 3 layout_mode = 3
size_flags_horizontal = 4 size_flags_horizontal = 3
size_flags_vertical = 4 size_flags_vertical = 3
horizontal_mode = 2 horizontal_mode = 2
vertical_mode = 2 vertical_mode = 2
[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"] [node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
layout_mode = 3 layout_mode = 3
size_flags_horizontal = 4 size_flags_horizontal = 3
size_flags_vertical = 4
theme_override_constants/separation = 8 theme_override_constants/separation = 8
[node name="LoadingLabel" type="Label" parent="VBoxContainer"] [node name="LoadingLabel" type="Label" parent="VBoxContainer"]
+81 -19
View File
@@ -1,17 +1,33 @@
extends Control extends Control
## Main menu with login/register UI wired to the backend auth API. ## 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 AuthPanel is hidden by default. When the user clicks the LoginButton or
## (the existing Play/Quit buttons), the auth panel slides in. The ModeToggle ## RegisterButton (AuthButtonsRow, above the panel), the auth panel slides in.
## switches between Login and Register modes, showing/hiding the email and DOB ## The ModeToggle switches between Login and Register modes, showing/hiding
## fields accordingly. ## the email and DOB fields accordingly.
## ##
## On successful auth the token is stored via ApiClient, the welcome label ## On successful auth the token is stored via ApiClient, AuthButtonsRow hides,
## shows the username, and the Play button becomes enabled. ## the welcome label shows the username, and the Play button becomes enabled.
##
## 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 label visible) instead of forcing the user
## back to the login form.
# ── UI references ──────────────────────────────────────────────────────────── ## 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 ────────────────────────────────────────────────────────────
var _auth_buttons_row: HBoxContainer
var _login_button: Button
var _register_button: Button
var _auth_panel: PanelContainer var _auth_panel: PanelContainer
var _auth_mode_label: Label var _auth_mode_label: Label
var _username_edit: LineEdit var _username_edit: LineEdit
@@ -29,10 +45,13 @@ var _is_register_mode: bool = false
var _is_authenticating: bool = false var _is_authenticating: bool = false
# ── Lifecycle ──────────────────────────────────────────────────────────────── # ─── Lifecycle ────────────────────────────────────────────────────────────────
func _ready() -> void: func _ready() -> void:
# Cache node references. # Cache node references.
_auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow
_login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton
_register_button = $CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton
_auth_panel = $CenterContainer/VBoxContainer/AuthPanel _auth_panel = $CenterContainer/VBoxContainer/AuthPanel
_auth_mode_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel _auth_mode_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit _username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
@@ -46,7 +65,46 @@ 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 label,
## auth buttons hidden).
func _show_authenticated(username: String) -> void:
_auth_panel.visible = true
_status_label.visible = false
_welcome_label.text = "Welcome, %s!" % username
_welcome_label.visible = true
_email_edit.visible = false
_dob_edit.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
@@ -56,7 +114,7 @@ func _ready() -> void:
_update_auth_mode_ui() _update_auth_mode_ui()
# ── Play / Quit ────────────────────────────────────────────────────────────── # ─── Play / Quit ─────────────────────────────────────────────────────────────
func _on_play_button_pressed() -> void: func _on_play_button_pressed() -> void:
if _play_button.disabled: if _play_button.disabled:
@@ -70,24 +128,25 @@ func _on_quit_button_pressed() -> void:
get_tree().quit() get_tree().quit()
# ── Auth panel toggle ──────────────────────────────────────────────────────── # ─── Auth panel toggle ───────────────────────────────────────────────────────
func _on_login_button_pressed() -> void: func _on_login_button_pressed() -> void:
"""Show the auth panel (called from a future Login button on the main menu).""" """Show the auth panel, called from AuthButtonsRow/LoginButton."""
_auth_panel.visible = true _auth_panel.visible = true
_auth_panel.grab_focus() _username_edit.grab_focus()
func _on_register_button_pressed() -> void: func _on_register_button_pressed() -> void:
"""Show the auth panel in register mode.""" """Show the auth panel in register mode, called from AuthButtonsRow/RegisterButton."""
_auth_panel.visible = true _auth_panel.visible = true
_is_register_mode = true _is_register_mode = true
_mode_toggle.button_pressed = true _mode_toggle.button_pressed = true
_update_auth_mode_ui() _update_auth_mode_ui()
_clear_status() _clear_status()
_username_edit.grab_focus()
# ── Mode toggle (Login / Register) ─────────────────────────────────────────── # ─── Mode toggle (Login / Register) ──────────────────────────────────────────
func _on_mode_toggle_toggled(button_pressed: bool) -> void: func _on_mode_toggle_toggled(button_pressed: bool) -> void:
_is_register_mode = button_pressed _is_register_mode = button_pressed
@@ -109,7 +168,7 @@ func _update_auth_mode_ui() -> void:
_dob_edit.visible = false _dob_edit.visible = false
# ── Auth submission ────────────────────────────────────────────────────────── # ─── Auth submission ─────────────────────────────────────────────────────────
func _on_auth_button_pressed() -> void: func _on_auth_button_pressed() -> void:
"""Handle login or register button press.""" """Handle login or register button press."""
@@ -166,18 +225,21 @@ func _on_auth_complete(result: Dictionary) -> void:
_is_authenticating = false _is_authenticating = false
if result.ok: if result.ok:
# Success — show welcome, enable Play. # Success — show welcome, enable Play, hide the now-redundant
# Login/Register entry points (the auth panel itself stays open,
# showing the welcome message in place of the form).
_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 _status_label.visible = false
_play_button.disabled = false _play_button.disabled = false
_auth_buttons_row.visible = false
print("Auth successful. Token stored.") print("Auth successful. Token stored.")
else: else:
# Failure — show backend error detail. # Failure — show backend error detail.
_show_status(result.detail) _show_status(result.detail)
# ── Status helpers ─────────────────────────────────────────────────────────── # ─── Status helpers ──────────────────────────────────────────────────────────
func _show_status(message: String) -> void: func _show_status(message: String) -> void:
_status_label.text = message _status_label.text = message
@@ -189,7 +251,7 @@ func _clear_status() -> void:
_status_label.visible = false _status_label.visible = false
# ── Validation ─────────────────────────────────────────────────────────────── # ─── Validation ──────────────────────────────────────────────────────────────
func _is_valid_date(date_str: String) -> bool: func _is_valid_date(date_str: String) -> bool:
"""Check if a string looks like a valid YYYY-MM-DD date.""" """Check if a string looks like a valid YYYY-MM-DD date."""
+154
View File
@@ -0,0 +1,154 @@
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!"
const DOB := "2000-01-01"
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, DOB)
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")
_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)
return (not play_button.disabled) \
and welcome_label.is_visible_in_tree() \
and not auth_buttons_row.visible \
and auth_panel.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)
+76
View File
@@ -0,0 +1,76 @@
extends SceneTree
## Full click-through simulation of the real user flow: press Register,
## fill the form exactly as a human would, submit, confirm Play actually
## becomes clickable. This is the check that would have caught the
## original bug (no way to even open the form) and would catch a repeat
## of it.
var passed := 0
var failed := 0
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
var packed: PackedScene = load("res://scenes/main_menu.tscn")
var instance: Control = packed.instantiate()
root.add_child(instance)
await process_frame
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
var auth_buttons_row: HBoxContainer = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow")
var username_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit")
var password_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit")
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
var dob_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit")
var auth_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
var welcome_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
var play_button: Button = instance.get_node("CenterContainer/VBoxContainer/PlayButton")
_check("Play starts disabled", play_button.disabled)
# Click Register -- a real signal emission is fine here since
# _on_register_button_pressed() is synchronous (no await), same as a
# real click's GUI event dispatch would drive it.
register_button.pressed.emit()
await process_frame
_check("auth panel opened in register mode", instance.get_node("CenterContainer/VBoxContainer/AuthPanel").visible)
# Fill the form exactly as a user would type into it.
var unique_id := int(Time.get_unix_time_from_system() * 1000)
username_edit.text = "mainmenu_e2e_%d" % unique_id
password_edit.text = "TestPass123!"
email_edit.text = "mainmenu_e2e_%d@test.example.com" % unique_id
dob_edit.text = "2000-01-01"
# Call the submit handler directly (awaited) rather than via
# auth_button.pressed.emit() -- a synthetic .emit() in --script/
# SceneTree headless mode doesn't drive an await-using handler's
# coroutine forward the same way real GUI input event dispatch does
# (confirmed via a direct-call vs emit() comparison: the direct call
# always resolves, the synthetic emit sometimes doesn't advance past
# its own await point even after 50 real process_frame waits). This
# is a headless-mode test-harness limitation, not a bug in the actual
# click-driven flow.
await instance._on_auth_button_pressed()
_check("Play became enabled after real register submission", not play_button.disabled)
_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)
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
quit(0 if failed == 0 else 1)
func _check(description: String, condition: bool) -> void:
if condition:
print(" PASS: ", description)
passed += 1
else:
printerr(" FAIL: ", description)
failed += 1
+60
View File
@@ -0,0 +1,60 @@
extends SceneTree
## Verifies the main menu's Login/Register buttons actually open the auth
## panel via a real signal emission (not calling the handler method
## directly) -- that's the exact gap that shipped without ever being
## caught: the AuthPanel and its logic were all correct, but nothing in
## the scene was ever wired to open it.
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
var passed := 0
var failed := 0
var packed: PackedScene = load("res://scenes/main_menu.tscn")
var instance: Control = packed.instantiate()
root.add_child(instance)
await process_frame
var login_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton")
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
var auth_panel: PanelContainer = instance.get_node("CenterContainer/VBoxContainer/AuthPanel")
if _check("auth panel starts hidden", not auth_panel.visible):
passed += 1
else:
failed += 1
login_button.pressed.emit()
await process_frame
if _check("auth panel visible after a real LoginButton press signal", auth_panel.visible):
passed += 1
else:
failed += 1
# Reset and check RegisterButton independently too.
auth_panel.visible = false
register_button.pressed.emit()
await process_frame
if _check("auth panel visible after a real RegisterButton press signal", auth_panel.visible):
passed += 1
else:
failed += 1
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
quit(0 if failed == 0 else 1)
func _check(description: String, condition: bool) -> bool:
if condition:
print(" PASS: ", description)
else:
printerr(" FAIL: ", description)
return condition
+53 -7
View File
@@ -16,15 +16,27 @@ extends SceneTree
## ##
## Tests: ## Tests:
## 1. Register + log in a fresh user (so get_worlds() has a valid token). ## 1. Register + log in a fresh user (so get_worlds() has a valid token).
## 2. Instantiate world_list.tscn, add to tree -- @onready resolution ## 2. POST a real world via the API so the populated-card path actually
## gets exercised -- run_checkpoint_tests.py always starts from a
## genuinely empty DB, so without this the card-rendering code below
## would never run in CI at all, only the empty-list path would.
## 3. Instantiate world_list.tscn, add to tree -- @onready resolution
## must not error. ## must not error.
## 3. Wait for _refresh_worlds()'s awaited request to complete. ## 4. Wait for _refresh_worlds()'s awaited request to complete.
## 4. Confirm the screen reached a real end state (world cards, or the ## 5. Confirm it actually reached the populated (not empty) state, and
## empty-list message -- either is fine, a crash/stuck-loading isn't). ## that the rendered card has a real width -- not just non-zero
## child_count. 2026-08-20: a real bug shipped where every card
## technically existed in the tree (child_count > 0, no error, no
## crash) but rendered at 1.0px wide because size_flags_horizontal/
## vertical used the raw integer 4 (Control.SIZE_SHRINK_CENTER)
## instead of 3 (Control.SIZE_EXPAND_FILL) on ScrollContainer -- a
## child-count check alone would never catch that, only a real size
## assertion does.
## ##
## Exits 0 on full pass, 1 on any failure. ## Exits 0 on full pass, 1 on any failure.
const WORLD_LIST_SCENE := "res://scenes/world_list.tscn" const WORLD_LIST_SCENE := "res://scenes/world_list.tscn"
const MIN_SANE_CARD_WIDTH := 200.0 # a real screen is at minimum a few hundred px wide
func _initialize() -> void: func _initialize() -> void:
@@ -52,6 +64,19 @@ func _run() -> void:
quit(1) quit(1)
return return
# --- Seed a real world so the populated-card path is actually exercised ---
print("\n=== Setup: seed a real world ===")
var world_body := JSON.stringify({
"name": "Smoke Test World %d" % unique_id,
"description": "Seeded by smoke_test_world_list.gd to exercise the populated-card render path.",
"visibility": "public",
})
var create_result: Dictionary = await api.request(HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true)
if not _check("setup: world created", create_result.ok):
print(" detail: ", create_result.detail)
quit(1)
return
# --- Test 1: Instantiate the real scene --- # --- Test 1: Instantiate the real scene ---
print("\n=== Test 1: Instantiate world_list.tscn ===") print("\n=== Test 1: Instantiate world_list.tscn ===")
var packed_scene: PackedScene = load(WORLD_LIST_SCENE) var packed_scene: PackedScene = load(WORLD_LIST_SCENE)
@@ -99,10 +124,31 @@ func _run() -> void:
if error_label.visible: if error_label.visible:
printerr(" Note: ended in error state: ", error_label.text) 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: elif empty_label.visible:
print(" Note: ended in empty-list state") # We just seeded a world -- ending up empty means GET /api/worlds
# isn't actually seeing what POST /api/worlds/ just created.
_check("ended in populated state, not empty (a world was just seeded)", false)
failed += 1
elif world_list_node.visible:
var card_count := world_list_node.get_child_count()
print(" Note: rendered %d world card(s)" % card_count)
if _check("at least one card rendered", card_count > 0):
passed += 1
else:
failed += 1
# The actual bug this test exists to catch: a card can be present
# in the tree, not crash, and still be visually nothing because
# it rendered at ~1px wide. Check every card's real computed size,
# not just that it exists.
for i in range(card_count):
var card: Control = world_list_node.get_child(i)
var wide_enough := card.size.x >= MIN_SANE_CARD_WIDTH
if _check("card %d has a real width (%.0fpx >= %.0fpx)" % [i, card.size.x, MIN_SANE_CARD_WIDTH], wide_enough):
passed += 1
else:
failed += 1
# --- Summary --- # --- Summary ---
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed]) print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
+19 -12
View File
@@ -88,10 +88,26 @@ func _refresh_worlds() -> void:
## Create a visual card for a single world entry. ## Create a visual card for a single world entry.
func _create_world_card(world_dict: Dictionary) -> Control: func _create_world_card(world_dict: Dictionary) -> Control:
# PanelContainer for the background, not a ColorRect moved to child index
# 0 of a VBoxContainer -- a VBoxContainer lays out ALL its children in a
# vertical sequence, it has no concept of "float this one child behind
# the others as an overlay". That version rendered as an empty dark bar
# sitting above the text, not a background behind it.
var panel := PanelContainer.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.custom_minimum_size = Vector2(0, 80)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
style.content_margin_left = 12
style.content_margin_right = 12
style.content_margin_top = 8
style.content_margin_bottom = 8
panel.add_theme_stylebox_override("panel", style)
var card := VBoxContainer.new() var card := VBoxContainer.new()
card.size_flags_horizontal = Control.SIZE_FILL card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
card.custom_minimum_size = Vector2(0, 80)
card.add_theme_constant_override("separation", 4) card.add_theme_constant_override("separation", 4)
panel.add_child(card)
var name_label := Label.new() var name_label := Label.new()
name_label.text = world_dict.get("name", "Unnamed World") name_label.text = world_dict.get("name", "Unnamed World")
@@ -123,13 +139,4 @@ func _create_world_card(world_dict: Dictionary) -> Control:
card.add_child(desc_label) card.add_child(desc_label)
card.add_child(meta_label) card.add_child(meta_label)
# Subtle background for the card return panel
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