Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f891a958a9 | |||
| 69b9cd5d8f | |||
| a9144a1160 | |||
| 6afee66b83 | |||
| 434f776f9a | |||
| f95edb6713 | |||
| 6684578701 | |||
| 77d4ea5669 | |||
| dd4edecf11 | |||
| bddee1b96d | |||
| 38dc0cab21 | |||
| 892e330456 | |||
| e3188b6123 | |||
| f8b1fc4ea7 | |||
| 041b5f907e | |||
| 603ecdf5e2 | |||
| ffa9a9e6da | |||
| a4aa50edb9 | |||
| 30ff63f3fd | |||
| 5c4a45f3c9 | |||
| 15ac91c42f | |||
| 70564ff799 |
@@ -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,64 @@
|
|||||||
|
# Package Checkpoint 4 (PC4) — "A world you can build and be social in"
|
||||||
|
|
||||||
|
> **Status: Complete & Verified.** PC4 delivers the integrated multi-user social world platform: in-game building and persistence (M2, M7), comprehensive social graph, chat, and presence (M8), virtual economy, marketplace, and creator payouts (M9), and home discovery with search, category filtering, and trending heuristics (M10).
|
||||||
|
|
||||||
|
Built and verified by the Antigravity fleet, branch `agent/2b4212c24690-pc4-checkpoint` → `main`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What's Real and Verified (`scripts/run_checkpoint_tests.py`)
|
||||||
|
|
||||||
|
| Check / Subsystem | Status | Verification Detail |
|
||||||
|
|---|---|---|
|
||||||
|
| Alembic Migrations (0001 → 0015) | PASS | Hermetic clean run, head revision `0015_add_discovery_metadata` |
|
||||||
|
| Backend API Test Suite | PASS | 100+ tests across auth, worlds, social, chat, wallet, marketplace, discovery |
|
||||||
|
| Godot Headless Smoke Tests | PASS | 20+ headless smoke suites covering all UIs, network, avatars, economy, discovery |
|
||||||
|
| 2-Player Multiplayer (`run_multiplayer_2p_test.sh`) | PASS | Real dedicated server + 2 simultaneous Godot client processes |
|
||||||
|
| Fresh-Clone Boot (`verify_fresh_clone.sh`) | PASS | Clean clone imports, compiles, and boots without script errors |
|
||||||
|
| Windows Desktop .exe Export | PASS | Packaged release binary with baked-in backend endpoint via `checkpoint_ops.py` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Milestone Capabilities Included
|
||||||
|
|
||||||
|
### M2 & M7 — World Persistence & In-Game Editor
|
||||||
|
- **Server Persistence**: World rooms, chunk coordinates (cx, cy, cz), binary chunk payload codec, chunk revision audit trail.
|
||||||
|
- **In-Game Editor**: Tools (Brush, Erase, Object Placement, Select, Transform), chunk edit model, undo/redo stack, non-blocking HTTP chunk saving.
|
||||||
|
|
||||||
|
### M8 — Social: Friends, Presence, Chat, Groups & Profiles
|
||||||
|
- **Social Graph**: Friend requests (pending, accepted, declined), follow/unfollow, bidirectional block enforcement.
|
||||||
|
- **Presence & Status**: Real-time Redis-backed heartbeats with custom status messages, friends presence queries.
|
||||||
|
- **Chat System**: 1-on-1 direct messaging, in-world spatial chat, chat moderation report queue with profanity filtering.
|
||||||
|
- **Groups**: Group creation, membership roles (owner, admin, member), member management, group transfer.
|
||||||
|
- **Public Profiles**: Bio, custom avatar rendering, favorite worlds listing, privacy controls.
|
||||||
|
- **WebSocket Gateway**: Dual endpoint (`/api/ws`, `/v1/ws`) broadcasting presence and real-time chat messages.
|
||||||
|
- **Godot Client & UIs**: `FriendsPresenceUI`, `ChatUI`, `ProfileUI`, `GroupsUI`.
|
||||||
|
|
||||||
|
### M9 — Economy: Wallets, Marketplace & Creator Payouts
|
||||||
|
- **Wallets & Ledger**: Double-entry accounting for balance credits, debits, transfers, and escrow.
|
||||||
|
- **Purchases**: `PaymentProvider` abstraction with simulated and external payment confirmations.
|
||||||
|
- **Marketplace**: Catalog item models, active listings, 30/70 platform/creator fee splits with atomic escrow settlement.
|
||||||
|
- **Creator Payouts**: Payout request lifecycle (`pending` → `approved` / `rejected` → `paid`), admin audit review.
|
||||||
|
- **Godot Client & UIs**: `WalletUI` (balance & transaction history), `MarketplaceUI` (browse & buy), `SellingPayoutUI` (create listings & request payouts).
|
||||||
|
|
||||||
|
### M10 — Discovery: Browse, Search & Home Feed
|
||||||
|
- **World Discovery Metadata**: Categories, tags (ARRAY with GIN indexing), play count, and last played timestamp.
|
||||||
|
- **Visit Tracking**: Atomic increment of `play_count` and `world_visits` log with request deduplication.
|
||||||
|
- **Browse & Search API**: Full-text name ILIKE query, category exact match, tag overlap (`&&`), sorting (`newest`, `most_played`, `trending`).
|
||||||
|
- **Trending Heuristic**: Time-decay weighted scoring over recent visits (last 24h: 3x, 3d: 2x, 7d: 1x) via single aggregate outer-join query.
|
||||||
|
- **Godot Client & UI**: `DiscoveryApiClient` and `DiscoveryUI` (`scenes/discovery_ui.tscn`) with live search, dropdown filters, pagination, and one-click world entry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Playtest Guide
|
||||||
|
|
||||||
|
1. **Dashboard Start**:
|
||||||
|
- Open http://localhost:7070/checkpoints
|
||||||
|
- Locate **PC4: A world you can build and be social in**
|
||||||
|
- Click **Start Backend** (launches backend on `:28004`, Postgres on `:25544`, ENet game servers on `27100–27149`)
|
||||||
|
- The backend automatically seeds the `playtester` account and 5 sample worlds across categories.
|
||||||
|
2. **Download & Play**:
|
||||||
|
- Click **Download Windows .exe**
|
||||||
|
- Launch `agp-game.exe` (automatically targets the PC4 backend instance)
|
||||||
|
- Browse the **Discover** tab to see curated, trending, and categorized worlds
|
||||||
|
- Enter worlds, socialize, chat, trade in the marketplace, and build.
|
||||||
@@ -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,96 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/marketplace_ui.gd" id="1_mpu"]
|
||||||
|
|
||||||
|
[node name="MarketplaceUI" 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_mpu")
|
||||||
|
|
||||||
|
[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 = "Marketplace"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="BalanceLabel" type="Label" parent="VBox/HeaderHBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
theme_override_colors/font_color = Color(1, 0.84, 0, 1)
|
||||||
|
text = "Wallet: 0 CR"
|
||||||
|
|
||||||
|
[node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"]
|
||||||
|
custom_minimum_size = Vector2(100, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Refresh"
|
||||||
|
|
||||||
|
[node name="FilterBar" type="HBoxContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="SearchEdit" type="LineEdit" parent="VBox/FilterBar"]
|
||||||
|
custom_minimum_size = Vector2(200, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
placeholder_text = "Search items by name or description..."
|
||||||
|
|
||||||
|
[node name="TypeOption" type="OptionButton" parent="VBox/FilterBar"]
|
||||||
|
custom_minimum_size = Vector2(140, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="MinPriceEdit" type="LineEdit" parent="VBox/FilterBar"]
|
||||||
|
custom_minimum_size = Vector2(90, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
placeholder_text = "Min CR"
|
||||||
|
|
||||||
|
[node name="MaxPriceEdit" type="LineEdit" parent="VBox/FilterBar"]
|
||||||
|
custom_minimum_size = Vector2(90, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
placeholder_text = "Max CR"
|
||||||
|
|
||||||
|
[node name="SearchBtn" type="Button" parent="VBox/FilterBar"]
|
||||||
|
custom_minimum_size = Vector2(90, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Search"
|
||||||
|
|
||||||
|
[node name="StatusLabel" type="Label" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="Scroll" type="ScrollContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[node name="ListingsGrid" type="GridContainer" parent="VBox/Scroll"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/h_separation = 16
|
||||||
|
theme_override_constants/v_separation = 16
|
||||||
|
columns = 4
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/wallet_ui.gd" id="1_wui"]
|
||||||
|
|
||||||
|
[node name="WalletUI" 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_wui")
|
||||||
|
|
||||||
|
[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 = 16
|
||||||
|
|
||||||
|
[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 = "Wallet & Credits"
|
||||||
|
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="BalanceCard" type="PanelContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Margin" type="MarginContainer" parent="VBox/BalanceCard"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/margin_left = 16
|
||||||
|
theme_override_constants/margin_top = 16
|
||||||
|
theme_override_constants/margin_right = 16
|
||||||
|
theme_override_constants/margin_bottom = 16
|
||||||
|
|
||||||
|
[node name="HBox" type="HBoxContainer" parent="VBox/BalanceCard/Margin"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 20
|
||||||
|
|
||||||
|
[node name="BalTitle" type="Label" parent="VBox/BalanceCard/Margin/HBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
text = "Available Balance:"
|
||||||
|
|
||||||
|
[node name="BalanceLabel" type="Label" parent="VBox/BalanceCard/Margin/HBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 22
|
||||||
|
text = "0 CR"
|
||||||
|
|
||||||
|
[node name="Spacer" type="Control" parent="VBox/BalanceCard/Margin/HBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="PacksHBox" type="HBoxContainer" parent="VBox/BalanceCard/Margin/HBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="Buy100Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "+100 CR ($0.99)"
|
||||||
|
|
||||||
|
[node name="Buy500Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "+500 CR ($4.99)"
|
||||||
|
|
||||||
|
[node name="Buy1000Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "+1,000 CR ($9.99)"
|
||||||
|
|
||||||
|
[node name="Buy5000Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "+5,000 CR ($44.99)"
|
||||||
|
|
||||||
|
[node name="LedgerSection" type="VBoxContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="LedgerTitle" type="Label" parent="VBox/LedgerSection"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
text = "Ledger History"
|
||||||
|
|
||||||
|
[node name="Scroll" type="ScrollContainer" parent="VBox/LedgerSection"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[node name="TxList" type="VBoxContainer" parent="VBox/LedgerSection/Scroll"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 6
|
||||||
|
|
||||||
|
[node name="PagingHBox" type="HBoxContainer" parent="VBox/LedgerSection"]
|
||||||
|
layout_mode = 2
|
||||||
|
alignment = 1
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
|
||||||
|
[node name="PrevPageBtn" type="Button" parent="VBox/LedgerSection/PagingHBox"]
|
||||||
|
custom_minimum_size = Vector2(80, 32)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Previous"
|
||||||
|
|
||||||
|
[node name="PageLabel" type="Label" parent="VBox/LedgerSection/PagingHBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Page 1 of 1"
|
||||||
|
|
||||||
|
[node name="NextPageBtn" type="Button" parent="VBox/LedgerSection/PagingHBox"]
|
||||||
|
custom_minimum_size = Vector2(80, 32)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Next"
|
||||||
+29
-1
@@ -138,7 +138,8 @@ func request(
|
|||||||
path: String,
|
path: String,
|
||||||
body: String = "",
|
body: String = "",
|
||||||
content_type: String = "application/json",
|
content_type: String = "application/json",
|
||||||
require_auth: bool = false
|
require_auth: bool = false,
|
||||||
|
custom_headers: PackedStringArray = PackedStringArray()
|
||||||
) -> Dictionary:
|
) -> Dictionary:
|
||||||
var http := HTTPRequest.new()
|
var http := HTTPRequest.new()
|
||||||
http.timeout = timeout_seconds
|
http.timeout = timeout_seconds
|
||||||
@@ -152,6 +153,8 @@ func request(
|
|||||||
headers.append("Content-Type: " + content_type)
|
headers.append("Content-Type: " + content_type)
|
||||||
if require_auth and has_token():
|
if require_auth and has_token():
|
||||||
headers.append("Authorization: " + bearer_token)
|
headers.append("Authorization: " + bearer_token)
|
||||||
|
for ch in custom_headers:
|
||||||
|
headers.append(ch)
|
||||||
|
|
||||||
var url := _base_url + path
|
var url := _base_url + path
|
||||||
var err := http.request(url, headers, method, body)
|
var err := http.request(url, headers, method, body)
|
||||||
@@ -453,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,142 @@
|
|||||||
|
extends RefCounted
|
||||||
|
class_name EconomyApiClient
|
||||||
|
## Helper class for AGP Economy REST endpoints (Wallet, Payments, Marketplace, Payouts).
|
||||||
|
##
|
||||||
|
## docs/planning/economy.md US-6 (task cc09c1428892).
|
||||||
|
|
||||||
|
static func get_api_client(node: Node) -> Node:
|
||||||
|
if node.has_node("/root/ApiClient"):
|
||||||
|
return node.get_node("/root/ApiClient")
|
||||||
|
return null
|
||||||
|
|
||||||
|
static func generate_idempotency_key() -> String:
|
||||||
|
return "%d-%d" % [Time.get_unix_time_from_system(), randi() % 1000000]
|
||||||
|
|
||||||
|
# ── Wallet & Currency ──
|
||||||
|
|
||||||
|
static func get_wallet(api: Node) -> Dictionary:
|
||||||
|
return await api.request(HTTPClient.METHOD_GET, "/api/wallet", "", "application/json", true)
|
||||||
|
|
||||||
|
static func get_wallet_transactions(api: Node, limit: int = 50, offset: int = 0) -> Dictionary:
|
||||||
|
return await api.request(HTTPClient.METHOD_GET, "/api/wallet/transactions?limit=%d&offset=%d" % [limit, offset], "", "application/json", true)
|
||||||
|
|
||||||
|
# ── Premium Purchases ──
|
||||||
|
|
||||||
|
static func purchase_credits(api: Node, credits: int, idempotency_key: String = "") -> Dictionary:
|
||||||
|
var custom_headers := PackedStringArray()
|
||||||
|
var key := idempotency_key if idempotency_key != "" else generate_idempotency_key()
|
||||||
|
custom_headers.append("Idempotency-Key: " + key)
|
||||||
|
|
||||||
|
var sess_res: Dictionary = await api.request(
|
||||||
|
HTTPClient.METHOD_POST,
|
||||||
|
"/api/payments/purchase",
|
||||||
|
JSON.stringify({"credits": credits}),
|
||||||
|
"application/json",
|
||||||
|
true,
|
||||||
|
custom_headers
|
||||||
|
)
|
||||||
|
if not sess_res.ok:
|
||||||
|
return sess_res
|
||||||
|
|
||||||
|
var sess_id: String = str(sess_res.data.get("session_id", ""))
|
||||||
|
if sess_id == "":
|
||||||
|
return {"ok": false, "status": 0, "data": null, "detail": "Invalid session returned"}
|
||||||
|
|
||||||
|
return await api.request(
|
||||||
|
HTTPClient.METHOD_POST,
|
||||||
|
"/api/payments/confirm",
|
||||||
|
JSON.stringify({"session_id": sess_id}),
|
||||||
|
"application/json",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Catalog Items & Listings ──
|
||||||
|
|
||||||
|
static func get_catalog_items(api: Node, mine: bool = false, item_type: String = "", limit: int = 50, offset: int = 0) -> Dictionary:
|
||||||
|
var q := "/api/marketplace/items?limit=%d&offset=%d" % [limit, offset]
|
||||||
|
if mine:
|
||||||
|
q += "&mine=true"
|
||||||
|
if item_type != "":
|
||||||
|
q += "&item_type=" + item_type.uri_encode()
|
||||||
|
return await api.request(HTTPClient.METHOD_GET, q, "", "application/json", true)
|
||||||
|
|
||||||
|
static func create_catalog_item(api: Node, name: String, item_type: String, description: String = "", metadata_json: Dictionary = {}) -> Dictionary:
|
||||||
|
var payload := {
|
||||||
|
"name": name,
|
||||||
|
"item_type": item_type,
|
||||||
|
"description": description,
|
||||||
|
"metadata_json": metadata_json
|
||||||
|
}
|
||||||
|
return await api.request(HTTPClient.METHOD_POST, "/api/marketplace/items", JSON.stringify(payload), "application/json", true)
|
||||||
|
|
||||||
|
static func create_listing(api: Node, catalog_item_id: int, price: int) -> Dictionary:
|
||||||
|
var payload := {
|
||||||
|
"catalog_item_id": catalog_item_id,
|
||||||
|
"price": price
|
||||||
|
}
|
||||||
|
return await api.request(HTTPClient.METHOD_POST, "/api/marketplace/listings", JSON.stringify(payload), "application/json", true)
|
||||||
|
|
||||||
|
static func search_listings(api: Node, query: String = "", item_type: String = "", min_price: int = -1, max_price: int = -1, limit: int = 50, offset: int = 0) -> Dictionary:
|
||||||
|
var q := "/api/marketplace/listings?limit=%d&offset=%d" % [limit, offset]
|
||||||
|
if query != "":
|
||||||
|
q += "&q=" + query.uri_encode()
|
||||||
|
if item_type != "":
|
||||||
|
q += "&item_type=" + item_type.uri_encode()
|
||||||
|
if min_price >= 0:
|
||||||
|
q += "&min_price=%d" % min_price
|
||||||
|
if max_price >= 0:
|
||||||
|
q += "&max_price=%d" % max_price
|
||||||
|
return await api.request(HTTPClient.METHOD_GET, q, "", "application/json", false)
|
||||||
|
|
||||||
|
static func get_listing(api: Node, listing_id: int) -> Dictionary:
|
||||||
|
return await api.request(HTTPClient.METHOD_GET, "/api/marketplace/listings/%d" % listing_id, "", "application/json", false)
|
||||||
|
|
||||||
|
static func update_listing(api: Node, listing_id: int, price: int = -1, status: String = "") -> Dictionary:
|
||||||
|
var payload := {}
|
||||||
|
if price > 0:
|
||||||
|
payload["price"] = price
|
||||||
|
if status != "":
|
||||||
|
payload["status"] = status
|
||||||
|
return await api.request(HTTPClient.METHOD_PATCH, "/api/marketplace/listings/%d" % listing_id, JSON.stringify(payload), "application/json", true)
|
||||||
|
|
||||||
|
static func delist_listing(api: Node, listing_id: int) -> Dictionary:
|
||||||
|
return await api.request(HTTPClient.METHOD_DELETE, "/api/marketplace/listings/%d" % listing_id, "", "application/json", true)
|
||||||
|
|
||||||
|
# ── Purchases & Escrow ──
|
||||||
|
|
||||||
|
static func purchase_listing(api: Node, listing_id: int, idempotency_key: String = "") -> Dictionary:
|
||||||
|
var custom_headers := PackedStringArray()
|
||||||
|
var key := idempotency_key if idempotency_key != "" else generate_idempotency_key()
|
||||||
|
custom_headers.append("Idempotency-Key: " + key)
|
||||||
|
|
||||||
|
return await api.request(
|
||||||
|
HTTPClient.METHOD_POST,
|
||||||
|
"/api/marketplace/listings/%d/purchase" % listing_id,
|
||||||
|
"",
|
||||||
|
"application/json",
|
||||||
|
true,
|
||||||
|
custom_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
static func get_purchases(api: Node, role: String = "all", limit: int = 50, offset: int = 0) -> Dictionary:
|
||||||
|
return await api.request(
|
||||||
|
HTTPClient.METHOD_GET,
|
||||||
|
"/api/marketplace/purchases?role=%s&limit=%d&offset=%d" % [role.uri_encode(), limit, offset],
|
||||||
|
"",
|
||||||
|
"application/json",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Creator Balances & Payouts ──
|
||||||
|
|
||||||
|
static func get_creator_balance(api: Node) -> Dictionary:
|
||||||
|
return await api.request(HTTPClient.METHOD_GET, "/api/marketplace/creator/balance", "", "application/json", true)
|
||||||
|
|
||||||
|
static func request_payout(api: Node, amount: int, external_ref: String = "") -> Dictionary:
|
||||||
|
var payload := {"amount": amount}
|
||||||
|
if external_ref != "":
|
||||||
|
payload["external_ref"] = external_ref
|
||||||
|
return await api.request(HTTPClient.METHOD_POST, "/api/payouts", JSON.stringify(payload), "application/json", true)
|
||||||
|
|
||||||
|
static func get_payouts(api: Node, limit: int = 50, offset: int = 0) -> Dictionary:
|
||||||
|
return await api.request(HTTPClient.METHOD_GET, "/api/payouts?limit=%d&offset=%d" % [limit, offset], "", "application/json", true)
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
extends Control
|
||||||
|
class_name MarketplaceUI
|
||||||
|
## Marketplace Browse & Buy UI panel.
|
||||||
|
##
|
||||||
|
## Displays:
|
||||||
|
## - Search bar with keyword, item type, and price filters
|
||||||
|
## - Marketplace active listings cards
|
||||||
|
## - Item details and direct atomic purchase flow
|
||||||
|
## - Wallet balance and real-time status feedback
|
||||||
|
##
|
||||||
|
## docs/planning/economy.md US-6 (task 9797a0cfd9e6).
|
||||||
|
|
||||||
|
signal back_requested
|
||||||
|
signal item_purchased(listing_id: int)
|
||||||
|
|
||||||
|
@onready var back_button: Button = $VBox/HeaderHBox/BackButton
|
||||||
|
@onready var refresh_button: Button = $VBox/HeaderHBox/RefreshButton
|
||||||
|
@onready var balance_label: Label = $VBox/HeaderHBox/BalanceLabel
|
||||||
|
@onready var status_label: Label = $VBox/StatusLabel
|
||||||
|
|
||||||
|
# Filter inputs
|
||||||
|
@onready var search_edit: LineEdit = $VBox/FilterBar/SearchEdit
|
||||||
|
@onready var type_option: OptionButton = $VBox/FilterBar/TypeOption
|
||||||
|
@onready var min_price_edit: LineEdit = $VBox/FilterBar/MinPriceEdit
|
||||||
|
@onready var max_price_edit: LineEdit = $VBox/FilterBar/MaxPriceEdit
|
||||||
|
@onready var search_btn: Button = $VBox/FilterBar/SearchBtn
|
||||||
|
|
||||||
|
# Listings container
|
||||||
|
@onready var listings_container: GridContainer = $VBox/Scroll/ListingsGrid
|
||||||
|
|
||||||
|
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_balance()
|
||||||
|
search_listings()
|
||||||
|
|
||||||
|
|
||||||
|
func _get_api() -> Node:
|
||||||
|
if has_node("/root/ApiClient"):
|
||||||
|
return get_node("/root/ApiClient")
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func _setup_type_options() -> void:
|
||||||
|
if not type_option:
|
||||||
|
return
|
||||||
|
type_option.clear()
|
||||||
|
type_option.add_item("All Types", 0)
|
||||||
|
type_option.add_item("Avatar Item", 1)
|
||||||
|
type_option.add_item("Asset", 2)
|
||||||
|
type_option.add_item("Experience Pass", 3)
|
||||||
|
|
||||||
|
|
||||||
|
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 search_btn and not search_btn.pressed.is_connected(search_listings):
|
||||||
|
search_btn.pressed.connect(search_listings)
|
||||||
|
if search_edit and not search_edit.text_submitted.is_connected(func(_txt): search_listings()):
|
||||||
|
search_edit.text_submitted.connect(func(_txt): search_listings())
|
||||||
|
|
||||||
|
|
||||||
|
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_balance()
|
||||||
|
search_listings()
|
||||||
|
|
||||||
|
|
||||||
|
func refresh_balance() -> void:
|
||||||
|
var api = _get_api()
|
||||||
|
if not api or not api.has_token():
|
||||||
|
return
|
||||||
|
var res: Dictionary = await EconomyApiClient.get_wallet(api)
|
||||||
|
if res.ok and res.data is Dictionary:
|
||||||
|
var balance: int = int(res.data.get("balance", 0))
|
||||||
|
if balance_label:
|
||||||
|
balance_label.text = "Wallet: %d CR" % balance
|
||||||
|
|
||||||
|
|
||||||
|
func search_listings() -> void:
|
||||||
|
var api = _get_api()
|
||||||
|
if not api:
|
||||||
|
return
|
||||||
|
|
||||||
|
var q: String = search_edit.text.strip_edges() if search_edit else ""
|
||||||
|
var item_type: String = ""
|
||||||
|
if type_option and type_option.selected > 0:
|
||||||
|
match type_option.selected:
|
||||||
|
1: item_type = "avatar_item"
|
||||||
|
2: item_type = "asset"
|
||||||
|
3: item_type = "experience_pass"
|
||||||
|
|
||||||
|
var min_price: int = -1
|
||||||
|
if min_price_edit and min_price_edit.text.is_valid_int():
|
||||||
|
min_price = min_price_edit.text.to_int()
|
||||||
|
|
||||||
|
var max_price: int = -1
|
||||||
|
if max_price_edit and max_price_edit.text.is_valid_int():
|
||||||
|
max_price = max_price_edit.text.to_int()
|
||||||
|
|
||||||
|
show_status("Loading listings...")
|
||||||
|
var res: Dictionary = await EconomyApiClient.search_listings(api, q, item_type, min_price, max_price)
|
||||||
|
if res.ok and res.data is Dictionary:
|
||||||
|
var items: Array = res.data.get("listings", [])
|
||||||
|
render_listings(items)
|
||||||
|
show_status("Found %d active listings" % items.size())
|
||||||
|
else:
|
||||||
|
show_status("Search failed: " + res.detail, true)
|
||||||
|
|
||||||
|
|
||||||
|
func render_listings(listings: Array) -> void:
|
||||||
|
if not listings_container:
|
||||||
|
return
|
||||||
|
|
||||||
|
for c in listings_container.get_children():
|
||||||
|
listings_container.remove_child(c)
|
||||||
|
c.queue_free()
|
||||||
|
|
||||||
|
if listings.is_empty():
|
||||||
|
var empty_lbl := Label.new()
|
||||||
|
empty_lbl.text = "No listings matching your search."
|
||||||
|
empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
|
||||||
|
listings_container.add_child(empty_lbl)
|
||||||
|
return
|
||||||
|
|
||||||
|
for l in listings:
|
||||||
|
if not l is Dictionary:
|
||||||
|
continue
|
||||||
|
var card := _create_listing_card(l)
|
||||||
|
listings_container.add_child(card)
|
||||||
|
|
||||||
|
|
||||||
|
func _create_listing_card(listing: Dictionary) -> Control:
|
||||||
|
var card := PanelContainer.new()
|
||||||
|
card.custom_minimum_size = Vector2(240, 200)
|
||||||
|
|
||||||
|
var margin := MarginContainer.new()
|
||||||
|
margin.add_theme_constant_override("margin_left", 12)
|
||||||
|
margin.add_theme_constant_override("margin_top", 12)
|
||||||
|
margin.add_theme_constant_override("margin_right", 12)
|
||||||
|
margin.add_theme_constant_override("margin_bottom", 12)
|
||||||
|
card.add_child(margin)
|
||||||
|
|
||||||
|
var vbox := VBoxContainer.new()
|
||||||
|
vbox.add_theme_constant_override("separation", 8)
|
||||||
|
margin.add_child(vbox)
|
||||||
|
|
||||||
|
var cat_item: Dictionary = listing.get("catalog_item", {})
|
||||||
|
var title_lbl := Label.new()
|
||||||
|
title_lbl.text = str(cat_item.get("name", "Unnamed Item"))
|
||||||
|
title_lbl.add_theme_font_size_override("font_size", 16)
|
||||||
|
vbox.add_child(title_lbl)
|
||||||
|
|
||||||
|
var type_lbl := Label.new()
|
||||||
|
var itype: String = str(cat_item.get("item_type", "")).replace("_", " ").capitalize()
|
||||||
|
type_lbl.text = itype
|
||||||
|
type_lbl.add_theme_color_override("font_color", Color(0.4, 0.7, 1.0))
|
||||||
|
type_lbl.add_theme_font_size_override("font_size", 12)
|
||||||
|
vbox.add_child(type_lbl)
|
||||||
|
|
||||||
|
var seller_lbl := Label.new()
|
||||||
|
var seller_name: String = str(listing.get("seller_username", "Unknown"))
|
||||||
|
seller_lbl.text = "By " + seller_name
|
||||||
|
seller_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
|
||||||
|
seller_lbl.add_theme_font_size_override("font_size", 12)
|
||||||
|
vbox.add_child(seller_lbl)
|
||||||
|
|
||||||
|
var desc_lbl := Label.new()
|
||||||
|
var desc: String = str(cat_item.get("description", ""))
|
||||||
|
if desc.length() > 60:
|
||||||
|
desc = desc.substr(0, 57) + "..."
|
||||||
|
desc_lbl.text = desc
|
||||||
|
desc_lbl.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
desc_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
vbox.add_child(desc_lbl)
|
||||||
|
|
||||||
|
var bottom_hbox := HBoxContainer.new()
|
||||||
|
vbox.add_child(bottom_hbox)
|
||||||
|
|
||||||
|
var price: int = int(listing.get("price", 0))
|
||||||
|
var price_lbl := Label.new()
|
||||||
|
price_lbl.text = "%d CR" % price
|
||||||
|
price_lbl.add_theme_font_size_override("font_size", 16)
|
||||||
|
price_lbl.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2))
|
||||||
|
price_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
bottom_hbox.add_child(price_lbl)
|
||||||
|
|
||||||
|
var buy_btn := Button.new()
|
||||||
|
buy_btn.text = "Buy"
|
||||||
|
buy_btn.custom_minimum_size = Vector2(80, 32)
|
||||||
|
var listing_id: int = int(listing.get("id", 0))
|
||||||
|
buy_btn.pressed.connect(_on_buy_clicked.bind(listing_id, str(cat_item.get("name", "Item")), price))
|
||||||
|
bottom_hbox.add_child(buy_btn)
|
||||||
|
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
func _on_buy_clicked(listing_id: int, item_name: String, price: int) -> void:
|
||||||
|
var api = _get_api()
|
||||||
|
if not api or not api.has_token():
|
||||||
|
show_status("Please login to purchase items", true)
|
||||||
|
return
|
||||||
|
|
||||||
|
show_status("Purchasing '%s' for %d CR..." % [item_name, price])
|
||||||
|
var res: Dictionary = await EconomyApiClient.purchase_listing(api, listing_id)
|
||||||
|
if res.ok:
|
||||||
|
show_status("Successfully purchased '%s'! Item added to inventory." % item_name)
|
||||||
|
item_purchased.emit(listing_id)
|
||||||
|
refresh_all()
|
||||||
|
else:
|
||||||
|
show_status("Purchase failed: " + res.detail, true)
|
||||||
@@ -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,44 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Headless smoke test for EconomyApiClient.
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
print("[SMOKE] Starting EconomyApiClient smoke test...")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Verify idempotency key generation
|
||||||
|
var key1: String = EconomyApiClient.generate_idempotency_key()
|
||||||
|
var key2: String = EconomyApiClient.generate_idempotency_key()
|
||||||
|
assert(key1 != "", "Key should not be empty")
|
||||||
|
assert(key1 != key2, "Keys should be unique")
|
||||||
|
print("[PASS] Idempotency key generation: ", key1)
|
||||||
|
|
||||||
|
# Verify method existence on EconomyApiClient
|
||||||
|
var client = EconomyApiClient.new()
|
||||||
|
assert(client.has_method("get_wallet"), "Missing get_wallet")
|
||||||
|
assert(client.has_method("get_wallet_transactions"), "Missing get_wallet_transactions")
|
||||||
|
assert(client.has_method("purchase_credits"), "Missing purchase_credits")
|
||||||
|
assert(client.has_method("get_catalog_items"), "Missing get_catalog_items")
|
||||||
|
assert(client.has_method("create_catalog_item"), "Missing create_catalog_item")
|
||||||
|
assert(client.has_method("create_listing"), "Missing create_listing")
|
||||||
|
assert(client.has_method("search_listings"), "Missing search_listings")
|
||||||
|
assert(client.has_method("get_listing"), "Missing get_listing")
|
||||||
|
assert(client.has_method("update_listing"), "Missing update_listing")
|
||||||
|
assert(client.has_method("delist_listing"), "Missing delist_listing")
|
||||||
|
assert(client.has_method("purchase_listing"), "Missing purchase_listing")
|
||||||
|
assert(client.has_method("get_purchases"), "Missing get_purchases")
|
||||||
|
assert(client.has_method("get_creator_balance"), "Missing get_creator_balance")
|
||||||
|
assert(client.has_method("request_payout"), "Missing request_payout")
|
||||||
|
assert(client.has_method("get_payouts"), "Missing get_payouts")
|
||||||
|
print("[PASS] All EconomyApiClient methods verified")
|
||||||
|
|
||||||
|
api.queue_free()
|
||||||
|
print("[SMOKE] EconomyApiClient smoke test PASSED")
|
||||||
|
quit(0)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Headless smoke test for MarketplaceUI scene and script.
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
print("[SMOKE] Starting MarketplaceUI smoke test...")
|
||||||
|
|
||||||
|
var scene = load("res://scenes/marketplace_ui.tscn")
|
||||||
|
if not scene:
|
||||||
|
printerr("[FAIL] Could not load scenes/marketplace_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.balance_label != null, "Missing balance_label")
|
||||||
|
assert(ui.status_label != null, "Missing status_label")
|
||||||
|
assert(ui.search_edit != null, "Missing search_edit")
|
||||||
|
assert(ui.type_option != null, "Missing type_option")
|
||||||
|
assert(ui.min_price_edit != null, "Missing min_price_edit")
|
||||||
|
assert(ui.max_price_edit != null, "Missing max_price_edit")
|
||||||
|
assert(ui.search_btn != null, "Missing search_btn")
|
||||||
|
assert(ui.listings_container != null, "Missing listings_container")
|
||||||
|
|
||||||
|
# Verify type options populated
|
||||||
|
assert(ui.type_option.item_count >= 4, "Type options not populated")
|
||||||
|
|
||||||
|
# Test rendering empty listings
|
||||||
|
ui.render_listings([])
|
||||||
|
assert(ui.listings_container.get_child_count() == 1, "Should show empty notice")
|
||||||
|
|
||||||
|
# Test rendering sample listings
|
||||||
|
var sample_listings = [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"price": 200,
|
||||||
|
"seller_username": "merchant_bob",
|
||||||
|
"catalog_item": {
|
||||||
|
"name": "Wizard Robe",
|
||||||
|
"item_type": "avatar_item",
|
||||||
|
"description": "Flowing velvet robe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"price": 500,
|
||||||
|
"seller_username": "dungeon_master",
|
||||||
|
"catalog_item": {
|
||||||
|
"name": "Castle Map",
|
||||||
|
"item_type": "asset",
|
||||||
|
"description": "Detailed 3D fortress map"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
ui.render_listings(sample_listings)
|
||||||
|
assert(ui.listings_container.get_child_count() == 2, "Should render 2 listing cards")
|
||||||
|
|
||||||
|
# Verify signals
|
||||||
|
assert(ui.has_signal("back_requested"), "Missing back_requested signal")
|
||||||
|
assert(ui.has_signal("item_purchased"), "Missing item_purchased signal")
|
||||||
|
|
||||||
|
ui.queue_free()
|
||||||
|
print("[SMOKE] MarketplaceUI 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)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Headless smoke test for WalletUI scene and script.
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
print("[SMOKE] Starting WalletUI smoke test...")
|
||||||
|
|
||||||
|
var scene = load("res://scenes/wallet_ui.tscn")
|
||||||
|
if not scene:
|
||||||
|
printerr("[FAIL] Could not load scenes/wallet_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.balance_label != null, "Missing balance_label")
|
||||||
|
assert(ui.buy_100_btn != null, "Missing buy_100_btn")
|
||||||
|
assert(ui.buy_500_btn != null, "Missing buy_500_btn")
|
||||||
|
assert(ui.buy_1000_btn != null, "Missing buy_1000_btn")
|
||||||
|
assert(ui.buy_5000_btn != null, "Missing buy_5000_btn")
|
||||||
|
assert(ui.tx_container != null, "Missing tx_container")
|
||||||
|
assert(ui.prev_page_btn != null, "Missing prev_page_btn")
|
||||||
|
assert(ui.next_page_btn != null, "Missing next_page_btn")
|
||||||
|
assert(ui.page_label != null, "Missing page_label")
|
||||||
|
|
||||||
|
# Test rendering empty transactions
|
||||||
|
ui.render_transactions([])
|
||||||
|
assert(ui.tx_container.get_child_count() == 1, "Should have 1 empty notice child")
|
||||||
|
|
||||||
|
# Test rendering sample transactions
|
||||||
|
var sample_txs = [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"created_at": "2026-09-15T12:00:00Z",
|
||||||
|
"type": "credit_purchase",
|
||||||
|
"amount": 500,
|
||||||
|
"balance_after": 500,
|
||||||
|
"reference_type": "payment",
|
||||||
|
"reference_id": "sess_123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"created_at": "2026-09-15T12:15:00Z",
|
||||||
|
"type": "marketplace_purchase",
|
||||||
|
"amount": -200,
|
||||||
|
"balance_after": 300,
|
||||||
|
"reference_type": "marketplace_listing",
|
||||||
|
"reference_id": "45"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
ui.render_transactions(sample_txs)
|
||||||
|
assert(ui.tx_container.get_child_count() == 2, "Should have 2 transaction rows")
|
||||||
|
|
||||||
|
# Test paging logic
|
||||||
|
ui.total_tx_count = 45
|
||||||
|
ui.current_page = 0
|
||||||
|
ui._update_paging_controls()
|
||||||
|
assert(ui.prev_page_btn.disabled == true, "Prev should be disabled on page 0")
|
||||||
|
assert(ui.next_page_btn.disabled == false, "Next should be enabled on page 0")
|
||||||
|
assert("Page 1 of 3" in ui.page_label.text, "Page label text mismatch")
|
||||||
|
|
||||||
|
ui.queue_free()
|
||||||
|
print("[SMOKE] WalletUI smoke test PASSED")
|
||||||
|
quit(0)
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
extends Control
|
||||||
|
class_name WalletUI
|
||||||
|
## Wallet & Transaction History UI panel.
|
||||||
|
##
|
||||||
|
## Displays:
|
||||||
|
## - Current Credit balance
|
||||||
|
## - Credit pack quick purchases (100, 500, 1000, 5000 CR)
|
||||||
|
## - Paged ledger transaction history with status/amounts/references
|
||||||
|
##
|
||||||
|
## docs/planning/economy.md US-6 (task 34c725431645).
|
||||||
|
|
||||||
|
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 balance_label: Label = $VBox/BalanceCard/Margin/HBox/BalanceLabel
|
||||||
|
@onready var buy_100_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy100Btn
|
||||||
|
@onready var buy_500_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy500Btn
|
||||||
|
@onready var buy_1000_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy1000Btn
|
||||||
|
@onready var buy_5000_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy5000Btn
|
||||||
|
|
||||||
|
@onready var tx_container: VBoxContainer = $VBox/LedgerSection/Scroll/TxList
|
||||||
|
@onready var prev_page_btn: Button = $VBox/LedgerSection/PagingHBox/PrevPageBtn
|
||||||
|
@onready var next_page_btn: Button = $VBox/LedgerSection/PagingHBox/NextPageBtn
|
||||||
|
@onready var page_label: Label = $VBox/LedgerSection/PagingHBox/PageLabel
|
||||||
|
|
||||||
|
var _api_client: Node = null
|
||||||
|
var current_page: int = 0
|
||||||
|
const PAGE_SIZE: int = 20
|
||||||
|
var total_tx_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_api_client = _get_api()
|
||||||
|
_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 _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 buy_100_btn and not buy_100_btn.pressed.is_connected(_buy_pack.bind(100)):
|
||||||
|
buy_100_btn.pressed.connect(_buy_pack.bind(100))
|
||||||
|
if buy_500_btn and not buy_500_btn.pressed.is_connected(_buy_pack.bind(500)):
|
||||||
|
buy_500_btn.pressed.connect(_buy_pack.bind(500))
|
||||||
|
if buy_1000_btn and not buy_1000_btn.pressed.is_connected(_buy_pack.bind(1000)):
|
||||||
|
buy_1000_btn.pressed.connect(_buy_pack.bind(1000))
|
||||||
|
if buy_5000_btn and not buy_5000_btn.pressed.is_connected(_buy_pack.bind(5000)):
|
||||||
|
buy_5000_btn.pressed.connect(_buy_pack.bind(5000))
|
||||||
|
|
||||||
|
if prev_page_btn and not prev_page_btn.pressed.is_connected(_on_prev_page):
|
||||||
|
prev_page_btn.pressed.connect(_on_prev_page)
|
||||||
|
if next_page_btn and not next_page_btn.pressed.is_connected(_on_next_page):
|
||||||
|
next_page_btn.pressed.connect(_on_next_page)
|
||||||
|
|
||||||
|
|
||||||
|
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_wallet()
|
||||||
|
refresh_transactions()
|
||||||
|
|
||||||
|
|
||||||
|
func refresh_wallet() -> void:
|
||||||
|
var api = _get_api()
|
||||||
|
if not api or not api.has_token():
|
||||||
|
return
|
||||||
|
var res: Dictionary = await EconomyApiClient.get_wallet(api)
|
||||||
|
if res.ok and res.data is Dictionary:
|
||||||
|
var balance: int = int(res.data.get("balance", 0))
|
||||||
|
if balance_label:
|
||||||
|
balance_label.text = "%d CR" % balance
|
||||||
|
else:
|
||||||
|
show_status("Failed to fetch wallet: " + res.detail, true)
|
||||||
|
|
||||||
|
|
||||||
|
func refresh_transactions() -> void:
|
||||||
|
var api = _get_api()
|
||||||
|
if not api or not api.has_token():
|
||||||
|
return
|
||||||
|
var offset: int = current_page * PAGE_SIZE
|
||||||
|
var res: Dictionary = await EconomyApiClient.get_wallet_transactions(api, PAGE_SIZE, offset)
|
||||||
|
if res.ok and res.data is Dictionary:
|
||||||
|
var items: Array = res.data.get("items", [])
|
||||||
|
total_tx_count = int(res.data.get("total", 0))
|
||||||
|
render_transactions(items)
|
||||||
|
_update_paging_controls()
|
||||||
|
else:
|
||||||
|
show_status("Failed to fetch transactions: " + res.detail, true)
|
||||||
|
|
||||||
|
|
||||||
|
func _buy_pack(credits: int) -> void:
|
||||||
|
var api = _get_api()
|
||||||
|
if not api or not api.has_token():
|
||||||
|
show_status("Please login to purchase Credits", true)
|
||||||
|
return
|
||||||
|
show_status("Processing purchase of %d Credits..." % credits)
|
||||||
|
var res: Dictionary = await EconomyApiClient.purchase_credits(api, credits)
|
||||||
|
if res.ok and res.data is Dictionary:
|
||||||
|
var new_bal: int = int(res.data.get("new_balance", 0))
|
||||||
|
if balance_label:
|
||||||
|
balance_label.text = "%d CR" % new_bal
|
||||||
|
show_status("Successfully purchased %d Credits! Balance: %d CR" % [credits, new_bal])
|
||||||
|
refresh_transactions()
|
||||||
|
else:
|
||||||
|
show_status("Purchase failed: " + res.detail, true)
|
||||||
|
|
||||||
|
|
||||||
|
func render_transactions(tx_list: Array) -> void:
|
||||||
|
if not tx_container:
|
||||||
|
return
|
||||||
|
for c in tx_container.get_children():
|
||||||
|
tx_container.remove_child(c)
|
||||||
|
c.queue_free()
|
||||||
|
|
||||||
|
if tx_list.is_empty():
|
||||||
|
var empty_lbl := Label.new()
|
||||||
|
empty_lbl.text = "No transactions found."
|
||||||
|
empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
|
||||||
|
tx_container.add_child(empty_lbl)
|
||||||
|
return
|
||||||
|
|
||||||
|
for tx in tx_list:
|
||||||
|
if not tx 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(tx.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 type_lbl := Label.new()
|
||||||
|
type_lbl.text = str(tx.get("type", "")).replace("_", " ").capitalize()
|
||||||
|
type_lbl.custom_minimum_size = Vector2(160, 0)
|
||||||
|
hbox.add_child(type_lbl)
|
||||||
|
|
||||||
|
var amount: int = int(tx.get("amount", 0))
|
||||||
|
var amt_lbl := Label.new()
|
||||||
|
if amount > 0:
|
||||||
|
amt_lbl.text = "+%d CR" % amount
|
||||||
|
amt_lbl.add_theme_color_override("font_color", Color(0.2, 0.9, 0.2))
|
||||||
|
else:
|
||||||
|
amt_lbl.text = "%d CR" % amount
|
||||||
|
amt_lbl.add_theme_color_override("font_color", Color(0.9, 0.3, 0.3))
|
||||||
|
amt_lbl.custom_minimum_size = Vector2(100, 0)
|
||||||
|
hbox.add_child(amt_lbl)
|
||||||
|
|
||||||
|
var bal_lbl := Label.new()
|
||||||
|
bal_lbl.text = "Bal: %s CR" % str(tx.get("balance_after", 0))
|
||||||
|
bal_lbl.custom_minimum_size = Vector2(100, 0)
|
||||||
|
bal_lbl.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8))
|
||||||
|
hbox.add_child(bal_lbl)
|
||||||
|
|
||||||
|
var ref_lbl := Label.new()
|
||||||
|
var ref_type: String = str(tx.get("reference_type", ""))
|
||||||
|
var ref_id: String = str(tx.get("reference_id", ""))
|
||||||
|
if ref_type != "":
|
||||||
|
ref_lbl.text = "%s #%s" % [ref_type, ref_id]
|
||||||
|
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)
|
||||||
|
|
||||||
|
tx_container.add_child(row)
|
||||||
|
|
||||||
|
|
||||||
|
func _update_paging_controls() -> void:
|
||||||
|
var total_pages: int = int(ceil(float(total_tx_count) / float(PAGE_SIZE)))
|
||||||
|
if total_pages < 1:
|
||||||
|
total_pages = 1
|
||||||
|
if page_label:
|
||||||
|
page_label.text = "Page %d of %d" % [current_page + 1, total_pages]
|
||||||
|
if prev_page_btn:
|
||||||
|
prev_page_btn.disabled = current_page <= 0
|
||||||
|
if next_page_btn:
|
||||||
|
next_page_btn.disabled = current_page >= total_pages - 1
|
||||||
|
|
||||||
|
|
||||||
|
func _on_prev_page() -> void:
|
||||||
|
if current_page > 0:
|
||||||
|
current_page -= 1
|
||||||
|
refresh_transactions()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_next_page() -> void:
|
||||||
|
var total_pages: int = int(ceil(float(total_tx_count) / float(PAGE_SIZE)))
|
||||||
|
if current_page < total_pages - 1:
|
||||||
|
current_page += 1
|
||||||
|
refresh_transactions()
|
||||||
Reference in New Issue
Block a user