Compare commits

..

8 Commits

10 changed files with 1293 additions and 0 deletions
+126
View File
@@ -10,18 +10,49 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, s
from pydantic import BaseModel
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.dependencies import OptionalUser
from app.models.world import World, WorldVisibility
from app.models.world_visit import WorldVisit
from app.services.trending import get_trending_subquery
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
class WorldOwnerSummary(BaseModel):
id: int
username: str
model_config = {"from_attributes": True}
class DiscoveredWorld(BaseModel):
id: int
name: str
description: str | None = None
category: str | None = None
tags: list[str] = []
play_count: int = 0
created_at: datetime | None = None
owner: WorldOwnerSummary
model_config = {"from_attributes": True}
class DiscoveryListResponse(BaseModel):
items: list[DiscoveredWorld]
total: int
skip: int
limit: int
class VisitRequest(BaseModel):
anonymous_token: str | None = None
idempotency_key: str | None = None
@@ -132,3 +163,98 @@ async def record_world_visit(
play_count=world.play_count,
last_played_at=world.last_played_at,
)
@router.get("/categories", response_model=list[str])
async def list_categories(
db: DbDep,
):
"""List distinct categories of public worlds."""
res = await db.execute(
select(World.category)
.where(World.category.is_not(None), World.visibility == WorldVisibility.PUBLIC)
.distinct()
.order_by(World.category)
)
categories = [c for c in res.scalars().all() if c]
return categories
@router.get("/worlds", response_model=DiscoveryListResponse)
async def list_discovery_worlds(
db: DbDep,
current_user: OptionalUser = None,
q: str | None = Query(None, description="Substring search on world name"),
tags: list[str] | None = Query(None, description="Filter by tags"),
category: str | None = Query(None, description="Exact category filter"),
sort: str = Query("newest", description="Sort order (newest, most_played)"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
):
"""List public worlds for discovery with search, category/tag filters, and pagination."""
base_query = select(World).where(World.visibility == WorldVisibility.PUBLIC)
if q:
base_query = base_query.where(World.name.ilike(f"%{q}%"))
if category:
base_query = base_query.where(func.lower(World.category) == category.lower())
if tags:
parsed_tags = [t.strip() for item in tags for t in item.split(",") if t.strip()]
if parsed_tags:
base_query = base_query.where(World.tags.overlap(parsed_tags))
# Count total matching worlds
count_query = select(func.count()).select_from(base_query.subquery())
total = (await db.execute(count_query)).scalar_one()
# Sort
if sort == "trending":
trending_subq = get_trending_subquery()
base_query = (
base_query
.outerjoin(trending_subq, World.id == trending_subq.c.trending_world_id)
.order_by(
func.coalesce(trending_subq.c.trending_score, 0.0).desc(),
World.play_count.desc(),
World.created_at.desc(),
)
)
elif sort == "most_played":
base_query = base_query.order_by(World.play_count.desc(), World.created_at.desc())
else: # default: newest
base_query = base_query.order_by(World.created_at.desc())
# Pagination & load owner
query = (
base_query
.offset(skip)
.limit(limit)
.options(selectinload(World.owner))
)
result = await db.execute(query)
worlds = result.scalars().all()
items = [
DiscoveredWorld(
id=w.id,
name=w.name,
description=w.description,
category=w.category,
tags=w.tags or [],
play_count=w.play_count,
created_at=w.created_at,
owner=WorldOwnerSummary(id=w.owner.id, username=w.owner.username),
)
for w in worlds
]
return DiscoveryListResponse(
items=items,
total=total,
skip=skip,
limit=limit,
)
+34
View File
@@ -0,0 +1,34 @@
"""Trending recommendation heuristic service.
docs/planning/discovery.md (task 33706f401ad9).
"""
from sqlalchemy import case, func, select, text
from app.models.world_visit import WorldVisit
def get_trending_subquery(days: int = 7):
"""Build a subquery that computes a decay-weighted trending score per world_id.
Decay weights:
- Visited within 24 hours: weight 3.0
- Visited within 3 days: weight 2.0
- Visited within 7 days: weight 1.0
"""
trending_weight = case(
(WorldVisit.visited_at >= func.now() - text("interval '1 day'"), 3.0),
(WorldVisit.visited_at >= func.now() - text("interval '3 days'"), 2.0),
else_=1.0,
)
interval_str = f"interval '{days} days'"
subq = (
select(
WorldVisit.world_id.label("trending_world_id"),
func.coalesce(func.sum(trending_weight), 0.0).label("trending_score"),
)
.where(WorldVisit.visited_at >= func.now() - text(interval_str))
.group_by(WorldVisit.world_id)
.subquery("trending_scores")
)
return subq
+188
View File
@@ -0,0 +1,188 @@
"""API tests for discovery browse & search endpoints.
docs/planning/discovery.md (task 300da1dcddf3).
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
@pytest.mark.asyncio
async def test_discovery_public_worlds_only_and_fields():
client = TestClient(app)
uid, headers = _register(client, "creator_browse_1")
# Create public world
pub_resp = client.post(
"/api/worlds/",
headers=headers,
json={
"name": "Fantasy Kingdom",
"description": "A magical realm",
"visibility": "public",
"category": "RPG",
"tags": ["magic", "dragons"],
},
)
assert pub_resp.status_code == 201, pub_resp.text
pub_id = pub_resp.json()["id"]
# Create private world
priv_resp = client.post(
"/api/worlds/",
headers=headers,
json={
"name": "Secret Base",
"description": "Classified",
"visibility": "private",
"category": "RPG",
"tags": ["secret"],
},
)
assert priv_resp.status_code == 201, priv_resp.text
# 1. Anonymous listing: only public world is returned
anon_resp = client.get("/api/discovery/worlds")
assert anon_resp.status_code == 200, anon_resp.text
data = anon_resp.json()
assert data["total"] >= 1
items = data["items"]
ids = [w["id"] for w in items]
assert pub_id in ids
assert priv_resp.json()["id"] not in ids
# Check fields of returned item
pub_item = next(w for w in items if w["id"] == pub_id)
assert pub_item["name"] == "Fantasy Kingdom"
assert pub_item["description"] == "A magical realm"
assert pub_item["category"] == "RPG"
assert "magic" in pub_item["tags"]
assert pub_item["play_count"] == 0
assert pub_item["owner"]["id"] == uid
assert pub_item["owner"]["username"] == "creator_browse_1"
# 2. Authenticated caller also does NOT see private worlds in discovery
auth_resp = client.get("/api/discovery/worlds", headers=headers)
assert auth_resp.status_code == 200
auth_ids = [w["id"] for w in auth_resp.json()["items"]]
assert priv_resp.json()["id"] not in auth_ids
@pytest.mark.asyncio
async def test_discovery_filtering_and_categories():
client = TestClient(app)
uid, headers = _register(client, "creator_filter_2")
# Create distinct worlds
client.post(
"/api/worlds/",
headers=headers,
json={"name": "Cyber Arena", "visibility": "public", "category": "Action", "tags": ["pvp", "fps"]},
)
client.post(
"/api/worlds/",
headers=headers,
json={"name": "Peaceful Meadow", "visibility": "public", "category": "Social", "tags": ["relax", "hangout"]},
)
client.post(
"/api/worlds/",
headers=headers,
json={"name": "Cyber Hangout", "visibility": "public", "category": "Social", "tags": ["cyberpunk", "chat"]},
)
# Search by q
res_q = client.get("/api/discovery/worlds?q=cyber")
assert res_q.status_code == 200
names = [w["name"] for w in res_q.json()["items"]]
assert "Cyber Arena" in names
assert "Cyber Hangout" in names
assert "Peaceful Meadow" not in names
# Filter by category (case-insensitive)
res_cat = client.get("/api/discovery/worlds?category=action")
assert res_cat.status_code == 200
names = [w["name"] for w in res_cat.json()["items"]]
assert "Cyber Arena" in names
assert "Peaceful Meadow" not in names
# Filter by tags (overlap)
res_tags = client.get("/api/discovery/worlds?tags=relax,pvp")
assert res_tags.status_code == 200
names = [w["name"] for w in res_tags.json()["items"]]
assert "Peaceful Meadow" in names
assert "Cyber Arena" in names
assert "Cyber Hangout" not in names
# Get distinct categories
res_cats = client.get("/api/discovery/categories")
assert res_cats.status_code == 200
cats = res_cats.json()
assert "Action" in cats
assert "Social" in cats
@pytest.mark.asyncio
async def test_discovery_sorting_and_pagination():
client = TestClient(app)
uid, headers = _register(client, "creator_sort_3")
w1 = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Sort World 1", "visibility": "public"},
).json()
w2 = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Sort World 2", "visibility": "public"},
).json()
# Record visits on w1 so play_count = 1
v_res = client.post(f"/api/discovery/worlds/{w1['id']}/visit")
assert v_res.status_code == 200
# Sort by most_played
res_mp = client.get("/api/discovery/worlds?sort=most_played")
assert res_mp.status_code == 200
items = res_mp.json()["items"]
# w1 should come before w2
idx_w1 = next(i for i, w in enumerate(items) if w["id"] == w1["id"])
idx_w2 = next(i for i, w in enumerate(items) if w["id"] == w2["id"])
assert idx_w1 < idx_w2
# Sort by newest (default)
res_new = client.get("/api/discovery/worlds?sort=newest")
assert res_new.status_code == 200
items_new = res_new.json()["items"]
idx_w1_n = next(i for i, w in enumerate(items_new) if w["id"] == w1["id"])
idx_w2_n = next(i for i, w in enumerate(items_new) if w["id"] == w2["id"])
# w2 was created after w1, so w2 is newer
assert idx_w2_n < idx_w1_n
# Pagination: limit 1, skip 0
p1 = client.get("/api/discovery/worlds?limit=1&skip=0")
assert p1.status_code == 200
assert len(p1.json()["items"]) == 1
assert p1.json()["total"] >= 2
assert p1.json()["skip"] == 0
assert p1.json()["limit"] == 1
+103
View File
@@ -0,0 +1,103 @@
"""API and heuristic tests for trending discovery sort.
docs/planning/discovery.md (task 33706f401ad9).
"""
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.world_visit import WorldVisit
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
@pytest.mark.asyncio
async def test_trending_sort_heuristic_and_zero_visits():
client = TestClient(app)
uid, headers = _register(client, "creator_trending_1")
# Create 3 worlds
wA = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Trending Hot World", "visibility": "public", "category": "Action"},
).json()
wB = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Older Mild World", "visibility": "public", "category": "Action"},
).json()
wC = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Zero Visit World", "visibility": "public", "category": "Action"},
).json()
engine, sf = _session_factory()
now = datetime.now(timezone.utc)
try:
async with sf() as session:
# World A: 3 visits within last 2 hours (weight 3 each = score 9)
for i in range(3):
session.add(
WorldVisit(
world_id=wA["id"],
anonymous_token=f"hot_visitor_{i}",
visited_at=now - timedelta(hours=1, minutes=i),
)
)
# World B: 1 visit 5 days ago (weight 1 = score 1)
session.add(
WorldVisit(
world_id=wB["id"],
anonymous_token="old_visitor",
visited_at=now - timedelta(days=5),
)
)
# World C: zero visits
await session.commit()
finally:
await engine.dispose()
# Query with sort=trending
resp = client.get("/api/discovery/worlds?sort=trending&category=Action")
assert resp.status_code == 200, resp.text
items = resp.json()["items"]
# Verify order: wA should be first, wB second, wC third
ids = [item["id"] for item in items]
assert wA["id"] in ids
assert wB["id"] in ids
assert wC["id"] in ids
idxA = ids.index(wA["id"])
idxB = ids.index(wB["id"])
idxC = ids.index(wC["id"])
assert idxA < idxB < idxC, f"Expected order A ({idxA}) < B ({idxB}) < C ({idxC})"
+150
View File
@@ -0,0 +1,150 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/discovery_ui.gd" id="1_disc"]
[node name="DiscoveryUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_disc")
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 24.0
offset_right = -32.0
offset_bottom = -24.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="Header" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBox/Header"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBox/Header"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 26
text = "Discover Experiences"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBox/Header"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="StatusLabel" type="Label" parent="VBox"]
visible = false
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.3, 0.3, 1)
text = ""
[node name="Filters" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="SearchEdit" type="LineEdit" parent="VBox/Filters"]
custom_minimum_size = Vector2(240, 36)
layout_mode = 2
placeholder_text = "Search by name or tag..."
[node name="CategoryOption" type="OptionButton" parent="VBox/Filters"]
custom_minimum_size = Vector2(160, 36)
layout_mode = 2
[node name="SortOption" type="OptionButton" parent="VBox/Filters"]
custom_minimum_size = Vector2(140, 36)
layout_mode = 2
[node name="FilterButton" type="Button" parent="VBox/Filters"]
custom_minimum_size = Vector2(90, 36)
layout_mode = 2
text = "Filter"
[node name="Content" type="Control" parent="VBox"]
layout_mode = 2
size_flags_vertical = 3
[node name="LoadingLabel" type="Label" parent="VBox/Content"]
visible = false
layout_mode = 1
anchors_preset = 14
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_top = -12.0
offset_bottom = 12.0
text = "Loading experiences..."
horizontal_alignment = 1
[node name="ErrorLabel" type="Label" parent="VBox/Content"]
visible = false
layout_mode = 1
anchors_preset = 14
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_top = -12.0
offset_bottom = 12.0
theme_override_colors/font_color = Color(1, 0.4, 0.4, 1)
text = ""
horizontal_alignment = 1
[node name="EmptyLabel" type="Label" parent="VBox/Content"]
visible = false
layout_mode = 1
anchors_preset = 14
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_top = -12.0
offset_bottom = 12.0
theme_override_colors/font_color = Color(0.7, 0.7, 0.7, 1)
text = "No experiences found."
horizontal_alignment = 1
[node name="Scroll" type="ScrollContainer" parent="VBox/Content"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="WorldsVBox" type="VBoxContainer" parent="VBox/Content/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 10
[node name="Pagination" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 16
alignment = 1
[node name="PrevButton" type="Button" parent="VBox/Pagination"]
custom_minimum_size = Vector2(90, 32)
layout_mode = 2
text = "< Prev"
[node name="PageLabel" type="Label" parent="VBox/Pagination"]
layout_mode = 2
text = "Page 1 of 1"
horizontal_alignment = 1
[node name="NextButton" type="Button" parent="VBox/Pagination"]
custom_minimum_size = Vector2(90, 32)
layout_mode = 2
text = "Next >"
+25
View File
@@ -456,6 +456,31 @@ func report_message(message_id: int, conversation_type: String, reason: String,
return await SocialApiClient.report_message(self, message_id, conversation_type, reason, context_text)
# ---------------------------------------------------------------------------
# Discovery Features
# ---------------------------------------------------------------------------
## List public worlds for discovery with optional search, tags, category, and sort.
func get_discovery_worlds(
q: String = "",
tags: Array = [],
category: String = "",
sort: String = "newest",
skip: int = 0,
limit: int = 50
) -> Dictionary:
return await DiscoveryApiClient.get_worlds(self, q, tags, category, sort, skip, limit)
## Get distinct categories of public worlds.
func get_discovery_categories() -> Dictionary:
return await DiscoveryApiClient.get_categories(self)
## Record a visit to a world.
func record_world_visit(world_id: int, anonymous_token: String = "") -> Dictionary:
return await DiscoveryApiClient.record_visit(self, world_id, anonymous_token)
# ---------------------------------------------------------------------------
# Internals
+140
View File
@@ -0,0 +1,140 @@
extends RefCounted
class_name DiscoveryApiClient
## Helper client for AGP Discovery REST endpoints (Browse, Search, Categories, Visit tracking).
##
## docs/planning/discovery.md (task 56c49440d0a6).
class WorldSummary extends RefCounted:
var id: int = 0
var name: String = ""
var description: String = ""
var category: String = ""
var tags: Array = []
var play_count: int = 0
var created_at: String = ""
var owner_id: int = 0
var owner_username: String = ""
static func from_dict(d: Dictionary) -> WorldSummary:
var w := WorldSummary.new()
w.id = int(d.get("id", 0))
w.name = str(d.get("name", ""))
w.description = str(d.get("description", "")) if d.get("description") != null else ""
w.category = str(d.get("category", "")) if d.get("category") != null else ""
var raw_tags = d.get("tags", [])
w.tags = raw_tags if raw_tags is Array else []
w.play_count = int(d.get("play_count", 0))
w.created_at = str(d.get("created_at", "")) if d.get("created_at") != null else ""
var owner: Dictionary = d.get("owner", {}) if d.get("owner") is Dictionary else {}
w.owner_id = int(owner.get("id", 0))
w.owner_username = str(owner.get("username", ""))
return w
class DiscoveryResult extends RefCounted:
var items: Array[WorldSummary] = []
var total: int = 0
var skip: int = 0
var limit: int = 50
static func from_dict(d: Dictionary) -> DiscoveryResult:
var res := DiscoveryResult.new()
res.total = int(d.get("total", 0))
res.skip = int(d.get("skip", 0))
res.limit = int(d.get("limit", 50))
var raw_items = d.get("items", [])
if raw_items is Array:
for item in raw_items:
if item is Dictionary:
res.items.append(WorldSummary.from_dict(item))
return res
static func get_api_client(node: Node) -> Node:
if node.has_node("/root/ApiClient"):
return node.get_node("/root/ApiClient")
return null
## Fetch worlds with search, tags, category filter, sort, and pagination.
## Attaches JWT token if client has one; works anonymously without token.
## Surfaces transport/HTTP errors in the standard result dictionary.
static func get_worlds(
api: Node,
q: String = "",
tags: Array = [],
category: String = "",
sort: String = "newest",
skip: int = 0,
limit: int = 50
) -> Dictionary:
var params: Array[String] = []
if q != "":
params.append("q=" + q.uri_encode())
if tags.size() > 0:
var tag_strs: Array[String] = []
for t in tags:
tag_strs.append(str(t))
params.append("tags=" + ",".join(tag_strs).uri_encode())
if category != "":
params.append("category=" + category.uri_encode())
if sort != "":
params.append("sort=" + sort.uri_encode())
params.append("skip=%d" % skip)
params.append("limit=%d" % limit)
var url := "/api/discovery/worlds"
if params.size() > 0:
url += "?" + "&".join(params)
var custom_headers := PackedStringArray()
if api != null and api.has_method("has_token") and api.has_token():
custom_headers.append("Authorization: " + api.bearer_token)
var res: Dictionary = await api.request(
HTTPClient.METHOD_GET,
url,
"",
"application/json",
false,
custom_headers
)
if res.ok and res.data is Dictionary:
res["parsed"] = DiscoveryResult.from_dict(res.data)
return res
## Fetch distinct categories of public worlds.
static func get_categories(api: Node) -> Dictionary:
var custom_headers := PackedStringArray()
if api != null and api.has_method("has_token") and api.has_token():
custom_headers.append("Authorization: " + api.bearer_token)
return await api.request(
HTTPClient.METHOD_GET,
"/api/discovery/categories",
"",
"application/json",
false,
custom_headers
)
## Record a visit to a world. Supports optional anonymous token or logged in user.
static func record_visit(api: Node, world_id: int, anonymous_token: String = "") -> Dictionary:
var custom_headers := PackedStringArray()
if api != null and api.has_method("has_token") and api.has_token():
custom_headers.append("Authorization: " + api.bearer_token)
var payload := {}
if anonymous_token != "":
payload["anonymous_token"] = anonymous_token
return await api.request(
HTTPClient.METHOD_POST,
"/api/discovery/worlds/%d/visit" % world_id,
JSON.stringify(payload) if payload.size() > 0 else "",
"application/json",
false,
custom_headers
)
+376
View File
@@ -0,0 +1,376 @@
extends Control
class_name DiscoveryUI
## Discovery & Home Screen for browsing, searching, and entering experiences.
##
## docs/planning/discovery.md (task b77bddf31ba5).
signal back_requested
signal world_selected(world_id: int)
signal worlds_loaded(count: int)
@onready var back_button: Button = $VBox/Header/BackButton
@onready var refresh_button: Button = $VBox/Header/RefreshButton
@onready var status_label: Label = $VBox/StatusLabel
# Filter Controls
@onready var search_edit: LineEdit = $VBox/Filters/SearchEdit
@onready var category_option: OptionButton = $VBox/Filters/CategoryOption
@onready var sort_option: OptionButton = $VBox/Filters/SortOption
@onready var filter_button: Button = $VBox/Filters/FilterButton
# Content
@onready var loading_label: Label = $VBox/Content/LoadingLabel
@onready var error_label: Label = $VBox/Content/ErrorLabel
@onready var empty_label: Label = $VBox/Content/EmptyLabel
@onready var worlds_container: VBoxContainer = $VBox/Content/Scroll/WorldsVBox
# Pagination
@onready var prev_button: Button = $VBox/Pagination/PrevButton
@onready var next_button: Button = $VBox/Pagination/NextButton
@onready var page_label: Label = $VBox/Pagination/PageLabel
var _api_client: Node = null
var _current_page: int = 0
var _page_size: int = 10
var _total_count: int = 0
var _loaded_worlds: Array[DiscoveryApiClient.WorldSummary] = []
func _ready() -> void:
_api_client = _get_api()
_wire_signals()
_setup_sort_options()
_setup_initial_ui_state()
# Populate categories then load initial worlds feed
await load_categories()
await load_worlds(0)
func _get_api() -> Node:
if has_node("/root/ApiClient"):
return get_node("/root/ApiClient")
var api_script = load("res://scripts/api_client.gd")
if api_script:
var api = api_script.new()
add_child(api)
return api
return null
func _setup_initial_ui_state() -> void:
if loading_label: loading_label.visible = false
if error_label: error_label.visible = false
if empty_label: empty_label.visible = false
if prev_button: prev_button.disabled = true
if next_button: next_button.disabled = true
if page_label: page_label.text = "Page 1"
func _setup_sort_options() -> void:
if not sort_option:
return
sort_option.clear()
sort_option.add_item("Newest", 0)
sort_option.add_item("Most Played", 1)
sort_option.add_item("Trending", 2)
sort_option.selected = 0
func _wire_signals() -> void:
if back_button and not back_button.pressed.is_connected(_on_back_pressed):
back_button.pressed.connect(_on_back_pressed)
if refresh_button and not refresh_button.pressed.is_connected(_on_refresh_pressed):
refresh_button.pressed.connect(_on_refresh_pressed)
if filter_button and not filter_button.pressed.is_connected(_on_filter_pressed):
filter_button.pressed.connect(_on_filter_pressed)
if search_edit and not search_edit.text_submitted.is_connected(_on_search_submitted):
search_edit.text_submitted.connect(_on_search_submitted)
if category_option and not category_option.item_selected.is_connected(_on_category_selected):
category_option.item_selected.connect(_on_category_selected)
if sort_option and not sort_option.item_selected.is_connected(_on_sort_selected):
sort_option.item_selected.connect(_on_sort_selected)
if prev_button and not prev_button.pressed.is_connected(_on_prev_page_pressed):
prev_button.pressed.connect(_on_prev_page_pressed)
if next_button and not next_button.pressed.is_connected(_on_next_page_pressed):
next_button.pressed.connect(_on_next_page_pressed)
func load_categories() -> void:
if not category_option:
return
category_option.clear()
category_option.add_item("All Categories", 0)
if _api_client == null:
return
var res: Dictionary = await DiscoveryApiClient.get_categories(_api_client)
if res.ok and res.data is Array:
var idx := 1
for cat in res.data:
var cat_str := str(cat).strip_edges()
if cat_str != "":
category_option.add_item(cat_str, idx)
idx += 1
func load_worlds(page: int = 0) -> void:
_current_page = page
if loading_label: loading_label.visible = true
if error_label: error_label.visible = false
if empty_label: empty_label.visible = false
var q := search_edit.text.strip_edges() if search_edit else ""
var category := ""
if category_option and category_option.selected > 0:
category = category_option.get_item_text(category_option.selected)
var sort := "newest"
if sort_option:
match sort_option.selected:
0: sort = "newest"
1: sort = "most_played"
2: sort = "trending"
var skip := _current_page * _page_size
var res: Dictionary = await DiscoveryApiClient.get_worlds(
_api_client,
q,
[],
category,
sort,
skip,
_page_size
)
if loading_label: loading_label.visible = false
if not res.ok:
if error_label:
error_label.text = "Error: %s" % res.detail
error_label.visible = true
_clear_container()
return
var parsed: DiscoveryApiClient.DiscoveryResult = res.get("parsed")
if parsed == null and res.data is Dictionary:
parsed = DiscoveryApiClient.DiscoveryResult.from_dict(res.data)
if parsed == null:
if error_label:
error_label.text = "Invalid discovery response"
error_label.visible = true
_clear_container()
return
_total_count = parsed.total
_loaded_worlds = parsed.items
_render_worlds(_loaded_worlds)
_update_pagination()
worlds_loaded.emit(_loaded_worlds.size())
func _clear_container() -> void:
if not worlds_container:
return
for c in worlds_container.get_children():
worlds_container.remove_child(c)
c.queue_free()
func _render_worlds(worlds: Array[DiscoveryApiClient.WorldSummary]) -> void:
_clear_container()
if worlds.is_empty():
if empty_label:
empty_label.text = "No experiences found matching your criteria."
empty_label.visible = true
return
if empty_label: empty_label.visible = false
for world in worlds:
var card := _create_world_card(world)
worlds_container.add_child(card)
func _create_world_card(world: DiscoveryApiClient.WorldSummary) -> Control:
var panel := PanelContainer.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.custom_minimum_size = Vector2(0, 90)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.14, 0.16, 0.20, 1.0)
style.corner_radius_top_left = 6
style.corner_radius_top_right = 6
style.corner_radius_bottom_left = 6
style.corner_radius_bottom_right = 6
style.content_margin_left = 16
style.content_margin_right = 16
style.content_margin_top = 10
style.content_margin_bottom = 10
panel.add_theme_stylebox_override("panel", style)
var vbox := VBoxContainer.new()
vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
vbox.add_theme_constant_override("separation", 4)
panel.add_child(vbox)
# Title & Category Row
var title_row := HBoxContainer.new()
var name_label := Label.new()
name_label.text = world.name
name_label.add_theme_font_size_override("font_size", 18)
name_label.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0, 1.0))
title_row.add_child(name_label)
if world.category != "":
var cat_label := Label.new()
cat_label.text = " [%s]" % world.category
cat_label.add_theme_font_size_override("font_size", 14)
cat_label.add_theme_color_override("font_color", Color(0.4, 0.7, 1.0, 1.0))
title_row.add_child(cat_label)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
title_row.add_child(spacer)
# Enter World Button
var enter_button := Button.new()
enter_button.text = "Enter World"
enter_button.custom_minimum_size = Vector2(110, 30)
enter_button.pressed.connect(_on_enter_world_pressed.bind(world.id, enter_button))
title_row.add_child(enter_button)
vbox.add_child(title_row)
# Description
var desc_label := Label.new()
desc_label.text = world.description if world.description != "" else "No description provided."
desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD
desc_label.add_theme_font_size_override("font_size", 13)
desc_label.add_theme_color_override("font_color", Color(0.75, 0.75, 0.75, 1.0))
vbox.add_child(desc_label)
# Metadata Row: Tags, Plays, Owner
var meta_row := HBoxContainer.new()
meta_row.add_theme_constant_override("separation", 16)
var play_label := Label.new()
play_label.text = "%d plays" % world.play_count
play_label.add_theme_font_size_override("font_size", 12)
play_label.add_theme_color_override("font_color", Color(0.6, 0.9, 0.6, 1.0))
meta_row.add_child(play_label)
if world.tags.size() > 0:
var tags_str: String = ""
for t in world.tags:
tags_str += "#" + str(t) + " "
var tags_label := Label.new()
tags_label.text = tags_str.strip_edges()
tags_label.add_theme_font_size_override("font_size", 12)
tags_label.add_theme_color_override("font_color", Color(0.8, 0.7, 0.4, 1.0))
meta_row.add_child(tags_label)
if world.owner_username != "":
var owner_label := Label.new()
owner_label.text = "Creator: %s" % world.owner_username
owner_label.add_theme_font_size_override("font_size", 12)
owner_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6, 1.0))
meta_row.add_child(owner_label)
vbox.add_child(meta_row)
return panel
func _update_pagination() -> void:
var total_pages := int(ceil(float(_total_count) / float(_page_size)))
if total_pages <= 0:
total_pages = 1
if page_label:
page_label.text = "Page %d of %d (%d experiences)" % [_current_page + 1, total_pages, _total_count]
if prev_button:
prev_button.disabled = (_current_page <= 0)
if next_button:
next_button.disabled = ((_current_page + 1) >= total_pages)
func _on_enter_world_pressed(world_id: int, button: Button) -> void:
button.disabled = true
button.text = "Joining…"
world_selected.emit(world_id)
# 1. Record visit metric
if _api_client != null:
await DiscoveryApiClient.record_visit(_api_client, world_id)
# 2. Join flow (existing game server orchestration)
if _api_client != null and _api_client.has_method("join_world"):
var result: Dictionary = await _api_client.join_world(world_id)
if result.ok and result.data is Dictionary:
var d: Dictionary = result.data
if has_node("/root/Session"):
get_node("/root/Session").begin(world_id, str(d.get("host", "")), int(d.get("port", 0)))
get_tree().change_scene_to_file("res://scenes/game.tscn")
return
if result.status == 503:
# Dev fallback: local single-player
if has_node("/root/Session"):
get_node("/root/Session").clear()
get_tree().change_scene_to_file("res://scenes/game.tscn")
return
if status_label:
status_label.text = "Failed to join: " + str(result.detail)
status_label.visible = true
else:
# Fallback if standalone test or not wired
if has_node("/root/Session"):
get_node("/root/Session").clear()
get_tree().change_scene_to_file("res://scenes/game.tscn")
return
button.disabled = false
button.text = "Enter World"
func _on_filter_pressed() -> void:
load_worlds(0)
func _on_search_submitted(_new_text: String) -> void:
load_worlds(0)
func _on_category_selected(_index: int) -> void:
load_worlds(0)
func _on_sort_selected(_index: int) -> void:
load_worlds(0)
func _on_refresh_pressed() -> void:
load_worlds(_current_page)
func _on_prev_page_pressed() -> void:
if _current_page > 0:
load_worlds(_current_page - 1)
func _on_next_page_pressed() -> void:
var total_pages := int(ceil(float(_total_count) / float(_page_size)))
if (_current_page + 1) < total_pages:
load_worlds(_current_page + 1)
func _on_back_pressed() -> void:
back_requested.emit()
if get_tree():
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
+62
View File
@@ -0,0 +1,62 @@
extends SceneTree
## Headless smoke test for DiscoveryApiClient.
func _init() -> void:
print("[SMOKE] Starting DiscoveryApiClient smoke test...")
# 1. Test WorldSummary parsing
var test_dict := {
"id": 42,
"name": "Neon Arcade",
"description": "Retro gaming lounge",
"category": "Arcade",
"tags": ["retro", "multiplayer"],
"play_count": 128,
"created_at": "2026-09-15T12:00:00Z",
"owner": {
"id": 7,
"username": "arcadegamer"
}
}
var summary := DiscoveryApiClient.WorldSummary.from_dict(test_dict)
assert(summary.id == 42, "summary id mismatch")
assert(summary.name == "Neon Arcade", "summary name mismatch")
assert(summary.category == "Arcade", "summary category mismatch")
assert(summary.tags.size() == 2, "summary tags size mismatch")
assert(summary.play_count == 128, "summary play_count mismatch")
assert(summary.owner_id == 7, "summary owner_id mismatch")
assert(summary.owner_username == "arcadegamer", "summary owner_username mismatch")
print("[PASS] WorldSummary parsing verified")
# 2. Test DiscoveryResult parsing
var page_dict := {
"items": [test_dict],
"total": 1,
"skip": 0,
"limit": 50
}
var res := DiscoveryApiClient.DiscoveryResult.from_dict(page_dict)
assert(res.total == 1, "res total mismatch")
assert(res.items.size() == 1, "res items size mismatch")
assert(res.items[0].id == 42, "res items[0] id mismatch")
print("[PASS] DiscoveryResult parsing verified")
# 3. Test ApiClient integration
var api_script = load("res://scripts/api_client.gd")
if not api_script:
printerr("[FAIL] Could not load api_client.gd")
quit(1)
return
var api: Node = api_script.new()
root.add_child(api)
assert(api.has_method("get_discovery_worlds"), "Missing get_discovery_worlds on ApiClient")
assert(api.has_method("get_discovery_categories"), "Missing get_discovery_categories on ApiClient")
assert(api.has_method("record_world_visit"), "Missing record_world_visit on ApiClient")
print("[PASS] ApiClient discovery methods verified")
api.queue_free()
print("[SMOKE] DiscoveryApiClient smoke test PASSED")
quit(0)
+89
View File
@@ -0,0 +1,89 @@
extends SceneTree
## Headless smoke test for DiscoveryUI.
func _initialize() -> void:
_run()
func _run() -> void:
print("[SMOKE] Starting DiscoveryUI smoke test...")
await process_frame
var scene = load("res://scenes/discovery_ui.tscn")
if not scene:
printerr("[FAIL] Could not load res://scenes/discovery_ui.tscn")
quit(1)
return
var ui: DiscoveryUI = scene.instantiate()
root.add_child(ui)
await process_frame
# 1. Verify node references
assert(ui.search_edit != null, "search_edit is null")
assert(ui.category_option != null, "category_option is null")
assert(ui.sort_option != null, "sort_option is null")
assert(ui.filter_button != null, "filter_button is null")
assert(ui.worlds_container != null, "worlds_container is null")
assert(ui.prev_button != null, "prev_button is null")
assert(ui.next_button != null, "next_button is null")
assert(ui.page_label != null, "page_label is null")
print("[PASS] Node references verified")
# 2. Verify sort options
assert(ui.sort_option.item_count == 3, "sort_option should have 3 items")
assert(ui.sort_option.get_item_text(0) == "Newest", "sort 0 should be Newest")
assert(ui.sort_option.get_item_text(1) == "Most Played", "sort 1 should be Most Played")
assert(ui.sort_option.get_item_text(2) == "Trending", "sort 2 should be Trending")
print("[PASS] Sort options verified")
# 3. Verify category dropdown initial state
assert(ui.category_option.item_count >= 1, "category_option should have at least All Categories")
assert(ui.category_option.get_item_text(0) == "All Categories", "category 0 should be All Categories")
print("[PASS] Category dropdown verified")
# 4. Test rendering of WorldSummary cards
var mock_w := DiscoveryApiClient.WorldSummary.new()
mock_w.id = 101
mock_w.name = "Cyber Nexus"
mock_w.category = "Action"
mock_w.tags = ["cyberpunk", "pvp"]
mock_w.play_count = 256
mock_w.description = "A futuristic arena"
mock_w.owner_username = "neon_builder"
var mock_worlds: Array[DiscoveryApiClient.WorldSummary] = [mock_w]
ui._render_worlds(mock_worlds)
assert(ui.worlds_container.get_child_count() == 1, "Should render 1 card")
assert(ui.empty_label.visible == false, "empty_label should be hidden when items present")
print("[PASS] World card rendering verified")
# 5. Test empty state
var empty_list: Array[DiscoveryApiClient.WorldSummary] = []
ui._render_worlds(empty_list)
assert(ui.worlds_container.get_child_count() == 0, "Container should have 0 children")
assert(ui.empty_label.visible == true, "empty_label should be visible when empty")
print("[PASS] Empty state verified")
# 6. Test pagination logic
ui._total_count = 25
ui._page_size = 10
ui._current_page = 0
ui._update_pagination()
assert(ui.prev_button.disabled == true, "Page 0 prev_button should be disabled")
assert(ui.next_button.disabled == false, "Page 0 next_button should be enabled")
assert(ui.page_label.text.contains("Page 1 of 3"), "Page 1 of 3 mismatch: %s" % ui.page_label.text)
ui._current_page = 1
ui._update_pagination()
assert(ui.prev_button.disabled == false, "Page 1 prev_button should be enabled")
assert(ui.next_button.disabled == false, "Page 1 next_button should be enabled")
ui._current_page = 2
ui._update_pagination()
assert(ui.prev_button.disabled == false, "Page 2 prev_button should be enabled")
assert(ui.next_button.disabled == true, "Page 2 next_button should be disabled")
print("[PASS] Pagination logic verified")
ui.queue_free()
print("[SMOKE] DiscoveryUI smoke test PASSED")
quit(0)