Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e51c4d80b8 | |||
| 00cdab0452 | |||
| c287202b9d | |||
| c01f9de586 | |||
| 0505cd7ff1 |
+20
-16
@@ -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
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -39,7 +39,17 @@ class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
over_18: bool # Simple boolean confirmation instead of date_of_birth
|
||||
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
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
@@ -108,16 +118,6 @@ 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,12 +155,16 @@ 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 over_18 boolean confirmation; rejects users who are not 18+.
|
||||
Requires date_of_birth and rejects users under 18.
|
||||
"""
|
||||
if not req.over_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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Must be 18 or older to register",
|
||||
detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})",
|
||||
)
|
||||
|
||||
# Check if username already exists
|
||||
@@ -183,7 +187,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=None, # No longer collecting DOB
|
||||
date_of_birth=dob,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush() # flush to get user.id before creating refresh token
|
||||
|
||||
@@ -57,38 +57,38 @@ class TestCalculateAge:
|
||||
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
|
||||
|
||||
|
||||
class TestRegisterRequestOver18Field:
|
||||
"""Tests for RegisterRequest's over_18 field.
|
||||
class TestRegisterRequestDateValidation:
|
||||
"""Tests for the RegisterRequest date_of_birth field validator."""
|
||||
|
||||
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):
|
||||
@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."""
|
||||
from app.routers.auth import RegisterRequest
|
||||
|
||||
req = RegisterRequest(username="u", email="u@example.com", password="p", over_18=True)
|
||||
assert req.over_18 is True
|
||||
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
|
||||
assert req.date_of_birth == dob
|
||||
|
||||
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)."""
|
||||
@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."""
|
||||
from app.routers.auth import RegisterRequest
|
||||
|
||||
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")
|
||||
with pytest.raises(ValueError):
|
||||
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
|
||||
|
||||
@@ -73,7 +73,7 @@ class TestRegister:
|
||||
"username": "newuser",
|
||||
"email": "new@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
@@ -81,14 +81,8 @@ class TestRegister:
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
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."""
|
||||
def test_register_rejects_underage(self):
|
||||
"""Users under 18 get a 400 with a detail message."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
@@ -96,7 +90,7 @@ class TestRegister:
|
||||
"username": "kid",
|
||||
"email": "kid@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": False,
|
||||
"date_of_birth": "2010-06-15", # ~15 years old
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
@@ -112,7 +106,7 @@ class TestRegister:
|
||||
"username": "duplicate",
|
||||
"email": "dup1@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
# Second registration with same username, different email
|
||||
@@ -122,7 +116,7 @@ class TestRegister:
|
||||
"username": "duplicate",
|
||||
"email": "dup2@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
@@ -137,7 +131,7 @@ class TestRegister:
|
||||
"username": "user1",
|
||||
"email": "same@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
resp = client.post(
|
||||
@@ -146,19 +140,14 @@ class TestRegister:
|
||||
"username": "user2",
|
||||
"email": "same@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert "Email already registered" in resp.json()["detail"]
|
||||
|
||||
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."""
|
||||
def test_register_invalid_date_format(self):
|
||||
"""Invalid date format returns 422 validation error."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
@@ -166,10 +155,25 @@ 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
|
||||
@@ -189,7 +193,7 @@ class TestLogin:
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"over_18": True,
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
@@ -93,7 +93,7 @@ def _register_user(client: TestClient, username: str) -> str:
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
@@ -59,7 +59,7 @@ class TestRefresh:
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"over_18": True,
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
@@ -143,7 +143,7 @@ class TestRefresh:
|
||||
"username": "refep_loginrefuser",
|
||||
"email": "loginref@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
"date_of_birth": "2000-01-01",
|
||||
},
|
||||
)
|
||||
# Login to get a refresh token
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
# PC2 Test Level & Player Spawn — Design Note
|
||||
|
||||
> **Status:** Decided (PC2)
|
||||
> **Owner:** Architecture
|
||||
> **Consumers:** the two PC2 implementation tasks — (1) build the test level,
|
||||
> (2) build the player & spawn it. Both must be able to follow this note
|
||||
> without further design decisions.
|
||||
|
||||
## 1. Decision: static scene, not chunk streaming
|
||||
|
||||
**The PC2 playable test level is a single, hand-authored, static Godot 3D
|
||||
scene.** It is **not** built through the chunk streaming system
|
||||
(`scripts/chunk_stream_manager.gd`, `chunk_scene.gd`, `chunk.gd`,
|
||||
`chunk_data_decoder.gd`).
|
||||
|
||||
This is a deliberate decision, not a default:
|
||||
|
||||
- **The streaming system has never been exercised with real content.** It is a
|
||||
faithful port of the `docs/architecture/world.md` format spec, but nothing in
|
||||
the project produces or serves chunk payloads for it to consume. `scenes/main.tscn`
|
||||
wires `chunk_stream_manager.gd` onto a bare `Node3D` with no `player` reference
|
||||
and no `_server_rpc`, so at runtime it does nothing. There is no server-side
|
||||
chunk authoring/streaming endpoint to integrate against.
|
||||
- **PC2 scope is single-player, local-only, no networking.** The value of chunk
|
||||
streaming is server-authoritative streaming around a moving player across a
|
||||
network boundary. None of those preconditions exist in PC2, so exercising the
|
||||
pipeline here would mean building a chunk *authoring + serving* path first —
|
||||
clearly out of checkpoint scope.
|
||||
- **A static scene is the lowest-risk way to get "spawn a player in a real 3D
|
||||
level and walk around" working**, which is the actual PC2 outcome being tested.
|
||||
|
||||
Chunk streaming is **not removed and not broken by this decision** — it stays as
|
||||
an optional, later milestone. The static level is the PC2 proof of the *player*
|
||||
and *camera/physics* half of the problem; chunk streaming remains the later proof
|
||||
of the *world data* half.
|
||||
|
||||
## 2. Where things live
|
||||
|
||||
| Artifact | Path | Owned by |
|
||||
|---|---|---|
|
||||
| Test level scene | `scenes/worlds/test_level.tscn` | level task |
|
||||
| Player scene | `scenes/player.tscn` | player task |
|
||||
| Player controller script | `scripts/player.gd` | player task |
|
||||
| Game composition scene | `scenes/game.tscn` | player task |
|
||||
|
||||
- **`scenes/worlds/`** is the convention for all level scenes (it does not exist
|
||||
yet; the level task creates it). Future levels follow `scenes/worlds/<name>.tscn`.
|
||||
- `scenes/player.tscn` is **reusable** across levels — it must not hard-code any
|
||||
reference to `test_level`; it discovers the spawn point at runtime (see §3).
|
||||
|
||||
## 3. Player spawn convention
|
||||
|
||||
The level scene **must** contain a `Marker3D` named exactly **`PlayerSpawn`**.
|
||||
That node's world transform is the spawn point.
|
||||
|
||||
- `Marker3D` is a plain `Node3D` that shows a cross gizmo in the editor and
|
||||
carries no gameplay behavior — it is the established Godot idiom for "position
|
||||
hint." (Confirmed present in Godot 4.6.2.)
|
||||
- The marker's **position** is authoritative; its rotation is ignored by the
|
||||
player (spawn yaw is the player's concern, default forward is fine).
|
||||
- Coordinate system: **Y-up, units = meters, 1 unit = 1 m.** The marker must sit
|
||||
at least **1.0 m above the ground's top surface** (recommended `y = 1.0` where
|
||||
the ground top is `y = 0`) so the player capsule does not spawn intersecting
|
||||
the floor and get popped out by the physics solver.
|
||||
- There is exactly **one** `PlayerSpawn` per level. (If a level ever needs
|
||||
multiple spawns, that is a later convention — not PC2.)
|
||||
|
||||
**Lookup rule (implemented by `player.gd`, not by the level):**
|
||||
|
||||
```
|
||||
# in player.gd _ready()
|
||||
var spawn := get_tree().current_scene.find_child("PlayerSpawn", true, false)
|
||||
if spawn is Node3D:
|
||||
global_position = spawn.global_position
|
||||
else:
|
||||
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
||||
global_position = Vector3(0, 1.0, 0) # fallback, do not crash
|
||||
```
|
||||
|
||||
The lookup runs from the current scene root so it finds the marker whether it is
|
||||
a direct child of the level instance or nested under a `Level` wrapper. The
|
||||
fallback keeps the player runnable in a scene that forgot its marker, but a
|
||||
**missing `PlayerSpawn` is a level bug** and the level task's smoke test must fail
|
||||
on it (see §6).
|
||||
|
||||
## 4. Composition (who puts level + player together)
|
||||
|
||||
`scenes/game.tscn` is the PC2 playable entry point:
|
||||
|
||||
```
|
||||
Game (Node3D)
|
||||
├── Level (instance of res://scenes/worlds/test_level.tscn)
|
||||
└── Player (instance of res://scenes/player.tscn)
|
||||
```
|
||||
|
||||
- `Game` is a plain `Node3D` with **no script** — composition is done by scene
|
||||
instantiation, not code, so there is nothing to keep in sync.
|
||||
- The **level scene is self-contained and has zero dependency on the player** —
|
||||
it loads and is smoke-testable on its own. The level task does not instance or
|
||||
reference `player.tscn`.
|
||||
- The **player scene is self-contained and has zero dependency on any specific
|
||||
level** — it only requires that the *current scene* contains a `PlayerSpawn`.
|
||||
- Ownership of `game.tscn` goes to the **player task**, which depends on the
|
||||
level task having landed `test_level.tscn` first (chain with `depends_on`).
|
||||
|
||||
## 5. Level content requirements (level task)
|
||||
|
||||
`scenes/worlds/test_level.tscn`, root node `TestLevel` (`Node3D`). Required
|
||||
children:
|
||||
|
||||
| Node | Type | Notes |
|
||||
|---|---|---|
|
||||
| `Ground` | `StaticBody3D` | `BoxShape3D` (e.g. `40 × 1 × 40`) centered at `(0, -0.5, 0)` so the **top face is `y = 0`**; matching `MeshInstance3D` (`BoxMesh`) so it is visible and collidable. Use a `StaticBody3D` rather than a bare `PlaneMesh` so collision is explicit and reliable. |
|
||||
| `Props` | `Node3D` | 3–5 simple props as `StaticBody3D` + `MeshInstance3D` using built-in primitives only (`BoxMesh`, `CylinderMesh`, `SphereMesh`). `assets/` is empty and PC2 must not require imported content. Positioned on the ground surface; scale freely. |
|
||||
| `Sun` | `DirectionalLight3D` | Pitch it downward (~ -45°), enable shadows if the compatibility renderer allows; shadows are optional. |
|
||||
| `WorldEnvironment` | `WorldEnvironment` | Ambient light and/or a simple background color. Keep it plain — the project uses `gl_compatibility`. |
|
||||
| `PlayerSpawn` | `Marker3D` | Per §3. Recommended `(0, 1.0, 0)`. |
|
||||
|
||||
Notes:
|
||||
|
||||
- **Do not** add any `script` to the level. It is pure content. Any per-level
|
||||
behavior belongs to the player (or a future game-mode script), not the level.
|
||||
- Keep the whole level on the XZ plane around the origin; nothing off at extreme
|
||||
coordinates, no thin walls the capsule can tunnel through.
|
||||
|
||||
## 6. Player contract (player task)
|
||||
|
||||
`scenes/player.tscn`, root `Player` (`CharacterBody3D`) + `scripts/player.gd`.
|
||||
|
||||
Minimum for PC2:
|
||||
|
||||
- **Body:** `CharacterBody3D` with a `CollisionShape3D` using `CapsuleShape3D`
|
||||
(~`radius 0.3`, ~`height 1.8`) and a matching `CapsuleMesh` for visibility.
|
||||
- **Camera:** a `Camera3D` child. First-person (camera at capsule head height) is
|
||||
the lowest-risk choice; third-person is fine too but is more work.
|
||||
- **Spawn:** per §3 — read `PlayerSpawn` from the current scene, fall back
|
||||
gracefully.
|
||||
- **Movement:** gravity + `move_and_slide()` so the player stands on `Ground`.
|
||||
WASD/arrow locomotion is the hard requirement. Mouse-look is recommended but
|
||||
may be split into its own task if the controller grows.
|
||||
- **No networking, no avatar UI, no chunk code** — PC2 is local-only. Do not add
|
||||
`MultiplayerAPI`, `ChunkStreamManager`, or any avatar customization here.
|
||||
|
||||
The player must work when instanced under `Game` alongside a level; it must not
|
||||
assume it is the only node in the scene.
|
||||
|
||||
## 7. Entry point / how a human plays it
|
||||
|
||||
- `scenes/game.tscn` is the PC2 playable scene. For playtesting, load it directly
|
||||
(e.g. `godot --path . scenes/game.tscn`), or temporarily set
|
||||
`application/run/main_scene` to it.
|
||||
- **Wiring "Play → Join a world" in the existing `world_list` UI is out of scope
|
||||
for this note** and out of scope for the level/player tasks. The current menu
|
||||
flow ends at browse-worlds (PC1). If a checkpoint wants a clickable path into
|
||||
the test level, that navigation wiring is its own small task — do not fold it
|
||||
into level or player.
|
||||
|
||||
## 8. Validation (both tasks must run this before "done")
|
||||
|
||||
```sh
|
||||
# one-time, first checkout only — builds the class/import cache
|
||||
godot --headless --path . --import
|
||||
|
||||
# then, to catch parse/load errors:
|
||||
godot --headless --path . --quit
|
||||
```
|
||||
|
||||
- **Level task:** additionally load `test_level.tscn` headlessly and assert it
|
||||
(a) loads without error and (b) contains a `Marker3D` named `PlayerSpawn`
|
||||
positioned ≥ 1.0 m above the ground top. A smoke script following the existing
|
||||
`scripts/smoke_test_*.gd` pattern is the right vehicle.
|
||||
- **Player task:** a smoke test that instantiates `game.tscn`, waits a frame,
|
||||
and asserts the player's global position equals the `PlayerSpawn` position
|
||||
(within a small epsilon). This is the check that proves the spawn convention
|
||||
actually wired through — not just "the scene loads."
|
||||
|
||||
## 9. Explicitly out of scope for PC2
|
||||
|
||||
- Networking / multiplayer / prediction (single-player, local-only).
|
||||
- Chunk streaming integration and server-side chunk authoring/serving.
|
||||
- Avatar customization UI and glTF avatar loading.
|
||||
- Join-a-world navigation from the world list.
|
||||
- Any content moderation / age-gating logic in the level.
|
||||
|
||||
## 10. Summary
|
||||
|
||||
| Concern | Decision |
|
||||
|---|---|
|
||||
| Level construction | Static hand-authored Godot 3D scene (chunk streaming deferred, not removed) |
|
||||
| Level location | `scenes/worlds/test_level.tscn` |
|
||||
| Spawn convention | `Marker3D` named `PlayerSpawn`, ≥ 1.0 m above ground, looked up by the player |
|
||||
| Player | `scenes/player.tscn` (`CharacterBody3D`) + `scripts/player.gd`, reusable across levels |
|
||||
| Composition | `scenes/game.tscn` instances `Level` + `Player`; no script |
|
||||
| Entry point | Load `scenes/game.tscn` directly; UI join-wiring deferred |
|
||||
@@ -8,12 +8,11 @@ to know what "run the tests" actually checked.
|
||||
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
||||
|
||||
Last verified against `main`: 2026-08-20 — full PC1 checkpoint run,
|
||||
migrations + pytest + all 7 Godot smoke tests + both platform exports, all
|
||||
clean. Real bugs found and fixed via real playtesting this same day (not
|
||||
caught by the checkpoint suite as it stood before today): the main menu
|
||||
had no way to actually open the login/register form, the world list's
|
||||
cards rendered at ~1px wide once real data existed, and navigating Play →
|
||||
Back logged the user out despite a still-valid session. All three are now
|
||||
migrations + pytest + all 6 Godot smoke tests + both platform exports, all
|
||||
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)
|
||||
@@ -21,13 +20,13 @@ covered by the smoke tests below so they can't silently regress.
|
||||
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
||||
reachable Postgres — see the runner script).
|
||||
|
||||
**Current total: 61 tests, all passing on a clean `main` checkout.**
|
||||
**Current total: 68 tests, all passing on a clean `main` checkout.**
|
||||
|
||||
| File | Tests | What it covers |
|
||||
|---|---|---|
|
||||
| `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_auth_age_gate.py` | 17 | `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_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_auth_endpoints.py` | 11 | **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), underage rejection (400), invalid date format (422), exact-18 registration, 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). |
|
||||
@@ -53,7 +52,7 @@ these yet):
|
||||
|
||||
## Godot client — `scripts/smoke_test_*.gd`
|
||||
|
||||
**7 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
|
||||
`application/agp/api_base_url` project setting (default
|
||||
`http://127.0.0.1:8000`) if unset.
|
||||
@@ -61,12 +60,11 @@ these yet):
|
||||
| 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_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_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, to build the class cache): `godot --headless --path . --import`,
|
||||
then for each smoke test: `godot --headless --path . --script
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://game_scene_pc2"]
|
||||
|
||||
[ext_resource type="PackedScene" uid="uid://c49a36fc7db_test_level" path="res://scenes/worlds/test_level.tscn" id="1_level"]
|
||||
[ext_resource type="PackedScene" uid="uid://player_scene_001" path="res://scenes/player.tscn" id="2_player"]
|
||||
|
||||
[node name="Game" type="Node3D"]
|
||||
|
||||
[node name="Level" parent="." instance=ExtResource("1_level")]
|
||||
|
||||
[node name="Player" parent="." instance=ExtResource("2_player")]
|
||||
@@ -83,12 +83,16 @@ visible = false
|
||||
placeholder_text = "Email"
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="Over18Check" type="CheckBox" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
[node name="DOBEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
visible = false
|
||||
text = "I am 18 or older"
|
||||
placeholder_text = "Date of Birth (YYYY-MM-DD)"
|
||||
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"
|
||||
@@ -121,6 +125,7 @@ size_flags_horizontal = 4
|
||||
[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"]
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://player_scene_001"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/player.gd" id="1_player"]
|
||||
|
||||
[sub_resource type="CapsuleMesh" id="CapsuleMesh1"]
|
||||
radius = 0.35
|
||||
height = 1.6
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape1"]
|
||||
radius = 0.35
|
||||
height = 1.6
|
||||
|
||||
[node name="Player" type="CharacterBody3D"]
|
||||
script = ExtResource("1_player")
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||
shape = SubResource("CapsuleShape1")
|
||||
|
||||
[node name="CapsuleMesh" type="MeshInstance3D" parent="."]
|
||||
mesh = SubResource("CapsuleMesh1")
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0)
|
||||
current = true
|
||||
@@ -1,120 +0,0 @@
|
||||
[gd_scene load_steps=1 format=3 uid="uid://c49a36fc7db_test_level"]
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
|
||||
size = Vector3(40, 1, 40)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_ground"]
|
||||
size = Vector3(40, 1, 40)
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_prop1"]
|
||||
size = Vector3(2, 3, 2)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_prop1"]
|
||||
size = Vector3(2, 3, 2)
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_prop2"]
|
||||
size = Vector3(3, 1, 3)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_prop2"]
|
||||
size = Vector3(3, 1, 3)
|
||||
|
||||
[sub_resource type="CylinderShape3D" id="CylinderShape3D_prop3"]
|
||||
top_radius = 1.0
|
||||
height = 4.0
|
||||
|
||||
[sub_resource type="CylinderMesh" id="CylinderMesh_prop3"]
|
||||
top_radius = 1.0
|
||||
bottom_radius = 1.0
|
||||
height = 4.0
|
||||
subdivisions_top_radius = 8
|
||||
|
||||
[sub_resource type="SphereShape3D" id="SphereShape3D_prop4"]
|
||||
radius = 1.0
|
||||
|
||||
[sub_resource type="SphereMesh" id="SphereMesh_prop4"]
|
||||
radius = 1.0
|
||||
subdivisions_ring = 12
|
||||
subdivisions_height = 12
|
||||
|
||||
[sub_resource type="SphereShape3D" id="SphereShape3D_prop5"]
|
||||
radius = 0.5
|
||||
|
||||
[sub_resource type="SphereMesh" id="SphereMesh_prop5"]
|
||||
radius = 0.5
|
||||
subdivisions_ring = 8
|
||||
subdivisions_height = 8
|
||||
|
||||
[sub_resource type="Environment" id="WorldEnvironment_env"]
|
||||
background_mode = 1
|
||||
background_color = Color(0.4, 0.6, 0.8, 1)
|
||||
ambient_light_source = 1
|
||||
ambient_light_energy = 0.4
|
||||
ambient_light_sky_energy = 1.0
|
||||
|
||||
[node name="TestLevel" type="Node3D"]
|
||||
|
||||
[node name="Ground" type="StaticBody3D" parent="."]
|
||||
position = Vector3(0, -0.5, 0)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"]
|
||||
shape = SubResource("BoxShape3D_ground")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground"]
|
||||
mesh = SubResource("BoxMesh_ground")
|
||||
|
||||
[node name="Props" type="Node3D" parent="."]
|
||||
|
||||
[node name="Prop1_Box" type="StaticBody3D" parent="Props"]
|
||||
position = Vector3(8, 1.5, 8)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop1_Box"]
|
||||
shape = SubResource("BoxShape3D_prop1")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop1_Box"]
|
||||
mesh = SubResource("BoxMesh_prop1")
|
||||
|
||||
[node name="Prop2_Pedestal" type="StaticBody3D" parent="Props"]
|
||||
position = Vector3(-8, 0.5, 6)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop2_Pedestal"]
|
||||
shape = SubResource("BoxShape3D_prop2")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop2_Pedestal"]
|
||||
mesh = SubResource("BoxMesh_prop2")
|
||||
|
||||
[node name="Prop3_Column" type="StaticBody3D" parent="Props"]
|
||||
position = Vector3(6, 2.0, -8)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop3_Column"]
|
||||
shape = SubResource("CylinderShape3D_prop3")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop3_Column"]
|
||||
mesh = SubResource("CylinderMesh_prop3")
|
||||
|
||||
[node name="Prop4_Sphere" type="StaticBody3D" parent="Props"]
|
||||
position = Vector3(-6, 1.0, -6)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop4_Sphere"]
|
||||
shape = SubResource("SphereShape3D_prop4")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop4_Sphere"]
|
||||
mesh = SubResource("SphereMesh_prop4")
|
||||
|
||||
[node name="Prop5_SmallSphere" type="StaticBody3D" parent="Props"]
|
||||
position = Vector3(0, 0.5, 12)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop5_SmallSphere"]
|
||||
shape = SubResource("SphereShape3D_prop5")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop5_SmallSphere"]
|
||||
mesh = SubResource("SphereMesh_prop5")
|
||||
|
||||
[node name="Sun" type="DirectionalLight3D" parent="."]
|
||||
rotation = Vector3(-0.785398, 0, 0)
|
||||
shadow_enabled = true
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||
environment = SubResource("WorldEnvironment_env")
|
||||
|
||||
[node name="PlayerSpawn" type="Marker3D" parent="."]
|
||||
position = Vector3(0, 1.5, 0)
|
||||
@@ -178,18 +178,18 @@ func login(username: String, password: String) -> Dictionary:
|
||||
|
||||
|
||||
## Register a new user. On success stores the returned access token.
|
||||
## @param over_18 Must be true; the backend rejects registration otherwise.
|
||||
## @param date_of_birth ISO date string "YYYY-MM-DD".
|
||||
func register(
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
over_18: bool
|
||||
date_of_birth: String
|
||||
) -> Dictionary:
|
||||
var payload := {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"over_18": over_18,
|
||||
"date_of_birth": date_of_birth,
|
||||
}
|
||||
var result := await request(
|
||||
HTTPClient.METHOD_POST,
|
||||
|
||||
+62
-50
@@ -3,17 +3,15 @@ extends Control
|
||||
##
|
||||
## 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.
|
||||
## The ModeToggle switches between Login and Register modes, showing/hiding
|
||||
## the email and DOB fields accordingly.
|
||||
##
|
||||
## 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
|
||||
## UI state (Play enabled, welcome label visible) instead of forcing the user
|
||||
## back to the login form.
|
||||
|
||||
|
||||
@@ -35,7 +33,8 @@ var _auth_mode_label: Label
|
||||
var _username_edit: LineEdit
|
||||
var _password_edit: LineEdit
|
||||
var _email_edit: LineEdit
|
||||
var _over_18_check: CheckBox
|
||||
var _dob_edit: LineEdit
|
||||
var _mode_toggle: CheckButton
|
||||
var _auth_button: Button
|
||||
var _status_label: Label
|
||||
var _welcome_label: Label
|
||||
@@ -58,7 +57,8 @@ func _ready() -> void:
|
||||
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
||||
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
||||
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
|
||||
_over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check
|
||||
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit
|
||||
_mode_toggle = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle
|
||||
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
||||
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
||||
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
||||
@@ -88,32 +88,15 @@ func _ready() -> void:
|
||||
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).
|
||||
## 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
|
||||
# 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
|
||||
_dob_edit.visible = false
|
||||
_play_button.disabled = false
|
||||
_auth_buttons_row.visible = false
|
||||
print("Restored authenticated session for: ", username)
|
||||
@@ -126,13 +109,12 @@ func _reset_to_logged_out() -> void:
|
||||
_status_label.visible = false
|
||||
_welcome_label.visible = false
|
||||
_email_edit.visible = false
|
||||
_over_18_check.visible = false
|
||||
_over_18_check.button_pressed = false
|
||||
_dob_edit.visible = 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:
|
||||
@@ -146,14 +128,11 @@ 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 in login mode, called from AuthButtonsRow/LoginButton."""
|
||||
"""Show the auth panel, called from AuthButtonsRow/LoginButton."""
|
||||
_auth_panel.visible = true
|
||||
_is_register_mode = false
|
||||
_update_auth_mode_ui()
|
||||
_clear_status()
|
||||
_username_edit.grab_focus()
|
||||
|
||||
|
||||
@@ -161,26 +140,35 @@ func _on_register_button_pressed() -> void:
|
||||
"""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()
|
||||
_username_edit.grab_focus()
|
||||
|
||||
|
||||
# ─── Mode toggle (Login / Register) ──────────────────────────────────────────
|
||||
|
||||
func _on_mode_toggle_toggled(button_pressed: bool) -> void:
|
||||
_is_register_mode = button_pressed
|
||||
_update_auth_mode_ui()
|
||||
_clear_status()
|
||||
|
||||
|
||||
func _update_auth_mode_ui() -> void:
|
||||
"""Update labels and field visibility based on login vs register mode."""
|
||||
if _is_register_mode:
|
||||
_auth_mode_label.text = "Register"
|
||||
_auth_button.text = "Register"
|
||||
_email_edit.visible = true
|
||||
_over_18_check.visible = true
|
||||
_dob_edit.visible = true
|
||||
else:
|
||||
_auth_mode_label.text = "Login"
|
||||
_auth_button.text = "Login"
|
||||
_email_edit.visible = false
|
||||
_over_18_check.visible = false
|
||||
_dob_edit.visible = false
|
||||
|
||||
|
||||
# ─── Auth submission ──────────────────────────────────────────────────────────
|
||||
# ─── Auth submission ─────────────────────────────────────────────────────────
|
||||
|
||||
func _on_auth_button_pressed() -> void:
|
||||
"""Handle login or register button press."""
|
||||
@@ -200,12 +188,17 @@ 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 not _over_18_check.button_pressed:
|
||||
_show_status("You must confirm you are 18 or older to register.")
|
||||
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.")
|
||||
return
|
||||
|
||||
_auth_button.disabled = true
|
||||
@@ -213,7 +206,7 @@ func _on_auth_button_pressed() -> void:
|
||||
_is_authenticating = true
|
||||
_clear_status()
|
||||
|
||||
var result := await ApiClient.register(username, email, password, true)
|
||||
var result := await ApiClient.register(username, email, password, dob)
|
||||
_on_auth_complete(result)
|
||||
else:
|
||||
_auth_button.disabled = true
|
||||
@@ -232,16 +225,12 @@ func _on_auth_complete(result: Dictionary) -> void:
|
||||
_is_authenticating = false
|
||||
|
||||
if result.ok:
|
||||
# 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
|
||||
# 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.visible = true
|
||||
_status_label.visible = false
|
||||
_play_button.disabled = false
|
||||
_auth_buttons_row.visible = false
|
||||
print("Auth successful. Token stored.")
|
||||
@@ -250,7 +239,7 @@ func _on_auth_complete(result: Dictionary) -> void:
|
||||
_show_status(result.detail)
|
||||
|
||||
|
||||
# ─── Status helpers ───────────────────────────────────────────────────────────
|
||||
# ─── Status helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
func _show_status(message: String) -> void:
|
||||
_status_label.text = message
|
||||
@@ -260,3 +249,26 @@ 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
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
class_name Player
|
||||
extends CharacterBody3D
|
||||
## The player character controller.
|
||||
##
|
||||
## A simple CharacterBody3D with WASD/arrow locomotion, gravity, jump, and a
|
||||
## first-person Camera3D with mouse-look. Reads the spawn point from the
|
||||
## current scene's `PlayerSpawn` Marker3D (see docs/architecture/pc2_test_level.md §3).
|
||||
##
|
||||
## No networking, no chunk code, no avatar UI — PC2 is local-only.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Movement constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const WALK_SPEED: float = 5.0
|
||||
const JUMP_VELOCITY: float = 4.5
|
||||
const GRAVITY: float = 9.8
|
||||
|
||||
## Mouse-look sensitivity (radians per pixel of relative motion).
|
||||
const MOUSE_SENSITIVITY: float = 0.003
|
||||
|
||||
## Camera pitch is clamped so the player can't flip past looking straight
|
||||
## up/down (which would otherwise let mouse-look invert on itself).
|
||||
const PITCH_LIMIT: float = deg_to_rad(89.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _input_direction := Vector2.ZERO
|
||||
var _camera_pitch: float = 0.0
|
||||
|
||||
@onready var _camera: Camera3D = $Camera3D
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _ready — spawn at PlayerSpawn marker, capture the mouse for look
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
# Per §3 of pc2_test_level.md: look up PlayerSpawn from the current
|
||||
# scene root. The lookup walks the full subtree (recursive=true) so
|
||||
# it finds the marker whether it is a direct child of the level or
|
||||
# nested deeper.
|
||||
#
|
||||
# current_scene is null in headless --script mode and in any scene
|
||||
# before the main scene is fully swapped in, so guard it rather than
|
||||
# blindly dereferencing.
|
||||
var current_scene: Node = get_tree().current_scene
|
||||
var spawn: Node = null
|
||||
if current_scene != null:
|
||||
spawn = current_scene.find_child("PlayerSpawn", true, false)
|
||||
|
||||
if spawn is Node3D:
|
||||
global_position = (spawn as Node3D).global_position
|
||||
# Align player forward with spawn yaw (optional polish).
|
||||
var spawn_yaw := (spawn as Node3D).rotation.y
|
||||
rotation.y = spawn_yaw
|
||||
else:
|
||||
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
||||
global_position = Vector3(0, 1.0, 0)
|
||||
|
||||
# Capture the mouse for FPS-style look. Headless/test runs have no real
|
||||
# window to capture input for, so guard against that rather than error.
|
||||
if DisplayServer.get_name() != "headless":
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _unhandled_input — capture keyboard / mouse for movement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey:
|
||||
var key := event as InputEventKey
|
||||
if key.pressed and not key.echo:
|
||||
_update_input_direction(key)
|
||||
elif not key.pressed:
|
||||
_clear_input_direction(key)
|
||||
# Release the mouse so the player can get back to a menu/cursor.
|
||||
if key.pressed and key.keycode == KEY_ESCAPE:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
elif event is InputEventMouseButton and event.pressed:
|
||||
# Clicking back into the viewport re-captures the mouse.
|
||||
if Input.mouse_mode == Input.MOUSE_MODE_VISIBLE and DisplayServer.get_name() != "headless":
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
elif event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||
_handle_mouse_look(event as InputEventMouseMotion)
|
||||
|
||||
|
||||
func _update_input_direction(key: InputEventKey) -> void:
|
||||
match key.keycode:
|
||||
KEY_W, KEY_UP:
|
||||
_input_direction.y += 1.0
|
||||
KEY_S, KEY_DOWN:
|
||||
_input_direction.y -= 1.0
|
||||
KEY_A, KEY_LEFT:
|
||||
_input_direction.x -= 1.0
|
||||
KEY_D, KEY_RIGHT:
|
||||
_input_direction.x += 1.0
|
||||
|
||||
|
||||
func _clear_input_direction(key: InputEventKey) -> void:
|
||||
match key.keycode:
|
||||
KEY_W, KEY_UP:
|
||||
_input_direction.y -= 1.0
|
||||
KEY_S, KEY_DOWN:
|
||||
_input_direction.y += 1.0
|
||||
KEY_A, KEY_LEFT:
|
||||
_input_direction.x += 1.0
|
||||
KEY_D, KEY_RIGHT:
|
||||
_input_direction.x -= 1.0
|
||||
|
||||
|
||||
func _handle_mouse_look(event: InputEventMouseMotion) -> void:
|
||||
# Yaw rotates the whole body (so movement direction turns with the
|
||||
# look direction); pitch only tilts the camera, so the capsule itself
|
||||
# never tips over.
|
||||
rotation.y -= event.relative.x * MOUSE_SENSITIVITY
|
||||
_camera_pitch = clampf(_camera_pitch - event.relative.y * MOUSE_SENSITIVITY, -PITCH_LIMIT, PITCH_LIMIT)
|
||||
_camera.rotation.x = _camera_pitch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Physics — gravity, jump, move_and_slide
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
# Apply gravity.
|
||||
if not is_on_floor():
|
||||
velocity.y -= GRAVITY * delta
|
||||
|
||||
# Jump.
|
||||
if is_on_floor() and Input.is_action_just_pressed("ui_accept"):
|
||||
velocity.y = JUMP_VELOCITY
|
||||
|
||||
# Build movement direction from input. Godot's local "forward" is -Z, not
|
||||
# +Z, so W (input.y = +1) must use -basis.z -- using +basis.z here would
|
||||
# move the player backward when pressing forward (confirmed by reading
|
||||
# Camera3D/Node3D's own convention: a node looks down its local -Z).
|
||||
var direction := (_input_direction.x * transform.basis.x -
|
||||
_input_direction.y * transform.basis.z).normalized()
|
||||
velocity.x = direction.x * WALK_SPEED
|
||||
velocity.z = direction.z * WALK_SPEED
|
||||
|
||||
move_and_slide()
|
||||
+12
-11
@@ -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 with over_18=false → 400 "Must be 18 or older".
|
||||
## 3. Register an under-18 user → 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,6 +21,8 @@ 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:
|
||||
@@ -38,13 +40,12 @@ func _run() -> void:
|
||||
var failed: int = 0
|
||||
|
||||
# --- Test 1: Register a unique user ---
|
||||
randomize()
|
||||
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||
var unique_id := Time.get_ticks_msec()
|
||||
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, true)
|
||||
var result := await api.register(username, email, TEST_PASSWORD, TEST_DOB_ADULT)
|
||||
if _check("register returns ok", result.ok) \
|
||||
and _check("register status is 201", result.status == 201) \
|
||||
and _check("register stores token", api.has_token()):
|
||||
@@ -55,7 +56,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, true)
|
||||
result = await api.register(username, email, TEST_PASSWORD, TEST_DOB_ADULT)
|
||||
if _check("duplicate register not ok", not result.ok) \
|
||||
and _check("duplicate status is 409", result.status == 409) \
|
||||
and _check("duplicate detail mentions username",
|
||||
@@ -65,14 +66,14 @@ func _run() -> void:
|
||||
failed += 1
|
||||
print(" detail: ", result.detail)
|
||||
|
||||
# --- Test 3: Register with over_18=false ---
|
||||
print("\n=== Test 3: over_18=false registration ===")
|
||||
# --- Test 3: Register under-18 user ---
|
||||
print("\n=== Test 3: Under-18 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, 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",
|
||||
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",
|
||||
"18" in result.detail):
|
||||
passed += 1
|
||||
else:
|
||||
|
||||
@@ -23,6 +23,7 @@ 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:
|
||||
@@ -69,7 +70,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, true)
|
||||
var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, TEST_DOB)
|
||||
var reg_token := _extract_token(reg)
|
||||
var reg_ok: bool = (
|
||||
reg.ok
|
||||
|
||||
@@ -25,6 +25,7 @@ extends SceneTree
|
||||
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
|
||||
@@ -49,7 +50,7 @@ func _run() -> void:
|
||||
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)
|
||||
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()
|
||||
@@ -115,10 +116,6 @@ func _is_authenticated(menu: Control) -> bool:
|
||||
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
|
||||
@@ -128,24 +125,11 @@ func _is_authenticated(menu: Control) -> bool:
|
||||
_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
|
||||
and auth_panel.visible
|
||||
|
||||
|
||||
func _wait_for_scene_change(previous_scene: Node) -> void:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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.
|
||||
## 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
|
||||
@@ -25,9 +26,8 @@ func _run() -> void:
|
||||
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 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 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")
|
||||
|
||||
@@ -46,7 +46,7 @@ func _run() -> void:
|
||||
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
|
||||
dob_edit.text = "2000-01-01"
|
||||
|
||||
# Call the submit handler directly (awaited) rather than via
|
||||
# auth_button.pressed.emit() -- a synthetic .emit() in --script/
|
||||
@@ -63,16 +63,6 @@ func _run() -> void:
|
||||
_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)
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
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.
|
||||
## 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:
|
||||
@@ -29,87 +24,26 @@ func _run() -> void:
|
||||
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):
|
||||
if _check("auth panel visible after a real LoginButton press signal", 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 ---
|
||||
# Reset and check RegisterButton independently too.
|
||||
auth_panel.visible = false
|
||||
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):
|
||||
if _check("auth panel visible after a real RegisterButton press signal", auth_panel.visible):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
extends SceneTree
|
||||
## Smoke test for the player character scene and its spawn behavior.
|
||||
##
|
||||
## Run with:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_player.gd
|
||||
##
|
||||
## Loads the test level, verifies the player is present, spawned at the
|
||||
## PlayerSpawn marker, and has the expected CharacterBody3D structure.
|
||||
|
||||
var _passed := 0
|
||||
var _failed := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
print("SMOKE: starting player smoke test")
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
print("SMOKE: loading game scene")
|
||||
|
||||
# Load scenes/game.tscn -- the real PC2 composition point (Level + Player
|
||||
# as siblings, per docs/architecture/pc2_test_level.md §4), not the level
|
||||
# scene directly. Use the same SceneTree API as the real game so
|
||||
# `current_scene` is populated before the scene is added and its
|
||||
# _ready() callbacks fire. That ordering is what the player's spawn
|
||||
# lookup (get_tree().current_scene) depends on.
|
||||
var packed: PackedScene = load("res://scenes/game.tscn")
|
||||
var result := change_scene_to_packed(packed)
|
||||
if result != OK:
|
||||
printerr("SMOKE: failed to change scene, error ", result)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
await scene_changed
|
||||
var game: Node3D = current_scene as Node3D
|
||||
print("SMOKE: game scene ready")
|
||||
|
||||
# 1. Verify the player node exists in the scene tree.
|
||||
var player: Node = game.get_node("Player")
|
||||
_check("Player node exists in test level", player != null)
|
||||
_check("Player is a CharacterBody3D", player is CharacterBody3D)
|
||||
|
||||
# 2. Verify the player has the expected child structure.
|
||||
var collision: Node = player.get_node("CollisionShape3D")
|
||||
_check("Player has CollisionShape3D", collision != null)
|
||||
_check("CollisionShape3D has a shape",
|
||||
collision is CollisionShape3D and (collision as CollisionShape3D).shape != null)
|
||||
|
||||
var mesh: Node = player.get_node("CapsuleMesh")
|
||||
_check("Player has CapsuleMesh", mesh != null)
|
||||
_check("CapsuleMesh has a mesh",
|
||||
mesh is MeshInstance3D and (mesh as MeshInstance3D).mesh != null)
|
||||
|
||||
# 3. Verify the player spawned at the PlayerSpawn marker position.
|
||||
var spawn_marker: Marker3D = game.get_node("Level/PlayerSpawn")
|
||||
_check("PlayerSpawn marker exists", spawn_marker != null)
|
||||
|
||||
# The player's _ready() should have moved it to the spawn marker's
|
||||
# global position. Compare global positions directly (not local) since
|
||||
# Player and Level sit at different depths under Game.
|
||||
var expected_pos: Vector3 = spawn_marker.global_position
|
||||
_check("Player spawned at PlayerSpawn position",
|
||||
player.global_position == expected_pos)
|
||||
|
||||
# 4. Verify the player script class_name is correct and has the
|
||||
# movement constants defined.
|
||||
var script: GDScript = player.get_script() as GDScript
|
||||
_check("Player has a script attached", script != null)
|
||||
var constants: Dictionary = script.get_script_constant_map()
|
||||
_check("Player has WALK_SPEED constant", constants.has("WALK_SPEED"))
|
||||
_check("Player has GRAVITY constant", constants.has("GRAVITY"))
|
||||
_check("Player has JUMP_VELOCITY constant", constants.has("JUMP_VELOCITY"))
|
||||
_check("JUMP_VELOCITY is positive (upward)", float(constants.get("JUMP_VELOCITY", -1.0)) > 0.0)
|
||||
|
||||
# 5. Verify the player has a Camera3D so the game is actually viewable
|
||||
# from the player's perspective, not just a walking capsule with no view.
|
||||
var camera: Node = player.get_node_or_null("Camera3D")
|
||||
_check("Player has a Camera3D", camera is Camera3D)
|
||||
_check("Camera3D is current", camera is Camera3D and (camera as Camera3D).current)
|
||||
|
||||
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
|
||||
@@ -1,125 +0,0 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for the PC2 test level scene.
|
||||
##
|
||||
## Run with:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_test_level.gd
|
||||
##
|
||||
## Loads the test level scene, asserts:
|
||||
## (a) it loads without error,
|
||||
## (b) it contains a Marker3D named "PlayerSpawn",
|
||||
## (c) PlayerSpawn is positioned >= 1.0 m above the ground top surface.
|
||||
## Exits 0 on success, 1 on failure.
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var level_path: String = "res://scenes/worlds/test_level.tscn"
|
||||
|
||||
# Load the scene file
|
||||
var level_resource: PackedScene = load(level_path)
|
||||
if level_resource == null:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 could not load ", level_path)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("SMOKE: test_level.tscn loaded successfully")
|
||||
|
||||
# Change the running scene to our test level
|
||||
var err: int = change_scene_to_packed(level_resource)
|
||||
if err != OK:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 change_scene_to_packed returned ", err)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Wait for the scene_changed signal so current_scene is set
|
||||
await scene_changed
|
||||
|
||||
var level_instance: Node3D = current_scene as Node3D
|
||||
if level_instance == null:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 current_scene is not Node3D")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Give the tree one frame to fully initialize
|
||||
await process_frame
|
||||
|
||||
# (b) Find the PlayerSpawn marker
|
||||
var spawn_marker: Node3D = level_instance.find_child("PlayerSpawn", true, false) as Node3D
|
||||
if spawn_marker == null:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 no Node3D named 'PlayerSpawn' found in test level")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("SMOKE: found PlayerSpawn at ", spawn_marker.global_position)
|
||||
|
||||
# (c) Verify PlayerSpawn is >= 1.0 m above ground top
|
||||
# The ground top surface is at y = 0 (Ground StaticBody3D centered at y = -0.5
|
||||
# with height 1.0, so top face is at y = 0).
|
||||
var spawn_y: float = spawn_marker.global_position.y
|
||||
if spawn_y < 1.0:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 PlayerSpawn y = ", spawn_y,
|
||||
" is below the required minimum of 1.0 m above ground top (y=0)")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("SMOKE: PlayerSpawn y = ", spawn_y, " >= 1.0 m \u2014 PASS")
|
||||
|
||||
# Additional check: verify Ground StaticBody3D exists
|
||||
var ground: StaticBody3D = level_instance.find_child("Ground", true, false) as StaticBody3D
|
||||
if ground == null:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 no StaticBody3D named 'Ground' found")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("SMOKE: found Ground StaticBody3D")
|
||||
|
||||
# Verify Ground top face is at y = 0
|
||||
var ground_shape: CollisionShape3D = ground.find_child("CollisionShape3D", true, false) as CollisionShape3D
|
||||
if ground_shape != null and ground_shape.shape is BoxShape3D:
|
||||
var box_shape: BoxShape3D = ground_shape.shape as BoxShape3D
|
||||
var ground_top: float = ground.global_position.y + box_shape.size.y / 2.0
|
||||
if abs(ground_top - 0.0) > 0.01:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 Ground top face at y = ", ground_top, " expected ~0.0")
|
||||
quit(1)
|
||||
return
|
||||
print("SMOKE: Ground top face at y = ", ground_top, " \u2014 PASS")
|
||||
|
||||
# Verify props exist (3-5 StaticBody3D children under Props node)
|
||||
var props_node: Node3D = level_instance.find_child("Props", true, false) as Node3D
|
||||
if props_node != null:
|
||||
var prop_count: int = 0
|
||||
for child in props_node.get_children():
|
||||
if child is StaticBody3D:
|
||||
prop_count += 1
|
||||
if prop_count < 3:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 only ", prop_count, " props found, need >= 3")
|
||||
quit(1)
|
||||
return
|
||||
print("SMOKE: found ", prop_count, " props \u2014 PASS")
|
||||
else:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 no 'Props' Node3D found")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Verify lighting exists
|
||||
var sun: DirectionalLight3D = level_instance.find_child("Sun", true, false) as DirectionalLight3D
|
||||
if sun == null:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 no DirectionalLight3D named 'Sun' found")
|
||||
quit(1)
|
||||
return
|
||||
print("SMOKE: found Sun DirectionalLight3D")
|
||||
|
||||
# Verify WorldEnvironment exists
|
||||
var env: WorldEnvironment = level_instance.find_child("WorldEnvironment", true, false) as WorldEnvironment
|
||||
if env == null:
|
||||
printerr("SMOKE RESULT: FAIL \u2014 no WorldEnvironment named 'WorldEnvironment' found")
|
||||
quit(1)
|
||||
return
|
||||
print("SMOKE: found WorldEnvironment")
|
||||
|
||||
# All checks passed
|
||||
print("SMOKE RESULT: PASS")
|
||||
quit(0)
|
||||
@@ -58,7 +58,7 @@ 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!", true)
|
||||
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", "2000-01-01")
|
||||
if not _check("setup: register succeeded", reg_result.ok):
|
||||
print(" detail: ", reg_result.detail)
|
||||
quit(1)
|
||||
|
||||
Reference in New Issue
Block a user