Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9144a1160 | |||
| 6afee66b83 | |||
| 434f776f9a | |||
| f95edb6713 | |||
| 6684578701 | |||
| 77d4ea5669 | |||
| dd4edecf11 | |||
| bddee1b96d | |||
| 38dc0cab21 | |||
| 892e330456 | |||
| e3188b6123 | |||
| f8b1fc4ea7 | |||
| 041b5f907e | |||
| 603ecdf5e2 |
@@ -0,0 +1,62 @@
|
|||||||
|
"""add discovery metadata to worlds and create world_visits table
|
||||||
|
|
||||||
|
Revision ID: 0015_add_discovery_metadata
|
||||||
|
Revises: 0014_add_payout_requests
|
||||||
|
Create Date: 2026-09-15 00:00:00.000000
|
||||||
|
|
||||||
|
docs/planning/discovery.md (task b377353b8167).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0015_add_discovery_metadata"
|
||||||
|
down_revision: Union[str, None] = "0014_add_payout_requests"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Add discovery metadata columns to worlds table
|
||||||
|
op.add_column("worlds", sa.Column("category", sa.String(length=100), nullable=True))
|
||||||
|
op.add_column("worlds", sa.Column("tags", postgresql.ARRAY(sa.String()), server_default="{}", nullable=True))
|
||||||
|
op.add_column("worlds", sa.Column("play_count", sa.Integer(), server_default="0", nullable=False))
|
||||||
|
op.add_column("worlds", sa.Column("last_played_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
op.create_index("ix_worlds_category", "worlds", ["category"], unique=False)
|
||||||
|
op.create_index("ix_worlds_tags", "worlds", ["tags"], unique=False, postgresql_using="gin")
|
||||||
|
|
||||||
|
# 2. Create world_visits table
|
||||||
|
op.create_table(
|
||||||
|
"world_visits",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("world_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("anonymous_token", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("visited_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["world_id"], ["worlds.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_world_visits_world_id", "world_visits", ["world_id"], unique=False)
|
||||||
|
op.create_index("ix_world_visits_user_id", "world_visits", ["user_id"], unique=False)
|
||||||
|
op.create_index("ix_world_visits_visited_at", "world_visits", ["visited_at"], unique=False)
|
||||||
|
op.create_index("ix_world_visits_world_visited_at", "world_visits", ["world_id", "visited_at"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_world_visits_world_visited_at", table_name="world_visits")
|
||||||
|
op.drop_index("ix_world_visits_visited_at", table_name="world_visits")
|
||||||
|
op.drop_index("ix_world_visits_user_id", table_name="world_visits")
|
||||||
|
op.drop_index("ix_world_visits_world_id", table_name="world_visits")
|
||||||
|
op.drop_table("world_visits")
|
||||||
|
|
||||||
|
op.drop_index("ix_worlds_tags", table_name="worlds", postgresql_using="gin")
|
||||||
|
op.drop_index("ix_worlds_category", table_name="worlds")
|
||||||
|
op.drop_column("worlds", "last_played_at")
|
||||||
|
op.drop_column("worlds", "play_count")
|
||||||
|
op.drop_column("worlds", "tags")
|
||||||
|
op.drop_column("worlds", "category")
|
||||||
+3
-1
@@ -9,7 +9,7 @@ from app.config import settings
|
|||||||
from app.routers import (
|
from app.routers import (
|
||||||
auth, users, worlds, assets, avatar, world_content, internal,
|
auth, users, worlds, assets, avatar, world_content, internal,
|
||||||
presence, groups, social_friends, profiles, chat_dm, chat_reports,
|
presence, groups, social_friends, profiles, chat_dm, chat_reports,
|
||||||
chat_world, wallet, payments, marketplace, payouts,
|
chat_world, wallet, payments, marketplace, payouts, discovery,
|
||||||
)
|
)
|
||||||
from app.gateway import ws
|
from app.gateway import ws
|
||||||
|
|
||||||
@@ -62,7 +62,9 @@ app.include_router(wallet.router, prefix="/api/wallet", tags=["wallet"])
|
|||||||
app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
|
app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
|
||||||
app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"])
|
app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"])
|
||||||
app.include_router(payouts.router, prefix="/api/payouts", tags=["payouts"])
|
app.include_router(payouts.router, prefix="/api/payouts", tags=["payouts"])
|
||||||
|
app.include_router(discovery.router, prefix="/api/discovery", tags=["discovery"])
|
||||||
app.include_router(ws.router, prefix="/api", tags=["websocket"])
|
app.include_router(ws.router, prefix="/api", tags=["websocket"])
|
||||||
|
|
||||||
app.include_router(ws.router, prefix="/v1", tags=["websocket"])
|
app.include_router(ws.router, prefix="/v1", tags=["websocket"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.models.marketplace import (
|
|||||||
CatalogItem, MarketplaceListing, Purchase, CreatorBalance,
|
CatalogItem, MarketplaceListing, Purchase, CreatorBalance,
|
||||||
ItemType, ListingStatus, PurchaseStatus,
|
ItemType, ListingStatus, PurchaseStatus,
|
||||||
)
|
)
|
||||||
|
from app.models.world_visit import WorldVisit
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "Session", "World", "Asset", "AvatarLoadout",
|
"User", "Session", "World", "Asset", "AvatarLoadout",
|
||||||
@@ -26,4 +27,5 @@ __all__ = [
|
|||||||
"Wallet", "Transaction", "TransactionType",
|
"Wallet", "Transaction", "TransactionType",
|
||||||
"CatalogItem", "MarketplaceListing", "Purchase", "CreatorBalance",
|
"CatalogItem", "MarketplaceListing", "Purchase", "CreatorBalance",
|
||||||
"ItemType", "ListingStatus", "PurchaseStatus",
|
"ItemType", "ListingStatus", "PurchaseStatus",
|
||||||
|
"WorldVisit",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
|
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index
|
||||||
from sqlalchemy.dialects.postgresql import ENUM
|
from sqlalchemy.dialects.postgresql import ENUM, ARRAY
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
import enum
|
import enum
|
||||||
@@ -29,11 +29,19 @@ class World(Base):
|
|||||||
),
|
),
|
||||||
default=WorldVisibility.PUBLIC,
|
default=WorldVisibility.PUBLIC,
|
||||||
)
|
)
|
||||||
|
category = Column(String(100), nullable=True, index=True)
|
||||||
|
tags = Column(ARRAY(String), nullable=True, server_default="{}")
|
||||||
|
play_count = Column(Integer, nullable=False, server_default="0", index=True)
|
||||||
|
last_played_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_worlds_tags", "tags", postgresql_using="gin"),
|
||||||
|
)
|
||||||
|
|
||||||
owner = relationship("User", back_populates="worlds")
|
owner = relationship("User", back_populates="worlds")
|
||||||
assets = relationship("Asset", back_populates="world", cascade="all, delete-orphan")
|
assets = relationship("Asset", back_populates="world", cascade="all, delete-orphan")
|
||||||
# world_content.py / world_persistence_impl.md §4.1. Added here (only)
|
|
||||||
# so PC1/PC2 code depending on this module is otherwise untouched.
|
|
||||||
rooms = relationship("WorldRoom", back_populates="world", cascade="all, delete-orphan")
|
rooms = relationship("WorldRoom", back_populates="world", cascade="all, delete-orphan")
|
||||||
|
visits = relationship("WorldVisit", back_populates="world", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""World visit tracking model for discovery analytics.
|
||||||
|
|
||||||
|
docs/planning/discovery.md (task b377353b8167).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Index
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class WorldVisit(Base):
|
||||||
|
__tablename__ = "world_visits"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
world_id = Column(Integer, ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
anonymous_token = Column(String(255), nullable=True)
|
||||||
|
visited_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_world_visits_world_visited_at", "world_id", "visited_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
world = relationship("World", back_populates="visits")
|
||||||
|
user = relationship("User")
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""Discovery router - visit tracking and world browsing.
|
||||||
|
|
||||||
|
docs/planning/discovery.md (tasks 42726e60fe10, 300da1dcddf3, 33706f401ad9).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class VisitResponse(BaseModel):
|
||||||
|
play_count: int
|
||||||
|
last_played_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
DEDUP_WINDOW_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/worlds/{world_id}/visit", response_model=VisitResponse)
|
||||||
|
async def record_world_visit(
|
||||||
|
world_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: DbDep,
|
||||||
|
current_user: OptionalUser = None,
|
||||||
|
visit_data: VisitRequest | None = None,
|
||||||
|
x_idempotency_key: Annotated[str | None, Header(alias="X-Idempotency-Key")] = None,
|
||||||
|
idempotency_key: str | None = Query(None),
|
||||||
|
):
|
||||||
|
"""Record a visit to a world.
|
||||||
|
|
||||||
|
- Increments worlds.play_count atomically (World.play_count + 1 in UPDATE).
|
||||||
|
- Updates worlds.last_played_at to func.now().
|
||||||
|
- Records row in world_visits (with world_id, user_id nullable, visited_at).
|
||||||
|
- Checks visibility: 404 if world not found or not PUBLIC and not owned by user.
|
||||||
|
- Idempotent: repeated calls within deduplication window or with same token/key do not duplicate.
|
||||||
|
"""
|
||||||
|
# 1. Fetch world
|
||||||
|
res = await db.execute(select(World).where(World.id == world_id))
|
||||||
|
world = res.scalar_one_or_none()
|
||||||
|
if not world:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found")
|
||||||
|
|
||||||
|
# 2. Check visibility: public or owned by requesting user
|
||||||
|
if world.visibility != WorldVisibility.PUBLIC:
|
||||||
|
if not current_user or world.owner_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found")
|
||||||
|
|
||||||
|
# Determine token / idempotency key
|
||||||
|
explicit_key = (
|
||||||
|
(visit_data.idempotency_key if visit_data else None)
|
||||||
|
or (visit_data.anonymous_token if visit_data else None)
|
||||||
|
or x_idempotency_key
|
||||||
|
or idempotency_key
|
||||||
|
)
|
||||||
|
|
||||||
|
fallback_host = request.client.host if request.client else "unknown_client"
|
||||||
|
dedup_key = explicit_key or (f"client_{fallback_host}" if not current_user else None)
|
||||||
|
|
||||||
|
# 3. Deduplication check
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(seconds=DEDUP_WINDOW_SECONDS)
|
||||||
|
is_duplicate = False
|
||||||
|
|
||||||
|
if current_user:
|
||||||
|
dedup_res = await db.execute(
|
||||||
|
select(WorldVisit).where(
|
||||||
|
WorldVisit.world_id == world_id,
|
||||||
|
WorldVisit.user_id == current_user.id,
|
||||||
|
WorldVisit.visited_at >= cutoff,
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
if dedup_res.scalar_one_or_none():
|
||||||
|
is_duplicate = True
|
||||||
|
elif dedup_key:
|
||||||
|
dedup_res = await db.execute(
|
||||||
|
select(WorldVisit).where(
|
||||||
|
WorldVisit.world_id == world_id,
|
||||||
|
WorldVisit.anonymous_token == dedup_key,
|
||||||
|
WorldVisit.visited_at >= cutoff,
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
if dedup_res.scalar_one_or_none():
|
||||||
|
is_duplicate = True
|
||||||
|
|
||||||
|
if is_duplicate:
|
||||||
|
return VisitResponse(
|
||||||
|
play_count=world.play_count,
|
||||||
|
last_played_at=world.last_played_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Atomic increment and visit log in single transaction
|
||||||
|
await db.execute(
|
||||||
|
update(World)
|
||||||
|
.where(World.id == world_id)
|
||||||
|
.values(
|
||||||
|
play_count=World.play_count + 1,
|
||||||
|
last_played_at=func.now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
visit = WorldVisit(
|
||||||
|
world_id=world_id,
|
||||||
|
user_id=current_user.id if current_user else None,
|
||||||
|
anonymous_token=dedup_key if not current_user else None,
|
||||||
|
)
|
||||||
|
db.add(visit)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Re-fetch or refresh to return updated fields
|
||||||
|
await db.refresh(world)
|
||||||
|
|
||||||
|
return VisitResponse(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -27,6 +27,8 @@ class WorldCreate(BaseModel):
|
|||||||
name: str = Field(..., min_length=1, max_length=255)
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
visibility: WorldVisibility = WorldVisibility.PUBLIC
|
visibility: WorldVisibility = WorldVisibility.PUBLIC
|
||||||
|
category: str | None = None
|
||||||
|
tags: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class WorldUpdate(BaseModel):
|
class WorldUpdate(BaseModel):
|
||||||
@@ -34,6 +36,8 @@ class WorldUpdate(BaseModel):
|
|||||||
name: str | None = Field(None, min_length=1, max_length=255)
|
name: str | None = Field(None, min_length=1, max_length=255)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
visibility: WorldVisibility | None = None
|
visibility: WorldVisibility | None = None
|
||||||
|
category: str | None = None
|
||||||
|
tags: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class WorldResponse(BaseModel):
|
class WorldResponse(BaseModel):
|
||||||
@@ -42,12 +46,17 @@ class WorldResponse(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
description: str | None
|
description: str | None
|
||||||
visibility: WorldVisibility
|
visibility: WorldVisibility
|
||||||
|
category: str | None = None
|
||||||
|
tags: list[str] | None = None
|
||||||
|
play_count: int = 0
|
||||||
|
last_played_at: datetime | None = None
|
||||||
owner_id: int
|
owner_id: int
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# --- Endpoints ---
|
# --- Endpoints ---
|
||||||
|
|
||||||
@router.get("/", response_model=list[WorldResponse])
|
@router.get("/", response_model=list[WorldResponse])
|
||||||
@@ -159,7 +168,10 @@ async def create_world(
|
|||||||
description=world_data.description,
|
description=world_data.description,
|
||||||
owner_id=current_user.id,
|
owner_id=current_user.id,
|
||||||
visibility=world_data.visibility,
|
visibility=world_data.visibility,
|
||||||
|
category=world_data.category,
|
||||||
|
tags=world_data.tags or [],
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(world)
|
db.add(world)
|
||||||
await db.flush() # assigns world.id, needed for the room's FK
|
await db.flush() # assigns world.id, needed for the room's FK
|
||||||
await WorldStore(db).get_or_create_main_room(world.id)
|
await WorldStore(db).get_or_create_main_room(world.id)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -45,6 +45,8 @@ TEST_TABLES = (
|
|||||||
# `worlds` the way world_rooms/world_chunks would be. List all three
|
# `worlds` the way world_rooms/world_chunks would be. List all three
|
||||||
# explicitly rather than rely on cascade for some of them.
|
# explicitly rather than rely on cascade for some of them.
|
||||||
"world_chunk_revisions", "world_chunks", "world_rooms",
|
"world_chunk_revisions", "world_chunks", "world_rooms",
|
||||||
|
# Discovery (app/models/world_visit.py) -- FK worlds.id with ON DELETE CASCADE
|
||||||
|
"world_visits",
|
||||||
"worlds",
|
"worlds",
|
||||||
# Social graph (app/models/social.py) -- all three FK users with
|
# Social graph (app/models/social.py) -- all three FK users with
|
||||||
# ON DELETE CASCADE, but list explicitly for clarity/consistency.
|
# ON DELETE CASCADE, but list explicitly for clarity/consistency.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Model-level integration tests for Discovery metadata and WorldVisit.
|
||||||
|
|
||||||
|
docs/planning/discovery.md (task b377353b8167).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.main import app
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.world import World, WorldVisibility
|
||||||
|
from app.models.world_visit import WorldVisit
|
||||||
|
|
||||||
|
|
||||||
|
def _register(client: TestClient, username: str) -> int:
|
||||||
|
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
|
||||||
|
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||||
|
me = client.get("/api/users/me", headers=headers)
|
||||||
|
assert me.status_code == 200, me.text
|
||||||
|
return me.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
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_world_discovery_metadata_and_visits():
|
||||||
|
client = TestClient(app)
|
||||||
|
user_id = _register(client, "discoverer_1")
|
||||||
|
visitor_id = _register(client, "visitor_1")
|
||||||
|
|
||||||
|
engine, sf = _session_factory()
|
||||||
|
try:
|
||||||
|
async with sf() as session:
|
||||||
|
# 1. Create a world with discovery metadata
|
||||||
|
world = World(
|
||||||
|
name="Cyber Adventure",
|
||||||
|
description="An epic adventure world",
|
||||||
|
owner_id=user_id,
|
||||||
|
visibility=WorldVisibility.PUBLIC,
|
||||||
|
category="Adventure",
|
||||||
|
tags=["cyberpunk", "rpg", "quest"],
|
||||||
|
)
|
||||||
|
session.add(world)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(world)
|
||||||
|
|
||||||
|
assert world.id is not None
|
||||||
|
assert world.category == "Adventure"
|
||||||
|
assert world.tags == ["cyberpunk", "rpg", "quest"]
|
||||||
|
assert world.play_count == 0
|
||||||
|
assert world.last_played_at is None
|
||||||
|
|
||||||
|
# 2. Add authenticated visit
|
||||||
|
visit1 = WorldVisit(
|
||||||
|
world_id=world.id,
|
||||||
|
user_id=visitor_id,
|
||||||
|
)
|
||||||
|
# 3. Add anonymous visit
|
||||||
|
visit2 = WorldVisit(
|
||||||
|
world_id=world.id,
|
||||||
|
user_id=None,
|
||||||
|
anonymous_token="anon_token_12345",
|
||||||
|
)
|
||||||
|
session.add_all([visit1, visit2])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Query world with visits
|
||||||
|
res = await session.execute(
|
||||||
|
select(World).where(World.id == world.id).options(selectinload(World.visits))
|
||||||
|
)
|
||||||
|
w = res.scalar_one()
|
||||||
|
assert len(w.visits) == 2
|
||||||
|
v_user = next(v for v in w.visits if v.user_id == visitor_id)
|
||||||
|
assert v_user.visited_at is not None
|
||||||
|
v_anon = next(v for v in w.visits if v.user_id is None)
|
||||||
|
assert v_anon.anonymous_token == "anon_token_12345"
|
||||||
|
|
||||||
|
# 4. Verify cascade delete on world
|
||||||
|
async with sf() as session:
|
||||||
|
res = await session.execute(select(World).where(World.id == world.id))
|
||||||
|
w = res.scalar_one()
|
||||||
|
await session.delete(w)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Visits should be gone
|
||||||
|
res_v = await session.execute(select(WorldVisit).where(WorldVisit.world_id == world.id))
|
||||||
|
assert len(res_v.scalars().all()) == 0
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_delete_sets_null_on_world_visits():
|
||||||
|
client = TestClient(app)
|
||||||
|
owner_id = _register(client, "world_owner_2")
|
||||||
|
visitor_id = _register(client, "temp_visitor_2")
|
||||||
|
|
||||||
|
engine, sf = _session_factory()
|
||||||
|
try:
|
||||||
|
async with sf() as session:
|
||||||
|
world = World(
|
||||||
|
name="Chill Lobby",
|
||||||
|
owner_id=owner_id,
|
||||||
|
visibility=WorldVisibility.PUBLIC,
|
||||||
|
category="Social",
|
||||||
|
tags=["hangout"],
|
||||||
|
)
|
||||||
|
session.add(world)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(world)
|
||||||
|
|
||||||
|
visit = WorldVisit(world_id=world.id, user_id=visitor_id)
|
||||||
|
session.add(visit)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(visit)
|
||||||
|
visit_id = visit.id
|
||||||
|
|
||||||
|
# Delete visitor user
|
||||||
|
visitor_user = await session.get(User, visitor_id)
|
||||||
|
await session.delete(visitor_user)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# In a new session, query from database to see ON DELETE SET NULL effect
|
||||||
|
async with sf() as session:
|
||||||
|
v = await session.get(WorldVisit, visit_id)
|
||||||
|
assert v is not None
|
||||||
|
assert v.user_id is None
|
||||||
|
assert v.world_id == world.id
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
@@ -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})"
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""API tests for world visit tracking endpoint.
|
||||||
|
|
||||||
|
docs/planning/discovery.md (task 42726e60fe10).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
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 import World, WorldVisibility
|
||||||
|
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_visit_world_atomic_increment_authenticated():
|
||||||
|
client = TestClient(app)
|
||||||
|
owner_id, owner_headers = _register(client, "visit_owner_1")
|
||||||
|
visitor_id, visitor_headers = _register(client, "visitor_user_1")
|
||||||
|
|
||||||
|
# Create world
|
||||||
|
resp = client.post(
|
||||||
|
"/api/worlds/",
|
||||||
|
headers=owner_headers,
|
||||||
|
json={"name": "Neon City", "visibility": "public", "category": "Roleplay"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
world_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Visitor visits the world
|
||||||
|
v_resp = client.post(
|
||||||
|
f"/api/discovery/worlds/{world_id}/visit",
|
||||||
|
headers=visitor_headers,
|
||||||
|
)
|
||||||
|
assert v_resp.status_code == 200, v_resp.text
|
||||||
|
data = v_resp.json()
|
||||||
|
assert data["play_count"] == 1
|
||||||
|
assert data["last_played_at"] is not None
|
||||||
|
|
||||||
|
# Verify DB state
|
||||||
|
engine, sf = _session_factory()
|
||||||
|
try:
|
||||||
|
async with sf() as session:
|
||||||
|
w = (await session.execute(select(World).where(World.id == world_id))).scalar_one()
|
||||||
|
assert w.play_count == 1
|
||||||
|
assert w.last_played_at is not None
|
||||||
|
|
||||||
|
visits = (
|
||||||
|
await session.execute(
|
||||||
|
select(WorldVisit).where(WorldVisit.world_id == world_id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
assert len(visits) == 1
|
||||||
|
assert visits[0].user_id == visitor_id
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visit_world_anonymous_and_idempotency():
|
||||||
|
client = TestClient(app)
|
||||||
|
owner_id, owner_headers = _register(client, "visit_owner_2")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/worlds/",
|
||||||
|
headers=owner_headers,
|
||||||
|
json={"name": "Space Station", "visibility": "public"},
|
||||||
|
)
|
||||||
|
world_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Anonymous visit with token
|
||||||
|
anon_token = "guest_token_999"
|
||||||
|
v_resp1 = client.post(
|
||||||
|
f"/api/discovery/worlds/{world_id}/visit",
|
||||||
|
json={"anonymous_token": anon_token},
|
||||||
|
)
|
||||||
|
assert v_resp1.status_code == 200, v_resp1.text
|
||||||
|
assert v_resp1.json()["play_count"] == 1
|
||||||
|
|
||||||
|
# Idempotent second call with same anonymous token
|
||||||
|
v_resp2 = client.post(
|
||||||
|
f"/api/discovery/worlds/{world_id}/visit",
|
||||||
|
json={"anonymous_token": anon_token},
|
||||||
|
)
|
||||||
|
assert v_resp2.status_code == 200, v_resp2.text
|
||||||
|
assert v_resp2.json()["play_count"] == 1
|
||||||
|
|
||||||
|
engine, sf = _session_factory()
|
||||||
|
try:
|
||||||
|
async with sf() as session:
|
||||||
|
visits = (
|
||||||
|
await session.execute(
|
||||||
|
select(WorldVisit).where(WorldVisit.world_id == world_id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
assert len(visits) == 1
|
||||||
|
assert visits[0].user_id is None
|
||||||
|
assert visits[0].anonymous_token == anon_token
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visit_world_visibility_and_404():
|
||||||
|
client = TestClient(app)
|
||||||
|
owner_id, owner_headers = _register(client, "visit_owner_3")
|
||||||
|
other_id, other_headers = _register(client, "other_user_3")
|
||||||
|
|
||||||
|
# 404 on non-existent world
|
||||||
|
res_404 = client.post("/api/discovery/worlds/999999/visit", headers=owner_headers)
|
||||||
|
assert res_404.status_code == 404
|
||||||
|
|
||||||
|
# Private world
|
||||||
|
resp = client.post(
|
||||||
|
"/api/worlds/",
|
||||||
|
headers=owner_headers,
|
||||||
|
json={"name": "Secret World", "visibility": "private"},
|
||||||
|
)
|
||||||
|
priv_world_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Other user gets 404
|
||||||
|
other_res = client.post(
|
||||||
|
f"/api/discovery/worlds/{priv_world_id}/visit",
|
||||||
|
headers=other_headers,
|
||||||
|
)
|
||||||
|
assert other_res.status_code == 404
|
||||||
|
|
||||||
|
# Anonymous user gets 404
|
||||||
|
anon_res = client.post(f"/api/discovery/worlds/{priv_world_id}/visit")
|
||||||
|
assert anon_res.status_code == 404
|
||||||
|
|
||||||
|
# Owner gets 200
|
||||||
|
owner_res = client.post(
|
||||||
|
f"/api/discovery/worlds/{priv_world_id}/visit",
|
||||||
|
headers=owner_headers,
|
||||||
|
)
|
||||||
|
assert owner_res.status_code == 200
|
||||||
|
assert owner_res.json()["play_count"] == 1
|
||||||
@@ -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 >"
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/selling_payout_ui.gd" id="1_spu"]
|
||||||
|
|
||||||
|
[node name="SellingPayoutUI" 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_spu")
|
||||||
|
|
||||||
|
[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 = 32.0
|
||||||
|
offset_right = -32.0
|
||||||
|
offset_bottom = -32.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_constants/separation = 12
|
||||||
|
|
||||||
|
[node name="HeaderHBox" type="HBoxContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
|
||||||
|
[node name="BackButton" type="Button" parent="VBox/HeaderHBox"]
|
||||||
|
custom_minimum_size = Vector2(100, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "< Back"
|
||||||
|
|
||||||
|
[node name="TitleLabel" type="Label" parent="VBox/HeaderHBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_font_sizes/font_size = 24
|
||||||
|
text = "Creator Studio — Selling & Payouts"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"]
|
||||||
|
custom_minimum_size = Vector2(100, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Refresh"
|
||||||
|
|
||||||
|
[node name="StatusLabel" type="Label" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="TabContainer" type="TabContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[node name="Selling" type="MarginContainer" parent="VBox/TabContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/margin_left = 12
|
||||||
|
theme_override_constants/margin_top = 12
|
||||||
|
theme_override_constants/margin_right = 12
|
||||||
|
theme_override_constants/margin_bottom = 12
|
||||||
|
|
||||||
|
[node name="VBox" type="VBoxContainer" parent="VBox/TabContainer/Selling"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 12
|
||||||
|
|
||||||
|
[node name="CreateListingBox" type="HBoxContainer" parent="VBox/TabContainer/Selling/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="NameInput" type="LineEdit" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
||||||
|
custom_minimum_size = Vector2(160, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
placeholder_text = "Item Name..."
|
||||||
|
|
||||||
|
[node name="TypeOption" type="OptionButton" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
||||||
|
custom_minimum_size = Vector2(130, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="DescInput" type="LineEdit" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
||||||
|
custom_minimum_size = Vector2(180, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
placeholder_text = "Description..."
|
||||||
|
|
||||||
|
[node name="PriceInput" type="LineEdit" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
||||||
|
custom_minimum_size = Vector2(90, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
placeholder_text = "Price (CR)"
|
||||||
|
|
||||||
|
[node name="CreateBtn" type="Button" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
||||||
|
custom_minimum_size = Vector2(110, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "List for Sale"
|
||||||
|
|
||||||
|
[node name="ListingsTitle" type="Label" parent="VBox/TabContainer/Selling/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 16
|
||||||
|
text = "Active Listings"
|
||||||
|
|
||||||
|
[node name="Scroll" type="ScrollContainer" parent="VBox/TabContainer/Selling/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[node name="ListingsList" type="VBoxContainer" parent="VBox/TabContainer/Selling/VBox/Scroll"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 6
|
||||||
|
|
||||||
|
[node name="Payouts" type="MarginContainer" parent="VBox/TabContainer"]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/margin_left = 12
|
||||||
|
theme_override_constants/margin_top = 12
|
||||||
|
theme_override_constants/margin_right = 12
|
||||||
|
theme_override_constants/margin_bottom = 12
|
||||||
|
|
||||||
|
[node name="VBox" type="VBoxContainer" parent="VBox/TabContainer/Payouts"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 12
|
||||||
|
|
||||||
|
[node name="BalanceCard" type="PanelContainer" parent="VBox/TabContainer/Payouts/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Margin" type="MarginContainer" parent="VBox/TabContainer/Payouts/VBox/BalanceCard"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/margin_left = 16
|
||||||
|
theme_override_constants/margin_top = 12
|
||||||
|
theme_override_constants/margin_right = 16
|
||||||
|
theme_override_constants/margin_bottom = 12
|
||||||
|
|
||||||
|
[node name="HBox" type="HBoxContainer" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 24
|
||||||
|
|
||||||
|
[node name="AvailTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Available to Payout:"
|
||||||
|
|
||||||
|
[node name="AvailableLbl" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
theme_override_colors/font_color = Color(0.2, 0.9, 0.2, 1)
|
||||||
|
text = "0 CR"
|
||||||
|
|
||||||
|
[node name="PendTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "In Escrow:"
|
||||||
|
|
||||||
|
[node name="PendingLbl" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
theme_override_colors/font_color = Color(1, 0.8, 0.2, 1)
|
||||||
|
text = "0 CR"
|
||||||
|
|
||||||
|
[node name="LifeTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Lifetime Earned:"
|
||||||
|
|
||||||
|
[node name="LifetimeLbl" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
theme_override_colors/font_color = Color(0.4, 0.7, 1, 1)
|
||||||
|
text = "0 CR"
|
||||||
|
|
||||||
|
[node name="RequestBox" type="HBoxContainer" parent="VBox/TabContainer/Payouts/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="AmountInput" type="LineEdit" parent="VBox/TabContainer/Payouts/VBox/RequestBox"]
|
||||||
|
custom_minimum_size = Vector2(130, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
placeholder_text = "Amount (min 100)"
|
||||||
|
|
||||||
|
[node name="DestInput" type="LineEdit" parent="VBox/TabContainer/Payouts/VBox/RequestBox"]
|
||||||
|
custom_minimum_size = Vector2(220, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
placeholder_text = "Payout Destination (e.g. PayPal / IBAN)"
|
||||||
|
|
||||||
|
[node name="SubmitBtn" type="Button" parent="VBox/TabContainer/Payouts/VBox/RequestBox"]
|
||||||
|
custom_minimum_size = Vector2(140, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Request Payout"
|
||||||
|
|
||||||
|
[node name="PayoutsTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 16
|
||||||
|
text = "Payout Requests History"
|
||||||
|
|
||||||
|
[node name="Scroll" type="ScrollContainer" parent="VBox/TabContainer/Payouts/VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[node name="PayoutsList" type="VBoxContainer" parent="VBox/TabContainer/Payouts/VBox/Scroll"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 6
|
||||||
@@ -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)
|
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
|
# Internals
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
extends Control
|
||||||
|
class_name SellingPayoutUI
|
||||||
|
## Selling & Creator Payout UI panel.
|
||||||
|
##
|
||||||
|
## Displays:
|
||||||
|
## - Tab 1 (Selling): Create new catalog item & marketplace listing, manage my active listings (update/delist)
|
||||||
|
## - Tab 2 (Payouts): Creator balance breakdown (available/pending/lifetime), submit payout request, view payout history
|
||||||
|
##
|
||||||
|
## docs/planning/economy.md US-6 (task 96e5f11d7896).
|
||||||
|
|
||||||
|
signal back_requested
|
||||||
|
|
||||||
|
@onready var back_button: Button = $VBox/HeaderHBox/BackButton
|
||||||
|
@onready var refresh_button: Button = $VBox/HeaderHBox/RefreshButton
|
||||||
|
@onready var status_label: Label = $VBox/StatusLabel
|
||||||
|
@onready var tab_container: TabContainer = $VBox/TabContainer
|
||||||
|
|
||||||
|
# Tab 1: Selling
|
||||||
|
@onready var item_name_input: LineEdit = $VBox/TabContainer/Selling/VBox/CreateListingBox/NameInput
|
||||||
|
@onready var item_type_option: OptionButton = $VBox/TabContainer/Selling/VBox/CreateListingBox/TypeOption
|
||||||
|
@onready var item_desc_input: LineEdit = $VBox/TabContainer/Selling/VBox/CreateListingBox/DescInput
|
||||||
|
@onready var price_input: LineEdit = $VBox/TabContainer/Selling/VBox/CreateListingBox/PriceInput
|
||||||
|
@onready var create_listing_btn: Button = $VBox/TabContainer/Selling/VBox/CreateListingBox/CreateBtn
|
||||||
|
@onready var my_listings_container: VBoxContainer = $VBox/TabContainer/Selling/VBox/Scroll/ListingsList
|
||||||
|
|
||||||
|
# Tab 2: Payouts
|
||||||
|
@onready var available_lbl: Label = $VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox/AvailableLbl
|
||||||
|
@onready var pending_lbl: Label = $VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox/PendingLbl
|
||||||
|
@onready var lifetime_lbl: Label = $VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox/LifetimeLbl
|
||||||
|
|
||||||
|
@onready var payout_amount_input: LineEdit = $VBox/TabContainer/Payouts/VBox/RequestBox/AmountInput
|
||||||
|
@onready var payout_dest_input: LineEdit = $VBox/TabContainer/Payouts/VBox/RequestBox/DestInput
|
||||||
|
@onready var request_payout_btn: Button = $VBox/TabContainer/Payouts/VBox/RequestBox/SubmitBtn
|
||||||
|
@onready var payouts_container: VBoxContainer = $VBox/TabContainer/Payouts/VBox/Scroll/PayoutsList
|
||||||
|
|
||||||
|
var _api_client: Node = null
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_api_client = _get_api()
|
||||||
|
_setup_type_options()
|
||||||
|
_wire_signals()
|
||||||
|
if _api_client != null and _api_client.has_token():
|
||||||
|
refresh_all()
|
||||||
|
|
||||||
|
|
||||||
|
func _get_api() -> Node:
|
||||||
|
if has_node("/root/ApiClient"):
|
||||||
|
return get_node("/root/ApiClient")
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func _setup_type_options() -> void:
|
||||||
|
if not item_type_option:
|
||||||
|
return
|
||||||
|
item_type_option.clear()
|
||||||
|
item_type_option.add_item("Avatar Item", 0)
|
||||||
|
item_type_option.add_item("Asset", 1)
|
||||||
|
item_type_option.add_item("Experience Pass", 2)
|
||||||
|
|
||||||
|
|
||||||
|
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(refresh_all):
|
||||||
|
refresh_button.pressed.connect(refresh_all)
|
||||||
|
if create_listing_btn and not create_listing_btn.pressed.is_connected(_on_create_listing_pressed):
|
||||||
|
create_listing_btn.pressed.connect(_on_create_listing_pressed)
|
||||||
|
if request_payout_btn and not request_payout_btn.pressed.is_connected(_on_request_payout_pressed):
|
||||||
|
request_payout_btn.pressed.connect(_on_request_payout_pressed)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_back_pressed() -> void:
|
||||||
|
back_requested.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func show_status(text: String, is_error: bool = false) -> void:
|
||||||
|
if not status_label:
|
||||||
|
return
|
||||||
|
status_label.text = text
|
||||||
|
if is_error:
|
||||||
|
status_label.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3))
|
||||||
|
else:
|
||||||
|
status_label.add_theme_color_override("font_color", Color(0.3, 0.9, 0.3))
|
||||||
|
|
||||||
|
|
||||||
|
func refresh_all() -> void:
|
||||||
|
refresh_my_listings()
|
||||||
|
refresh_creator_balance()
|
||||||
|
refresh_payouts()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Selling / Listings ──
|
||||||
|
|
||||||
|
func refresh_my_listings() -> void:
|
||||||
|
var api = _get_api()
|
||||||
|
if not api or not api.has_token():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fetch my items
|
||||||
|
var items_res: Dictionary = await EconomyApiClient.get_catalog_items(api, true)
|
||||||
|
# Fetch active listings
|
||||||
|
var list_res: Dictionary = await EconomyApiClient.search_listings(api, "", "", -1, -1, 100, 0)
|
||||||
|
|
||||||
|
if list_res.ok and list_res.data is Dictionary:
|
||||||
|
var all_listings: Array = list_res.data.get("listings", [])
|
||||||
|
# Filter for seller_id or user's items
|
||||||
|
render_my_listings(all_listings)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_create_listing_pressed() -> void:
|
||||||
|
var api = _get_api()
|
||||||
|
if not api or not api.has_token():
|
||||||
|
show_status("Please login to create listings", true)
|
||||||
|
return
|
||||||
|
|
||||||
|
var item_name: String = item_name_input.text.strip_edges() if item_name_input else ""
|
||||||
|
if item_name == "":
|
||||||
|
show_status("Item name is required", true)
|
||||||
|
return
|
||||||
|
|
||||||
|
var itype: String = "avatar_item"
|
||||||
|
if item_type_option:
|
||||||
|
match item_type_option.selected:
|
||||||
|
0: itype = "avatar_item"
|
||||||
|
1: itype = "asset"
|
||||||
|
2: itype = "experience_pass"
|
||||||
|
|
||||||
|
var desc: String = item_desc_input.text.strip_edges() if item_desc_input else ""
|
||||||
|
var price_text: String = price_input.text.strip_edges() if price_input else ""
|
||||||
|
if not price_text.is_valid_int() or price_text.to_int() <= 0:
|
||||||
|
show_status("Valid positive price in Credits is required", true)
|
||||||
|
return
|
||||||
|
var price: int = price_text.to_int()
|
||||||
|
|
||||||
|
show_status("Creating item and listing...")
|
||||||
|
# 1. Create Catalog Item
|
||||||
|
var item_res: Dictionary = await EconomyApiClient.create_catalog_item(api, item_name, itype, desc)
|
||||||
|
if not item_res.ok or not item_res.data is Dictionary:
|
||||||
|
show_status("Failed to create catalog item: " + item_res.detail, true)
|
||||||
|
return
|
||||||
|
|
||||||
|
var item_id: int = int(item_res.data.get("id", 0))
|
||||||
|
|
||||||
|
# 2. Create Listing
|
||||||
|
var list_res: Dictionary = await EconomyApiClient.create_listing(api, item_id, price)
|
||||||
|
if list_res.ok:
|
||||||
|
show_status("Successfully listed '%s' for %d Credits!" % [item_name, price])
|
||||||
|
if item_name_input: item_name_input.text = ""
|
||||||
|
if item_desc_input: item_desc_input.text = ""
|
||||||
|
if price_input: price_input.text = ""
|
||||||
|
refresh_my_listings()
|
||||||
|
else:
|
||||||
|
show_status("Failed to create listing: " + list_res.detail, true)
|
||||||
|
|
||||||
|
|
||||||
|
func render_my_listings(listings: Array) -> void:
|
||||||
|
if not my_listings_container:
|
||||||
|
return
|
||||||
|
|
||||||
|
for c in my_listings_container.get_children():
|
||||||
|
my_listings_container.remove_child(c)
|
||||||
|
c.queue_free()
|
||||||
|
|
||||||
|
if listings.is_empty():
|
||||||
|
var empty_lbl := Label.new()
|
||||||
|
empty_lbl.text = "No listings found."
|
||||||
|
empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
|
||||||
|
my_listings_container.add_child(empty_lbl)
|
||||||
|
return
|
||||||
|
|
||||||
|
for l in listings:
|
||||||
|
if not l is Dictionary:
|
||||||
|
continue
|
||||||
|
var row := PanelContainer.new()
|
||||||
|
var hbox := HBoxContainer.new()
|
||||||
|
hbox.add_theme_constant_override("separation", 12)
|
||||||
|
row.add_child(hbox)
|
||||||
|
|
||||||
|
var cat_item: Dictionary = l.get("catalog_item", {})
|
||||||
|
var name_lbl := Label.new()
|
||||||
|
name_lbl.text = str(cat_item.get("name", "Item"))
|
||||||
|
name_lbl.custom_minimum_size = Vector2(160, 0)
|
||||||
|
name_lbl.add_theme_font_size_override("font_size", 14)
|
||||||
|
hbox.add_child(name_lbl)
|
||||||
|
|
||||||
|
var type_lbl := Label.new()
|
||||||
|
type_lbl.text = str(cat_item.get("item_type", "")).replace("_", " ").capitalize()
|
||||||
|
type_lbl.custom_minimum_size = Vector2(120, 0)
|
||||||
|
type_lbl.add_theme_color_override("font_color", Color(0.5, 0.7, 1.0))
|
||||||
|
hbox.add_child(type_lbl)
|
||||||
|
|
||||||
|
var price_lbl := Label.new()
|
||||||
|
price_lbl.text = "%d CR" % int(l.get("price", 0))
|
||||||
|
price_lbl.custom_minimum_size = Vector2(80, 0)
|
||||||
|
price_lbl.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2))
|
||||||
|
hbox.add_child(price_lbl)
|
||||||
|
|
||||||
|
var status_lbl_item := Label.new()
|
||||||
|
var l_status: String = str(l.get("status", ""))
|
||||||
|
status_lbl_item.text = l_status.capitalize()
|
||||||
|
status_lbl_item.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
hbox.add_child(status_lbl_item)
|
||||||
|
|
||||||
|
var listing_id: int = int(l.get("id", 0))
|
||||||
|
if l_status == "active":
|
||||||
|
var delist_btn := Button.new()
|
||||||
|
delist_btn.text = "Delist"
|
||||||
|
delist_btn.pressed.connect(_on_delist_pressed.bind(listing_id))
|
||||||
|
hbox.add_child(delist_btn)
|
||||||
|
|
||||||
|
my_listings_container.add_child(row)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_delist_pressed(listing_id: int) -> void:
|
||||||
|
var api = _get_api()
|
||||||
|
if not api or not api.has_token():
|
||||||
|
return
|
||||||
|
show_status("Delisting listing #%d..." % listing_id)
|
||||||
|
var res: Dictionary = await EconomyApiClient.delist_listing(api, listing_id)
|
||||||
|
if res.ok:
|
||||||
|
show_status("Listing #%d delisted" % listing_id)
|
||||||
|
refresh_my_listings()
|
||||||
|
else:
|
||||||
|
show_status("Failed to delist: " + res.detail, true)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Creator Balances & Payouts ──
|
||||||
|
|
||||||
|
func refresh_creator_balance() -> void:
|
||||||
|
var api = _get_api()
|
||||||
|
if not api or not api.has_token():
|
||||||
|
return
|
||||||
|
var res: Dictionary = await EconomyApiClient.get_creator_balance(api)
|
||||||
|
if res.ok and res.data is Dictionary:
|
||||||
|
var avail: int = int(res.data.get("available", 0))
|
||||||
|
var pend: int = int(res.data.get("pending", 0))
|
||||||
|
var life: int = int(res.data.get("lifetime_earned", 0))
|
||||||
|
|
||||||
|
if available_lbl: available_lbl.text = "%d CR" % avail
|
||||||
|
if pending_lbl: pending_lbl.text = "%d CR" % pend
|
||||||
|
if lifetime_lbl: lifetime_lbl.text = "%d CR" % life
|
||||||
|
|
||||||
|
|
||||||
|
func refresh_payouts() -> void:
|
||||||
|
var api = _get_api()
|
||||||
|
if not api or not api.has_token():
|
||||||
|
return
|
||||||
|
var res: Dictionary = await EconomyApiClient.get_payouts(api)
|
||||||
|
if res.ok and res.data is Array:
|
||||||
|
render_payouts(res.data)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_request_payout_pressed() -> void:
|
||||||
|
var api = _get_api()
|
||||||
|
if not api or not api.has_token():
|
||||||
|
show_status("Please login to request payouts", true)
|
||||||
|
return
|
||||||
|
|
||||||
|
var amt_str: String = payout_amount_input.text.strip_edges() if payout_amount_input else ""
|
||||||
|
if not amt_str.is_valid_int() or amt_str.to_int() < 100:
|
||||||
|
show_status("Minimum payout amount is 100 Credits", true)
|
||||||
|
return
|
||||||
|
var amount: int = amt_str.to_int()
|
||||||
|
|
||||||
|
var dest: String = payout_dest_input.text.strip_edges() if payout_dest_input else ""
|
||||||
|
show_status("Submitting payout request for %d CR..." % amount)
|
||||||
|
var res: Dictionary = await EconomyApiClient.request_payout(api, amount, dest)
|
||||||
|
if res.ok:
|
||||||
|
show_status("Payout request for %d CR submitted successfully!" % amount)
|
||||||
|
if payout_amount_input: payout_amount_input.text = ""
|
||||||
|
if payout_dest_input: payout_dest_input.text = ""
|
||||||
|
refresh_creator_balance()
|
||||||
|
refresh_payouts()
|
||||||
|
else:
|
||||||
|
show_status("Payout request failed: " + res.detail, true)
|
||||||
|
|
||||||
|
|
||||||
|
func render_payouts(payouts: Array) -> void:
|
||||||
|
if not payouts_container:
|
||||||
|
return
|
||||||
|
|
||||||
|
for c in payouts_container.get_children():
|
||||||
|
payouts_container.remove_child(c)
|
||||||
|
c.queue_free()
|
||||||
|
|
||||||
|
if payouts.is_empty():
|
||||||
|
var empty_lbl := Label.new()
|
||||||
|
empty_lbl.text = "No payout history."
|
||||||
|
empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
|
||||||
|
payouts_container.add_child(empty_lbl)
|
||||||
|
return
|
||||||
|
|
||||||
|
for p in payouts:
|
||||||
|
if not p is Dictionary:
|
||||||
|
continue
|
||||||
|
var row := PanelContainer.new()
|
||||||
|
var hbox := HBoxContainer.new()
|
||||||
|
hbox.add_theme_constant_override("separation", 12)
|
||||||
|
row.add_child(hbox)
|
||||||
|
|
||||||
|
var date_str: String = str(p.get("created_at", ""))
|
||||||
|
if date_str.length() > 19:
|
||||||
|
date_str = date_str.substr(0, 19).replace("T", " ")
|
||||||
|
var date_lbl := Label.new()
|
||||||
|
date_lbl.text = date_str
|
||||||
|
date_lbl.custom_minimum_size = Vector2(140, 0)
|
||||||
|
date_lbl.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
|
||||||
|
hbox.add_child(date_lbl)
|
||||||
|
|
||||||
|
var amt_lbl := Label.new()
|
||||||
|
amt_lbl.text = "%d CR" % int(p.get("amount", 0))
|
||||||
|
amt_lbl.custom_minimum_size = Vector2(90, 0)
|
||||||
|
amt_lbl.add_theme_font_size_override("font_size", 14)
|
||||||
|
hbox.add_child(amt_lbl)
|
||||||
|
|
||||||
|
var status_str: String = str(p.get("status", "")).capitalize()
|
||||||
|
var stat_lbl := Label.new()
|
||||||
|
stat_lbl.text = status_str
|
||||||
|
stat_lbl.custom_minimum_size = Vector2(100, 0)
|
||||||
|
if status_str == "Paid":
|
||||||
|
stat_lbl.add_theme_color_override("font_color", Color(0.2, 0.9, 0.2))
|
||||||
|
elif status_str == "Rejected":
|
||||||
|
stat_lbl.add_theme_color_override("font_color", Color(0.9, 0.3, 0.3))
|
||||||
|
else:
|
||||||
|
stat_lbl.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2))
|
||||||
|
hbox.add_child(stat_lbl)
|
||||||
|
|
||||||
|
var ref_lbl := Label.new()
|
||||||
|
var ext_ref: String = str(p.get("external_ref", ""))
|
||||||
|
var prov_ref: String = str(p.get("provider_ref", ""))
|
||||||
|
if prov_ref != "":
|
||||||
|
ref_lbl.text = "Ref: %s" % prov_ref
|
||||||
|
elif ext_ref != "":
|
||||||
|
ref_lbl.text = "Dest: %s" % ext_ref
|
||||||
|
else:
|
||||||
|
ref_lbl.text = "-"
|
||||||
|
ref_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
ref_lbl.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5))
|
||||||
|
hbox.add_child(ref_lbl)
|
||||||
|
|
||||||
|
payouts_container.add_child(row)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Headless smoke test for SellingPayoutUI scene and script.
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
print("[SMOKE] Starting SellingPayoutUI smoke test...")
|
||||||
|
|
||||||
|
var scene = load("res://scenes/selling_payout_ui.tscn")
|
||||||
|
if not scene:
|
||||||
|
printerr("[FAIL] Could not load scenes/selling_payout_ui.tscn")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
var ui: Control = scene.instantiate()
|
||||||
|
root.add_child(ui)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
# Verify onready nodes
|
||||||
|
assert(ui.back_button != null, "Missing back_button")
|
||||||
|
assert(ui.refresh_button != null, "Missing refresh_button")
|
||||||
|
assert(ui.status_label != null, "Missing status_label")
|
||||||
|
assert(ui.tab_container != null, "Missing tab_container")
|
||||||
|
|
||||||
|
# Selling nodes
|
||||||
|
assert(ui.item_name_input != null, "Missing item_name_input")
|
||||||
|
assert(ui.item_type_option != null, "Missing item_type_option")
|
||||||
|
assert(ui.item_desc_input != null, "Missing item_desc_input")
|
||||||
|
assert(ui.price_input != null, "Missing price_input")
|
||||||
|
assert(ui.create_listing_btn != null, "Missing create_listing_btn")
|
||||||
|
assert(ui.my_listings_container != null, "Missing my_listings_container")
|
||||||
|
|
||||||
|
# Payout nodes
|
||||||
|
assert(ui.available_lbl != null, "Missing available_lbl")
|
||||||
|
assert(ui.pending_lbl != null, "Missing pending_lbl")
|
||||||
|
assert(ui.lifetime_lbl != null, "Missing lifetime_lbl")
|
||||||
|
assert(ui.payout_amount_input != null, "Missing payout_amount_input")
|
||||||
|
assert(ui.payout_dest_input != null, "Missing payout_dest_input")
|
||||||
|
assert(ui.request_payout_btn != null, "Missing request_payout_btn")
|
||||||
|
assert(ui.payouts_container != null, "Missing payouts_container")
|
||||||
|
|
||||||
|
# Test rendering sample listings
|
||||||
|
var sample_listings = [
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"price": 300,
|
||||||
|
"status": "active",
|
||||||
|
"catalog_item": {
|
||||||
|
"name": "Knight Helmet",
|
||||||
|
"item_type": "avatar_item"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
ui.render_my_listings(sample_listings)
|
||||||
|
assert(ui.my_listings_container.get_child_count() == 1, "Should render 1 listing row")
|
||||||
|
|
||||||
|
# Test rendering sample payouts
|
||||||
|
var sample_payouts = [
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"amount": 250,
|
||||||
|
"status": "paid",
|
||||||
|
"created_at": "2026-09-15T15:00:00Z",
|
||||||
|
"provider_ref": "stripe_123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"amount": 100,
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-09-15T16:00:00Z",
|
||||||
|
"external_ref": "paypal@creator.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
ui.render_payouts(sample_payouts)
|
||||||
|
assert(ui.payouts_container.get_child_count() == 2, "Should render 2 payout rows")
|
||||||
|
|
||||||
|
# Verify signals
|
||||||
|
assert(ui.has_signal("back_requested"), "Missing back_requested signal")
|
||||||
|
|
||||||
|
ui.queue_free()
|
||||||
|
print("[SMOKE] SellingPayoutUI smoke test PASSED")
|
||||||
|
quit(0)
|
||||||
Reference in New Issue
Block a user