Compare commits

...

24 Commits

Author SHA1 Message Date
reviewer-bot 66adcff145 Merge pull request 'PC2: Design the test level & player spawn approach' (#46) from agent/a6deeb84eace into main 2026-08-20 21:58:55 +00:00
architect-bot 2ffcd9cd3f WIP checkpoint: PC2: Design the test level & player spawn approach 2026-08-20 21:09:30 +00:00
claude-checkpoint-tooling aeccc78143 Fix login form fields reappearing after Play -> Back (real playtest bug)
_show_authenticated() (the session-restore path, used when returning to
main_menu.tscn -- e.g. after clicking Back from world_list) only hid
EmailEdit/Over18Check, unlike _on_auth_complete() (the fresh-login path)
which hides the full form: UsernameEdit, PasswordEdit, EmailEdit,
Over18Check, AuthButton, AuthModeLabel. Play was correctly enabled and
the welcome message was correctly visible, so a check that stopped at
those two passed clean -- but the actual login fields were still on
screen, which is what a real user sees as "it's making me log in again."
Confirmed via live PC1 playtest today.

Reported by: real playtest, 2026-08-20 (yesterday's automated regression
test for this exact Play -> Back flow only asserted Play/welcome/auth
buttons row -- strengthened smoke_test_main_menu_back.gd to also check
the individual form fields, and confirmed it fails exactly this way with
the bug reintroduced before restoring the fix).

Verified against a live backend: 61/61 pytest (unaffected), 8/8 Godot
smoke tests (including 20/20 on the strengthened back-nav test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-20 19:21:40 +00:00
claude-checkpoint-tooling 1c64dd4967 Fix broken main_menu.gd on main + land Play->Back session persistence
Two things, found together while investigating a stuck blocker task:

1. URGENT: main_menu.gd currently fails to parse at all on main
   ("Identifier '_dob_edit' not declared in the current scope"). PR #44
   (hide-all-fields-on-auth-success) and PR #45 (DOBEdit -> Over18Check
   checkbox) each merged cleanly on their own -- Gitea's merge reported
   mergeable:true for both -- but together they left a reference to the
   old _dob_edit var on a line neither PR's diff touched, so no conflict
   was ever flagged. Same failure mode in smoke_test_main_menu_full_flow.gd
   (dob_edit, would have failed to even parse in the checkpoint suite).
   Fixed both to reference _over_18_check / over_18_check.

2. Lands the Play -> Back session-persistence fix (PR #43, task
   28357bca192d) that was stuck behind review the whole time main didn't
   have it yet: signal auth_state_ready, _show_authenticated() /
   _reset_to_logged_out() helpers, and _ready() restoring the
   authenticated UI (not resetting it) when ApiClient already holds a
   valid token. Plus the real fix a review round caught on top of that
   (task 9186405a6373): _show_authenticated() was hiding AuthPanel while
   claiming the welcome label inside it was visible -- a no-op UI-wise,
   since WelcomeLabel.visible=true does nothing once its parent is
   hidden. AuthPanel now stays visible; the regression test
   (smoke_test_main_menu_back.gd, new) asserts is_visible_in_tree(), not
   just .visible, so this class of bug can't pass silently again.

Verified against a live backend: 61/61 pytest, 7/7 Godot smoke tests
(including the new smoke_test_main_menu_back.gd, 12/12).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-20 18:15:32 +00:00
reviewer-bot 4dd6c7c8af Merge pull request 'Main menu: hide the login/register form once authenticated, keep only the welcome message' (#44) from agent/470f875e0416 into main 2026-08-20 17:08:45 +00:00
reviewer-bot e90b394f03 Merge pull request 'Registration: replace date-of-birth field with a simple 18+ confirmation' (#45) from agent/f4c371c981ba into main 2026-08-20 16:51:43 +00:00
claude-checkpoint-tooling 33b0c3f1fb Merge main into agent/470f875e0416 to resolve PR #44 conflicts
# Conflicts:
#	scripts/main_menu.gd
2026-08-20 16:47:44 +00:00
coder-bot 2b88fc476e Merge remote WIP checkpoint; keep the verified over_18 fix (ModeToggle stays removed per PR #42)
# Conflicts:
#	backend/app/routers/auth.py
2026-08-20 16:27:41 +00:00
claude-checkpoint-tooling 0ecc16d963 Registration: replace date-of-birth field with a simple 18+ confirmation
The register form collected and validated an actual date of birth just to
throw it away (backend never stored it beyond a nullable column). Per the
roadmap note: don't collect/store DOB right now, just get a client-side
"I am 18 or older" confirmation. RegisterRequest.date_of_birth (str) is now
over_18 (bool); the endpoint rejects over_18=false with 400 same as before.
calculate_age() is kept (unused by /register now) since real DOB collection
is coming back in a future pass and deleting it broke test_auth_age_gate.py's
import for no benefit.

Frontend: DOBEdit LineEdit replaced with an Over18Check CheckBox; api_client.gd's
register() now takes a bool instead of a date string. All Godot callers
(smoke_test_auth/e2e/world_list/main_menu_ui/main_menu_full_flow) and backend
test files updated to match. Full suite verified against a live backend:
61/61 pytest, 6/6 Godot smoke tests (33 individual assertions), all passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-20 16:25:27 +00:00
coder-bot 4ec71b87b9 WIP checkpoint: Registration: replace date-of-birth field with a simple 18+ confirmation 2026-08-20 15:10:43 +00:00
reviewer-bot dc0c133af8 Merge pull request 'Main menu: fix Register-mode UI trapping the user (remove the confusing mode toggle)' (#42) from agent/0b9e2c5ae6ec into main 2026-08-20 15:06:42 +00:00
coder-bot 8f1a1110e2 WIP checkpoint: Main menu: hide the login/register form once authenticated, keep only the welcome message 2026-08-20 15:01:02 +00:00
coder-bot 0f7531008f WIP checkpoint: Registration: replace date-of-birth field with a simple 18+ confirmation 2026-08-20 14:58:15 +00:00
coder-bot 12fef51020 WIP checkpoint: Main menu: fix Register-mode UI trapping the user (remove the confusing mode toggle) 2026-08-20 14:03:57 +00:00
coder-bot af50819a16 WIP checkpoint: Main menu: fix Register-mode UI trapping the user (remove the confusing mode toggle) 2026-08-20 14:01:20 +00:00
coder-bot 16bb87c6b0 WIP checkpoint: Main menu: fix Register-mode UI trapping the user (remove the confusing mode toggle) 2026-08-20 13:56:44 +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
architect-bot 1cc81979a4 Merge architect's per-file test detail into the checkpoint doc fix
Architect independently reclaimed this task after my fix landed and
rewrote the same doc with more precise per-file test descriptions and a
more specific gap list -- genuinely better in places (caught real detail
I'd summarized loosely) but its own investigation also introduced one
factual error (claimed 18 tests in test_auth_age_gate.py; verified via
pytest --collect-only, it's 17) and dropped two things worth keeping:
the exact commit hash for reproducibility, and the Client export section
(it never looked at the export step).

Merged: kept the architect's richer per-file coverage descriptions and
gap analysis, restored the verified counts/commit hash/export section/
--quit gotcha from my original.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 01:37:01 +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 afc2211d70 Update test_inventory.md to reflect PC1 checkpoint reality
Was written 2026-08-19 and badly stale by the time the full PC1
checkpoint validation ran 2026-08-20: said 38 backend tests (actually
68), said zero integration/endpoint tests exist (4 real integration
test files landed since), and said nothing had merged for Godot smoke
tests (4 real ones did).

Verified for real, fresh clone of main, commit 927f9bd: migrations +
68/68 pytest + all 4 Godot smoke tests + both platform exports (Linux
ELF64 + Windows PE32+, confirmed valid via file) all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 00:47:18 +00:00
20 changed files with 975 additions and 276 deletions
+16 -20
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel, EmailStr, field_validator
from pydantic import BaseModel, EmailStr
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -39,17 +39,7 @@ class RegisterRequest(BaseModel):
username: str
email: EmailStr
password: str
date_of_birth: str # ISO format date string (YYYY-MM-DD)
@field_validator("date_of_birth")
@classmethod
def validate_date_of_birth(cls, v: str) -> str:
"""Validate that the date string is a valid date in YYYY-MM-DD format."""
try:
datetime.strptime(v, "%Y-%m-%d")
except ValueError:
raise ValueError("date_of_birth must be in YYYY-MM-DD format")
return v
over_18: bool # Simple boolean confirmation instead of date_of_birth
class UserResponse(BaseModel):
@@ -118,6 +108,16 @@ def hash_password(password: str) -> str:
def calculate_age(birth_date: date, today: date | None = None) -> int:
"""Calculate age in years from a birth date.
Not called by /register anymore (that endpoint now just trusts the
client's over_18 boolean, see RegisterRequest) -- kept as a real,
tested utility since date-of-birth storage/verification is coming
back in a future pass (real age math, not just a client-side claim,
the user explicitly wants to revisit this design). Removing it broke
backend/tests/test_auth_age_gate.py's collection entirely (ImportError
on `from app.routers.auth import calculate_age`) for zero benefit --
restoring it is the lower-risk path over rewriting or deleting 17
tests for logic this codebase will need again.
Args:
birth_date: The person's date of birth.
today: The reference date. Defaults to today's date (UTC).
@@ -155,16 +155,12 @@ def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return access + refresh tokens.
Requires date_of_birth and rejects users under 18.
Requires over_18 boolean confirmation; rejects users who are not 18+.
"""
# Parse and validate date_of_birth
dob = datetime.strptime(req.date_of_birth, "%Y-%m-%d").date()
age = calculate_age(dob)
if age < 18:
if not req.over_18:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})",
detail="Must be 18 or older to register",
)
# Check if username already exists
@@ -187,7 +183,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
username=req.username,
email=req.email,
hashed_password=hash_password(req.password),
date_of_birth=dob,
date_of_birth=None, # No longer collecting DOB
)
db.add(user)
await db.flush() # flush to get user.id before creating refresh token
+29 -29
View File
@@ -57,38 +57,38 @@ class TestCalculateAge:
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
class TestRegisterRequestDateValidation:
"""Tests for the RegisterRequest date_of_birth field validator."""
class TestRegisterRequestOver18Field:
"""Tests for RegisterRequest's over_18 field.
@pytest.mark.parametrize(
"dob",
[
"2000-01-01",
"1990-12-31",
"2004-02-29", # leap day
],
)
def test_valid_dates(self, dob):
"""Valid YYYY-MM-DD dates pass validation."""
Replaces TestRegisterRequestDateValidation -- date_of_birth and its
field_validator were removed from RegisterRequest 2026-08-20 (interim
simplification: a plain client-side over_18 confirmation instead of
collecting/validating an actual date of birth; see calculate_age's
own docstring in app/routers/auth.py for why that function is still
here even though this endpoint no longer calls it). There's no date
format left to validate, so the equivalent coverage is just: the
field exists, is a real bool, and is required."""
def test_over_18_true_accepted(self):
from app.routers.auth import RegisterRequest
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
assert req.date_of_birth == dob
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=True)
assert req.over_18 is True
@pytest.mark.parametrize(
"dob",
[
"01-01-2000",
"2000/01/01",
"2000-13-01",
"2000-00-10",
"not-a-date",
"",
],
)
def test_invalid_dates_rejected(self, dob):
"""Invalid dates are rejected by the validator."""
def test_over_18_false_accepted(self):
"""False is a valid value at the schema level -- the endpoint,
not the schema, is what rejects it (see
TestRegister::test_register_rejects_not_over_18 in
test_auth_endpoints.py)."""
from app.routers.auth import RegisterRequest
with pytest.raises(ValueError):
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=False)
assert req.over_18 is False
def test_over_18_missing_rejected(self):
"""over_18 has no default -- omitting it entirely is a validation error."""
from app.routers.auth import RegisterRequest
from pydantic import ValidationError
with pytest.raises(ValidationError):
RegisterRequest(username="u", email="u@example.com", password="p")
+22 -26
View File
@@ -73,7 +73,7 @@ class TestRegister:
"username": "newuser",
"email": "new@example.com",
"password": "secret123",
"date_of_birth": "2000-01-01",
"over_18": True,
},
)
assert resp.status_code == 201
@@ -81,8 +81,14 @@ class TestRegister:
assert "access_token" in data
assert data["token_type"] == "bearer"
def test_register_rejects_underage(self):
"""Users under 18 get a 400 with a detail message."""
def test_register_rejects_not_over_18(self):
"""over_18=false gets a 400 with a detail message.
The interim design (2026-08-20) trusts a client-side boolean
confirmation instead of collecting/validating an actual date of
birth -- this only checks that the flag is respected, not real
age math (there's no date to verify against anymore). Real DOB
collection/verification may come back in a future pass."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
@@ -90,7 +96,7 @@ class TestRegister:
"username": "kid",
"email": "kid@example.com",
"password": "secret123",
"date_of_birth": "2010-06-15", # ~15 years old
"over_18": False,
},
)
assert resp.status_code == 400
@@ -106,7 +112,7 @@ class TestRegister:
"username": "duplicate",
"email": "dup1@example.com",
"password": "secret123",
"date_of_birth": "2000-01-01",
"over_18": True,
},
)
# Second registration with same username, different email
@@ -116,7 +122,7 @@ class TestRegister:
"username": "duplicate",
"email": "dup2@example.com",
"password": "secret123",
"date_of_birth": "2000-01-01",
"over_18": True,
},
)
assert resp.status_code == 409
@@ -131,7 +137,7 @@ class TestRegister:
"username": "user1",
"email": "same@example.com",
"password": "secret123",
"date_of_birth": "2000-01-01",
"over_18": True,
},
)
resp = client.post(
@@ -140,14 +146,19 @@ class TestRegister:
"username": "user2",
"email": "same@example.com",
"password": "secret123",
"date_of_birth": "2000-01-01",
"over_18": True,
},
)
assert resp.status_code == 409
assert "Email already registered" in resp.json()["detail"]
def test_register_invalid_date_format(self):
"""Invalid date format returns 422 validation error."""
def test_register_missing_over_18(self):
"""Missing the required over_18 field returns 422.
Replaces the old test_register_invalid_date_format -- there's no
date format to get wrong anymore (no date field at all), so the
closest equivalent "malformed request" case is the required
over_18 boolean being absent entirely."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
@@ -155,25 +166,10 @@ class TestRegister:
"username": "user",
"email": "user@example.com",
"password": "secret123",
"date_of_birth": "01-01-2000", # wrong format
},
)
assert resp.status_code == 422
def test_register_exactly_18(self):
"""A user who just turned 18 can register."""
client = TestClient(app)
resp = client.post(
"/api/auth/register",
json={
"username": "just18",
"email": "just18@example.com",
"password": "secret123",
"date_of_birth": "2007-06-15",
},
)
assert resp.status_code == 201
# ---------------------------------------------------------------------------
# Login endpoint
@@ -193,7 +189,7 @@ class TestLogin:
"username": username,
"email": email,
"password": password,
"date_of_birth": "2000-01-01",
"over_18": True,
},
)
assert resp.status_code == 201
+1 -1
View File
@@ -93,7 +93,7 @@ def _register_user(client: TestClient, username: str) -> str:
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "2000-01-01",
"over_18": True,
},
)
assert resp.status_code == 201
+2 -2
View File
@@ -59,7 +59,7 @@ class TestRefresh:
"username": username,
"email": email,
"password": password,
"date_of_birth": "2000-01-01",
"over_18": True,
},
)
assert resp.status_code == 201
@@ -143,7 +143,7 @@ class TestRefresh:
"username": "refep_loginrefuser",
"email": "loginref@example.com",
"password": "secret123",
"date_of_birth": "2000-01-01",
"over_18": True,
},
)
# Login to get a refresh token
+194
View File
@@ -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 |
+19 -31
View File
@@ -37,45 +37,33 @@ the real scope of PC1; don't expect more when playtesting.
## Manual playtest: backend on cloudbison, client on pinkbison
Cloudbison's LAN IP: `192.168.58.138`. Pinkbison's: `192.168.58.124`.
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 — start a real (persistent, not throwaway) backend
### 1. On cloudbison — the AGP dashboard
```bash
cd ~
git clone http://localhost:3000/daddy/agp-game.git agp-game-playtest
cd agp-game-playtest/backend
docker compose up -d postgres
docker compose exec -T postgres pg_isready -U agp_user -d agp_db # wait for this to succeed
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).
export DATABASE_URL="postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db"
export JWT_ALGORITHM="RS256"
/home/daddy/agp/venv/bin/python3 -m alembic upgrade head
nohup /home/daddy/agp/venv/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/agp-backend.log 2>&1 &
disown
```
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.
`--host 0.0.0.0` matters — the default `127.0.0.1` only listens on loopback
and pinkbison won't be able to reach it. Verify: `curl
http://localhost:8000/api/health` should return `{"status":"ok",...}`.
### 2. On pinkbison — download and run the .exe
### 2. On pinkbison — get the game and point it at cloudbison
Copy `export/windows/agp-game.exe` (or `export/linux/agp-game.x86_64`,
whichever matches pinkbison's OS) from cloudbison, or clone the repo there
and run `scripts/build.sh` locally. Launch with the backend URL as an env
var so you don't have to rebuild to change it:
- Windows (PowerShell): `$env:AGP_API_BASE_URL="http://192.168.58.138:8000"; .\agp-game.exe`
- Linux: `AGP_API_BASE_URL=http://192.168.58.138:8000 ./agp-game.x86_64`
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
```bash
# on cloudbison
pkill -f "uvicorn app.main:app"
cd ~/agp-game-playtest/backend && docker compose down -v
```
**Stop & Clean Up** on the dashboard page. That's it.
## Status
+75 -47
View File
@@ -7,72 +7,100 @@ 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-19 (commit `1c75367`).
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.
## Backend — `backend/tests/` (pytest)
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
reachable Postgres — see the runner script; most of these tests don't
actually touch the DB, but the app's import chain requires a valid URL to
construct).
reachable Postgres — see the runner script).
**Current total: 38 tests, all passing on a clean `main` checkout.**
**Current total: 61 tests, all passing on a clean `main` checkout.**
| File | Tests | What it covers |
|---|---|---|
| `test_auth_age_gate.py` | 10 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year) + `RegisterRequest` date-of-birth validation (parametrized valid/invalid date rejection). Backs the 18+ registration gate (PR #23). |
| `test_auth_age_gate.py` | 11 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year — kept as a tested utility for a future real-DOB pass even though `/register` no longer calls it) + `RegisterRequest.over_18` field tests (true accepted, false accepted, missing rejected). 2026-08-20: the register flow was simplified from collecting/validating a date of birth to a plain client-side "I am 18 or older" confirmation, so the old date-validation parametrized cases were replaced. Backs the 18+ registration gate (PR #23, revised PC1). |
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). |
| `test_auth_endpoints.py` | 10 | **Integration tests** for `POST /api/auth/register` and `POST /api/auth/login` against the real FastAPI app + real Postgres. Covers: successful register (201), duplicate username (409), duplicate email (409), `over_18=false` rejection (400), missing `over_18` field (422), successful login with correct credentials (200), wrong password (401), nonexistent user (401), form-encoded login (OAuth2), and JWT validity of returned tokens. |
| `test_refresh_endpoint.py` | 8 | **Integration tests** for `POST /api/auth/refresh`. Covers: successful refresh (200, new access token, same refresh token returned), refresh produces different access tokens on repeated calls, invalid refresh token (401), empty body (422), missing field (422), refresh token from login also works, returned access token is valid JWT decodable with public key, `access_token_expires_minutes` in response. |
| `test_refresh_token_jti.py` | 6 | Unit tests for refresh token uniqueness (jti claim) and model column length. Covers: two calls with same sub produce different tokens (jti uniqueness), jti is valid UUID, rapid repeated calls all produce unique jtis, jti survives decode, `RefreshToken.token` column is `String(512)`, column type is `String`. |
| `test_auth_expired_token.py` | 5 | **Integration tests** for expired-token rejection across auth endpoints. Covers: expired access JWT rejected by `GET /api/auth/me` (401), garbage access token rejected by `/me` (401), missing bearer token returns 401, expired refresh token (DB `expires_at` in past) rejected by `/refresh` (401), expired refresh JWT (past `exp` claim) rejected before DB lookup (401). |
**What these are, honestly:** pure unit tests of standalone functions
(`calculate_age`, the glTF parser, Pydantic validators). None of them spin
up the FastAPI app or hit a real endpoint — they run in ~1s and don't
actually need Postgres reachable at all (the import chain just needs a
syntactically valid `DATABASE_URL`, nothing connects during collection).
**What these are, honestly:** a mix of pure unit tests (`test_auth_age_gate.py`,
`test_gltf_metadata.py`, `test_refresh_token_jti.py`) and real integration tests
(`test_auth_endpoints.py`, `test_refresh_endpoint.py`, `test_auth_expired_token.py`)
that hit the FastAPI app against a real Postgres database via `TestClient`.
The integration tests exercise the full stack: Pydantic validation → DB →
JWT → response. The suite is also re-runnable without manual cleanup —
`backend/tests/conftest.py` has a session-scoped autouse fixture that
truncates all app tables once before the suite runs.
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
these yet):
- No integration/endpoint tests at all — `/register`, `/login`, `/refresh`,
`/worlds`, `/assets/*` have zero tests that actually call them through
the FastAPI app (e.g. via `httpx.AsyncClient` + `ASGITransport`) against a
real (or test) database. Everything currently shipped works because it
was manually verified at merge time (checkpoint review), not because a
test asserts it.
- No tests for the RS256 auth upgrade (key generation, refresh-token
issuance/persistence) — PR #30, pending review as of this writing,
manually verified (correct key usage, all 38 existing tests still pass)
but not covered by an automated test of its own yet.
- No tests for the moderation schema/migration (in progress as of this
writing) beyond "the migration runs."
- No endpoint tests for `GET /api/worlds`, `GET /api/assets/*`, or any
world/asset CRUD routes — only auth endpoints have integration coverage
(`GET /api/worlds` is covered indirectly via the Godot smoke tests below).
- No tests for the moderation schema/migration beyond "the migration runs."
- No tests for the `GET /api/auth/me` endpoint's response body (only its
auth-header validation is covered via expired-token tests).
- No tests for session management (create/validate/revoke sessions).
## Godot client — `scripts/smoke_test_*.gd`
**Nothing merged into `main` yet as of this writing** — `sandbox_probe/test_script.gd`
is the only Godot script on `main` right now, and it's **not a real
test** (a leftover manual probe, returns a hardcoded string, asserts
nothing). Don't count it toward coverage; delete or repurpose it if anyone
actually needs a sandbox probe again.
**7 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.
`scripts/smoke_test_api.gd` (PC1, PR #29, pending review as of this writing)
will be the first real one once merged — `ApiClient` autoload's
`health_check()` against a **live backend** (set `AGP_API_BASE_URL` to point
at one), verified by hand end-to-end before opening the PR: exercises the
real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, the
result-dictionary contract), not just that the script compiles. Exits 1 on
failure, so it's CI/script-friendly.
| File | What it covers |
|---|---|
| `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), `over_18=false` 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_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. |
| `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 merged): `godot --headless --path . --import` once (builds the
class cache — skip this and every `class_name`-declared class falsely
errors as undeclared), then for each `scripts/smoke_test_*.gd`:
`godot --headless --path . --script res://scripts/<name>.gd`
Run (once, to build the class cache): `godot --headless --path . --import`,
then for each smoke test: `godot --headless --path . --script
res://scripts/smoke_test_<name>.gd` — **no `--quit`**, it kills these before
their awaited HTTP calls finish.
**Known gap once it lands:** only `health_check()` is exercised end-to-end.
`login`, `register`, `get_worlds`, `get_me` are implemented (see
`scripts/api_client.gd`) but have no smoke coverage yet — each would need a
real user/world fixture in the target backend to test against meaningfully.
**Known gaps:**
- No smoke tests for chunk loading/streaming (`chunk.gd`, `chunk_scene.gd`).
- No smoke tests for dynamic/static object nodes.
- All smoke tests require a live backend; no offline/unit smoke tests exist.
- `smoke_test_world_list.gd` and `smoke_test_main_menu_*.gd` are the only
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
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
`scripts/build.sh` produces real Linux (`agp-game.x86_64`) and Windows
(`agp-game.exe`) binaries via `--export-release`. Verified 2026-08-20: both
exports complete clean from a fresh checkout and produce valid binaries
(confirmed via `file` — real ELF64/PE32+, not empty or truncated). Neither
export is exercised by an automated GUI test (headless Godot can't drive a
window) — actually launching and clicking through the exported binary is a
manual step, see `docs/checkpoints/PC1.md` for playtest instructions.
## What "full coverage" should eventually mean here
Not defined precisely yet — the honest target as of 2026-08-19 is just
"more than pure-function unit tests." As real integration/endpoint tests get
added, update the tables above rather than letting this doc drift stale;
a wrong inventory is worse than no inventory.
Not defined precisely yet — the honest target as of 2026-08-20 is
"integration tests for every endpoint + smoke tests for every major scene."
As real integration/endpoint tests get added, update the tables above
rather than letting this doc drift stale; a wrong inventory is worse than
no inventory.
+2 -2
View File
@@ -21,7 +21,7 @@ script_export_mode=2
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=false
binary_format/embed_pck=true
texture_format/bptc=true
texture_format/s3tc=true
texture_format/etc=false
@@ -52,7 +52,7 @@ script_export_mode=2
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=false
binary_format/embed_pck=true
texture_format/bptc=true
texture_format/s3tc=true
texture_format/etc=false
+19 -7
View File
@@ -29,6 +29,21 @@ text = "AGP Game"
horizontal_alignment = 1
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"]
layout_mode = 1
visible = false
@@ -68,16 +83,12 @@ visible = false
placeholder_text = "Email"
size_flags_horizontal = 3
[node name="DOBEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
[node name="Over18Check" type="CheckBox" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
visible = false
placeholder_text = "Date of Birth (YYYY-MM-DD)"
text = "I am 18 or older"
size_flags_horizontal = 3
[node name="ModeToggle" type="CheckButton" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
text = "Register"
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1
text = "Login"
@@ -106,9 +117,10 @@ layout_mode = 1
text = "Quit"
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/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="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit" to="." method="_on_auth_button_pressed"]
+11 -9
View File
@@ -11,14 +11,17 @@ grow_vertical = 2
script = ExtResource("1_wl")
[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
size_flags_horizontal = 4
size_flags_vertical = 4
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 3
size_flags_horizontal = 4
size_flags_horizontal = 3
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
@@ -30,7 +33,7 @@ size_flags_horizontal = 0
layout_mode = 3
text = "Browse Worlds"
horizontal_alignment = 1
size_flags_horizontal = 4
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 28
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
@@ -40,15 +43,14 @@ size_flags_horizontal = 0
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
layout_mode = 3
size_flags_horizontal = 4
size_flags_vertical = 4
size_flags_horizontal = 3
size_flags_vertical = 3
horizontal_mode = 2
vertical_mode = 2
[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
layout_mode = 3
size_flags_horizontal = 4
size_flags_vertical = 4
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="LoadingLabel" type="Label" parent="VBoxContainer"]
+3 -3
View File
@@ -178,18 +178,18 @@ func login(username: String, password: String) -> Dictionary:
## Register a new user. On success stores the returned access token.
## @param date_of_birth ISO date string "YYYY-MM-DD".
## @param over_18 Must be true; the backend rejects registration otherwise.
func register(
username: String,
email: String,
password: String,
date_of_birth: String
over_18: bool
) -> Dictionary:
var payload := {
"username": username,
"email": email,
"password": password,
"date_of_birth": date_of_birth,
"over_18": over_18,
}
var result := await request(
HTTPClient.METHOD_POST,
+115 -65
View File
@@ -1,24 +1,41 @@
extends Control
## 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 existing Play/Quit buttons), the auth panel slides in. The ModeToggle
## switches between Login and Register modes, showing/hiding the email and DOB
## fields accordingly.
## The AuthPanel is hidden by default. When the user clicks the LoginButton or
## RegisterButton (AuthButtonsRow, above the panel), the auth panel slides in.
## 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
## the panel is open.
##
## On successful auth the token is stored via ApiClient, the welcome label
## shows the username, and the Play button becomes enabled.
## 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.
# ── 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_mode_label: Label
var _username_edit: LineEdit
var _password_edit: LineEdit
var _email_edit: LineEdit
var _dob_edit: LineEdit
var _mode_toggle: CheckButton
var _over_18_check: CheckBox
var _auth_button: Button
var _status_label: Label
var _welcome_label: Label
@@ -29,34 +46,93 @@ var _is_register_mode: bool = false
var _is_authenticating: bool = false
# ── Lifecycle ────────────────────────────────────────────────────────────────
# ─── Lifecycle ────────────────────────────────────────────────────────────────
func _ready() -> void:
# 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_mode_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit
_mode_toggle = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle
_over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
_play_button = $CenterContainer/VBoxContainer/PlayButton
_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
_status_label.visible = false
_welcome_label.visible = false
_email_edit.visible = false
_dob_edit.visible = false
_over_18_check.visible = false
_over_18_check.button_pressed = false
_play_button.disabled = true # Gate play behind successful auth.
_update_auth_mode_ui()
# ── Play / Quit ──────────────────────────────────────────────────────────────
# ─── Play / Quit ──────────────────────────────────────────────────────────────
func _on_play_button_pressed() -> void:
if _play_button.disabled:
@@ -70,29 +146,24 @@ func _on_quit_button_pressed() -> void:
get_tree().quit()
# ── Auth panel toggle ────────────────────────────────────────────────────────
# ─── Auth panel toggle ────────────────────────────────────────────────────────
func _on_login_button_pressed() -> void:
"""Show the auth panel (called from a future Login button on the main menu)."""
"""Show the auth panel in login mode, called from AuthButtonsRow/LoginButton."""
_auth_panel.visible = true
_auth_panel.grab_focus()
_is_register_mode = false
_update_auth_mode_ui()
_clear_status()
_username_edit.grab_focus()
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
_is_register_mode = true
_mode_toggle.button_pressed = true
_update_auth_mode_ui()
_clear_status()
# ── Mode toggle (Login / Register) ───────────────────────────────────────────
func _on_mode_toggle_toggled(button_pressed: bool) -> void:
_is_register_mode = button_pressed
_update_auth_mode_ui()
_clear_status()
_username_edit.grab_focus()
func _update_auth_mode_ui() -> void:
@@ -101,15 +172,15 @@ func _update_auth_mode_ui() -> void:
_auth_mode_label.text = "Register"
_auth_button.text = "Register"
_email_edit.visible = true
_dob_edit.visible = true
_over_18_check.visible = true
else:
_auth_mode_label.text = "Login"
_auth_button.text = "Login"
_email_edit.visible = false
_dob_edit.visible = false
_over_18_check.visible = false
# ── Auth submission ──────────────────────────────────────────────────────────
# ─── Auth submission ──────────────────────────────────────────────────────────
func _on_auth_button_pressed() -> void:
"""Handle login or register button press."""
@@ -129,17 +200,12 @@ func _on_auth_button_pressed() -> void:
if _is_register_mode:
var email := _email_edit.text.strip_edges()
var dob := _dob_edit.text.strip_edges()
if email == "":
_show_status("Email is required.")
return
if dob == "":
_show_status("Date of birth is required.")
return
# Basic YYYY-MM-DD format check.
if not _is_valid_date(dob):
_show_status("Date of birth must be in YYYY-MM-DD format.")
if not _over_18_check.button_pressed:
_show_status("You must confirm you are 18 or older to register.")
return
_auth_button.disabled = true
@@ -147,7 +213,7 @@ func _on_auth_button_pressed() -> void:
_is_authenticating = true
_clear_status()
var result := await ApiClient.register(username, email, password, dob)
var result := await ApiClient.register(username, email, password, true)
_on_auth_complete(result)
else:
_auth_button.disabled = true
@@ -166,18 +232,25 @@ func _on_auth_complete(result: Dictionary) -> void:
_is_authenticating = false
if result.ok:
# Success — show welcome, enable Play.
# Success — hide the auth form fields, show only the welcome label.
_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
_status_label.visible = false
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
_welcome_label.visible = true
_status_label.visible = false
_play_button.disabled = false
_auth_buttons_row.visible = false
print("Auth successful. Token stored.")
else:
# Failure — show backend error detail.
_show_status(result.detail)
# ── Status helpers ───────────────────────────────────────────────────────────
# ─── Status helpers ───────────────────────────────────────────────────────────
func _show_status(message: String) -> void:
_status_label.text = message
@@ -187,26 +260,3 @@ func _show_status(message: String) -> void:
func _clear_status() -> void:
_status_label.text = ""
_status_label.visible = false
# ── Validation ───────────────────────────────────────────────────────────────
func _is_valid_date(date_str: String) -> bool:
"""Check if a string looks like a valid YYYY-MM-DD date."""
if date_str.length() != 10:
return false
var parts := date_str.split("-")
if parts.size() != 3:
return false
var year := parts[0].to_int()
var month := parts[1].to_int()
var day := parts[2].to_int()
if month < 1 or month > 12:
return false
if day < 1 or day > 31:
return false
# Rough month-day validation.
var days_in_month := [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
if day > days_in_month[month]:
return false
return true
+11 -12
View File
@@ -10,7 +10,7 @@ extends SceneTree
## Tests:
## 1. Register a unique user → 201 + token stored.
## 2. Register the same user again → 409 "Username already registered".
## 3. Register an under-18 user → 400 "Must be 18 or older".
## 3. Register with over_18=false → 400 "Must be 18 or older".
## 4. Login with correct credentials → 200 + token stored.
## 5. Login with wrong password → 401 "Incorrect username or password".
## 6. Login with non-existent user → 401 "Incorrect username or password".
@@ -21,8 +21,6 @@ const API_BASE := "http://127.0.0.1:8000"
const TEST_USERNAME := "smoke_test_user_%d"
const TEST_EMAIL := "smoke_%d@test.example.com"
const TEST_PASSWORD := "SmokeTest123!"
const TEST_DOB_ADULT := "2000-01-01"
const TEST_DOB_CHILD := "2020-01-01" # ~5 years old
func _initialize() -> void:
@@ -40,12 +38,13 @@ func _run() -> void:
var failed: int = 0
# --- Test 1: Register a unique user ---
var unique_id := Time.get_ticks_msec()
randomize()
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
var username := "smoke_%d" % unique_id
var email := "smoke_%d@test.example.com" % unique_id
print("\n=== Test 1: Register unique user ===")
var result := await api.register(username, email, TEST_PASSWORD, TEST_DOB_ADULT)
var result := await api.register(username, email, TEST_PASSWORD, true)
if _check("register returns ok", result.ok) \
and _check("register status is 201", result.status == 201) \
and _check("register stores token", api.has_token()):
@@ -56,7 +55,7 @@ func _run() -> void:
# --- Test 2: Register same user again (duplicate) ---
print("\n=== Test 2: Duplicate username ===")
result = await api.register(username, email, TEST_PASSWORD, TEST_DOB_ADULT)
result = await api.register(username, email, TEST_PASSWORD, true)
if _check("duplicate register not ok", not result.ok) \
and _check("duplicate status is 409", result.status == 409) \
and _check("duplicate detail mentions username",
@@ -66,14 +65,14 @@ func _run() -> void:
failed += 1
print(" detail: ", result.detail)
# --- Test 3: Register under-18 user ---
print("\n=== Test 3: Under-18 registration ===")
# --- Test 3: Register with over_18=false ---
print("\n=== Test 3: over_18=false registration ===")
var child_username := "child_%d" % unique_id
var child_email := "child_%d@test.example.com" % unique_id
result = await api.register(child_username, child_email, TEST_PASSWORD, TEST_DOB_CHILD)
if _check("under-18 register not ok", not result.ok) \
and _check("under-18 status is 400", result.status == 400) \
and _check("under-18 detail mentions age",
result = await api.register(child_username, child_email, TEST_PASSWORD, false)
if _check("over_18=false register not ok", not result.ok) \
and _check("over_18=false status is 400", result.status == 400) \
and _check("over_18=false detail mentions age",
"18" in result.detail):
passed += 1
else:
+1 -2
View File
@@ -23,7 +23,6 @@ extends SceneTree
const TEST_USERNAME_BASE := "smoke_test_user"
const TEST_EMAIL := "smoke@test.com"
const TEST_PASSWORD := "TestPass123"
const TEST_DOB := "2000-01-01"
func _initialize() -> void:
@@ -70,7 +69,7 @@ func _run() -> void:
# --- Step 2: Register -----------------------------------------------------
print("\n=== Step 2: Register (POST /api/auth/register) ===")
var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, TEST_DOB)
var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, true)
var reg_token := _extract_token(reg)
var reg_ok: bool = (
reg.ok
+170
View File
@@ -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)
+86
View File
@@ -0,0 +1,86 @@
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. Also verifies that after successful auth the auth
## form fields are hidden, leaving only the welcome message visible.
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 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_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 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
over_18_check.button_pressed = true
# 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)
# 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])
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
+126
View File
@@ -0,0 +1,126 @@
extends SceneTree
## Verifies the main menu's Login/Register buttons correctly open the auth
## panel and toggle between Login and Register modes via signal emission.
##
## Key tests:
## - AuthPanel starts hidden.
## - LoginButton press opens the panel in Login mode.
## - RegisterButton press opens the panel in Register mode.
## - Clicking Login while in Register mode switches back to Login mode.
## - Clicking Register while in Login mode switches to Register mode.
## - The ModeToggle CheckButton no longer exists in the scene.
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")
var auth_mode_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
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")
# --- Test 1: auth panel starts hidden ---
if _check("auth panel starts hidden", not auth_panel.visible):
passed += 1
else:
failed += 1
# --- Test 2: LoginButton press opens panel in Login mode ---
login_button.pressed.emit()
await process_frame
if _check("auth panel visible after LoginButton press", auth_panel.visible):
passed += 1
else:
failed += 1
if _check("Login mode label after LoginButton press", auth_mode_label.text == "Login"):
passed += 1
else:
failed += 1
if _check("Email field hidden in Login mode", not email_edit.visible):
passed += 1
else:
failed += 1
if _check("Over18 checkbox hidden in Login mode", not over_18_check.visible):
passed += 1
else:
failed += 1
# --- Test 3: RegisterButton press switches to Register mode ---
register_button.pressed.emit()
await process_frame
if _check("auth panel still visible after RegisterButton press", auth_panel.visible):
passed += 1
else:
failed += 1
if _check("Register mode label after RegisterButton press", auth_mode_label.text == "Register"):
passed += 1
else:
failed += 1
if _check("Email field visible in Register mode", email_edit.visible):
passed += 1
else:
failed += 1
if _check("Over18 checkbox visible in Register mode", over_18_check.visible):
passed += 1
else:
failed += 1
# --- Test 4: LoginButton press switches back to Login mode ---
login_button.pressed.emit()
await process_frame
if _check("Login mode label after second LoginButton press", auth_mode_label.text == "Login"):
passed += 1
else:
failed += 1
if _check("Email field hidden after switching back to Login", not email_edit.visible):
passed += 1
else:
failed += 1
if _check("Over18 checkbox hidden after switching back to Login", not over_18_check.visible):
passed += 1
else:
failed += 1
# --- Test 5: ModeToggle CheckButton no longer exists ---
var mode_toggle_path = "CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle"
var has_mode_toggle = instance.has_node(mode_toggle_path)
if _check("ModeToggle CheckButton removed from scene", not has_mode_toggle):
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
+54 -8
View File
@@ -16,15 +16,27 @@ extends SceneTree
##
## Tests:
## 1. Register + log in a fresh user (so get_worlds() has a valid token).
## 2. Instantiate world_list.tscn, add to tree -- @onready resolution
## 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.
## 3. Wait for _refresh_worlds()'s awaited request to complete.
## 4. Confirm the screen reached a real end state (world cards, or the
## empty-list message -- either is fine, a crash/stuck-loading isn't).
## 4. Wait for _refresh_worlds()'s awaited request to complete.
## 5. Confirm it actually reached the populated (not empty) state, and
## 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.
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:
@@ -46,12 +58,25 @@ func _run() -> void:
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
var username := "worldlist_smoke_%d" % unique_id
var email := "worldlist_smoke_%d@test.example.com" % unique_id
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", "2000-01-01")
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)
quit(1)
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 ---
print("\n=== Test 1: Instantiate world_list.tscn ===")
var packed_scene: PackedScene = load(WORLD_LIST_SCENE)
@@ -99,10 +124,31 @@ func _run() -> void:
if error_label.visible:
printerr(" Note: ended in error state: ", error_label.text)
elif world_list_node.visible:
print(" Note: rendered %d world card(s)" % world_list_node.get_child_count())
elif empty_label.visible:
print(" Note: ended in empty-list state")
# 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 ---
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.
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()
card.size_flags_horizontal = Control.SIZE_FILL
card.custom_minimum_size = Vector2(0, 80)
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
card.add_theme_constant_override("separation", 4)
panel.add_child(card)
var name_label := Label.new()
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(meta_label)
# Subtle background for the card
var bg := ColorRect.new()
bg.color = Color(0.15, 0.15, 0.15, 1.0)
bg.size_flags_horizontal = Control.SIZE_FILL
bg.size_flags_vertical = Control.SIZE_FILL
bg.custom_minimum_size = Vector2(0, 80)
card.add_child(bg)
card.move_child(bg, 0)
return card
return panel