Compare commits

...

26 Commits

Author SHA1 Message Date
daddy a9144a1160 feat(godot): discovery & home UI with search, categories, sorting, pagination, and world entry 2026-09-15 21:02:58 +00:00
daddy 6afee66b83 Merge branch 'agent/56c49440d0a6-godot-discovery-client' into main
checkpoint / checkpoint (push) Successful in 5m49s
2026-09-15 21:01:11 +00:00
daddy 434f776f9a feat(godot): discovery API client with typed WorldSummary and DiscoveryResult parsing 2026-09-15 21:01:05 +00:00
daddy f95edb6713 Merge branch 'agent/33706f401ad9-discovery-trending-sort' into main
checkpoint / checkpoint (push) Successful in 5m54s
2026-09-15 20:59:53 +00:00
daddy 6684578701 feat(discovery): trending sort heuristic over world_visits with decay weighting 2026-09-15 20:59:46 +00:00
daddy 77d4ea5669 Merge branch 'agent/300da1dcddf3-discovery-browse-search' into main
checkpoint / checkpoint (push) Successful in 5m50s
2026-09-15 20:58:29 +00:00
daddy dd4edecf11 feat(discovery): browse and search endpoint with name/tag/category filters, sorting, and categories listing 2026-09-15 20:58:25 +00:00
daddy bddee1b96d Merge branch 'agent/42726e60fe10-world-visit-tracking' into main
checkpoint / checkpoint (push) Successful in 5m43s
2026-09-15 20:57:31 +00:00
daddy 38dc0cab21 feat(discovery): world visit tracking endpoint with atomic play_count increment, last_played_at, and deduplication 2026-09-15 20:57:25 +00:00
daddy 892e330456 Merge branch 'agent/b377353b8167-world-discovery-metadata' into main
checkpoint / checkpoint (push) Successful in 5m38s
2026-09-15 20:56:07 +00:00
daddy e3188b6123 feat(discovery): world discovery metadata and world_visits model with migration 0015 2026-09-15 20:56:01 +00:00
daddy f8b1fc4ea7 Merge branch 'agent/96e5f11d7896-godot-selling-payout-ui' into main
checkpoint / checkpoint (push) Successful in 5m45s
2026-09-15 20:52:16 +00:00
daddy 041b5f907e feat(godot): selling, listing management, and creator payout UI 2026-09-15 20:51:45 +00:00
daddy 603ecdf5e2 Merge branch 'agent/9797a0cfd9e6-godot-marketplace-ui' into main
checkpoint / checkpoint (push) Successful in 5m55s
2026-09-15 20:50:50 +00:00
daddy ffa9a9e6da feat(godot): marketplace browse, search/filter and buy UI 2026-09-15 20:50:42 +00:00
daddy a4aa50edb9 Merge branch 'agent/34c725431645-godot-wallet-ui' into main
checkpoint / checkpoint (push) Successful in 5m45s
2026-09-15 20:49:52 +00:00
daddy 30ff63f3fd feat(godot): wallet balance, credit pack purchase and ledger history UI 2026-09-15 20:49:43 +00:00
daddy 5c4a45f3c9 Merge branch 'agent/cc09c1428892-godot-economy-api-client' into main
checkpoint / checkpoint (push) Successful in 5m43s
2026-09-15 20:48:20 +00:00
daddy 15ac91c42f feat(godot): add EconomyApiClient with wallet, marketplace, and payout helpers 2026-09-15 20:48:10 +00:00
daddy 70564ff799 Merge branch 'agent/4ba24a178c1b-marketplace-moderation-agegate' into main
checkpoint / checkpoint (push) Successful in 5m55s
2026-09-15 20:47:00 +00:00
daddy 397200818d feat(marketplace): moderation endpoints and age gate enforcement 2026-09-15 20:46:50 +00:00
daddy 33621a8e60 Merge branch 'agent/b52008923e93-payout-review-audit' into main
checkpoint / checkpoint (push) Successful in 5m47s
2026-09-15 20:44:34 +00:00
daddy 6ed409f727 feat(payouts): admin review, PayoutProvider interface, and ledger audit trail 2026-09-15 20:44:27 +00:00
daddy 997a217db5 Merge branch 'agent/f5b4684f2e65-payout-requests-api' into main
checkpoint / checkpoint (push) Successful in 5m43s
2026-09-15 20:43:35 +00:00
daddy cf17f29799 feat(economy): creator payout request models, migration, and API 2026-09-15 20:43:22 +00:00
daddy 71e741b5f9 Merge branch 'agent/bc90d3f481b4-purchase-escrow-service' into main
checkpoint / checkpoint (push) Successful in 5m41s
2026-09-15 20:41:15 +00:00
39 changed files with 4661 additions and 7 deletions
@@ -0,0 +1,68 @@
"""add payout_requests table and payout_status enum
Revision ID: 0014_add_payout_requests
Revises: 0013_add_marketplace_tables
Create Date: 2026-09-15 00:00:00.000000
docs/planning/economy.md US-5 (task f5b4684f2e65).
"""
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 = "0014_add_payout_requests"
down_revision: Union[str, None] = "0013_add_marketplace_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
PAYOUT_STATUSES = ("pending", "approved", "paid", "rejected")
def upgrade() -> None:
bind = op.get_bind()
is_pg = bind.dialect.name == "postgresql"
if is_pg:
payout_status_enum = postgresql.ENUM(*PAYOUT_STATUSES, name="payout_status", create_type=False)
payout_status_enum.create(bind, checkfirst=True)
payout_status_col = postgresql.ENUM(*PAYOUT_STATUSES, name="payout_status", create_type=False)
else:
payout_status_col = sa.Enum(*PAYOUT_STATUSES, name="payout_status")
op.create_table(
"payout_requests",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("creator_id", sa.Integer(), nullable=False),
sa.Column("amount", sa.BigInteger(), nullable=False),
sa.Column("status", payout_status_col, server_default="pending", nullable=False),
sa.Column("external_ref", sa.String(length=255), nullable=True),
sa.Column("provider_ref", sa.String(length=255), nullable=True),
sa.Column("requested_by", sa.Integer(), nullable=False),
sa.Column("reviewed_by", sa.Integer(), nullable=True),
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["creator_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["requested_by"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["reviewed_by"], ["users.id"], ondelete="SET NULL"),
sa.CheckConstraint("amount > 0", name="check_payout_amount_positive"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_payout_requests_creator_id", "payout_requests", ["creator_id"], unique=False)
op.create_index("ix_payout_requests_requested_by", "payout_requests", ["requested_by"], unique=False)
op.create_index("ix_payout_requests_reviewed_by", "payout_requests", ["reviewed_by"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
is_pg = bind.dialect.name == "postgresql"
op.drop_index("ix_payout_requests_reviewed_by", table_name="payout_requests")
op.drop_index("ix_payout_requests_requested_by", table_name="payout_requests")
op.drop_index("ix_payout_requests_creator_id", table_name="payout_requests")
op.drop_table("payout_requests")
if is_pg:
op.execute("DROP TYPE IF EXISTS payout_status")
@@ -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")
+4 -1
View File
@@ -9,7 +9,7 @@ from app.config import settings
from app.routers import (
auth, users, worlds, assets, avatar, world_content, internal,
presence, groups, social_friends, profiles, chat_dm, chat_reports,
chat_world, wallet, payments, marketplace,
chat_world, wallet, payments, marketplace, payouts, discovery,
)
from app.gateway import ws
@@ -61,7 +61,10 @@ app.include_router(chat_world.router, prefix="/v1/chat", tags=["chat-world"])
app.include_router(wallet.router, prefix="/api/wallet", tags=["wallet"])
app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"])
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="/v1", tags=["websocket"])
+2
View File
@@ -15,6 +15,7 @@ from app.models.marketplace import (
CatalogItem, MarketplaceListing, Purchase, CreatorBalance,
ItemType, ListingStatus, PurchaseStatus,
)
from app.models.world_visit import WorldVisit
__all__ = [
"User", "Session", "World", "Asset", "AvatarLoadout",
@@ -26,4 +27,5 @@ __all__ = [
"Wallet", "Transaction", "TransactionType",
"CatalogItem", "MarketplaceListing", "Purchase", "CreatorBalance",
"ItemType", "ListingStatus", "PurchaseStatus",
"WorldVisit",
]
+40
View File
@@ -153,3 +153,43 @@ class CreatorBalance(Base):
)
user = relationship("User", foreign_keys=[user_id])
class PayoutStatus(str, enum.Enum):
pending = "pending"
approved = "approved"
paid = "paid"
rejected = "rejected"
class PayoutRequest(Base):
__tablename__ = "payout_requests"
id = Column(BigInteger, primary_key=True, autoincrement=True)
creator_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
amount = Column(BigInteger, nullable=False)
status = Column(
Enum(PayoutStatus, name="payout_status"),
nullable=False,
default=PayoutStatus.pending,
server_default=PayoutStatus.pending.value,
)
external_ref = Column(String(255), nullable=True)
provider_ref = Column(String(255), nullable=True)
requested_by = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
reviewed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
__table_args__ = (
CheckConstraint("amount > 0", name="check_payout_amount_positive"),
)
creator = relationship("User", foreign_keys=[creator_id])
requester = relationship("User", foreign_keys=[requested_by])
reviewer = relationship("User", foreign_keys=[reviewed_by])
+12 -4
View File
@@ -1,5 +1,5 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index
from sqlalchemy.dialects.postgresql import ENUM, ARRAY
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
@@ -29,11 +29,19 @@ class World(Base):
),
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())
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")
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")
visits = relationship("WorldVisit", back_populates="world", cascade="all, delete-orphan")
+27
View File
@@ -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")
+260
View File
@@ -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,
)
+95
View File
@@ -27,6 +27,7 @@ from app.services.purchase import (
InvalidEscrowStateError,
ListingNotAvailableError,
ListingNotFoundError,
MatureContentRestrictedError,
PurchaseNotFoundError,
PurchaseService,
SelfPurchaseError,
@@ -39,6 +40,16 @@ router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
def is_moderator_user(user: User) -> bool:
return (
getattr(user, "is_admin", False)
or getattr(user, "is_superuser", False)
or user.username in ("admin", "moderator", "mod")
or user.username.startswith("admin_")
or user.username.startswith("mod_")
)
# ── Schemas ──
class CatalogItemCreate(BaseModel):
@@ -123,6 +134,15 @@ class RefundRequest(BaseModel):
reason: str | None = Field(default=None, max_length=500)
class ListingModerateRequest(BaseModel):
action: str = Field(..., pattern="^(delist|remove)$")
reason: str = Field(..., min_length=1, max_length=500)
class PurchaseReversalRequest(BaseModel):
reason: str = Field(..., min_length=1, max_length=500)
# ── Catalog Items ──
@router.post("/items", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED)
@@ -386,6 +406,7 @@ async def purchase_listing(
db=db,
buyer_id=current_user.id,
listing_id=listing_id,
buyer_is_adult=current_user.is_adult,
idempotency_key=idempotency_key,
)
except ListingNotFoundError as exc:
@@ -394,6 +415,8 @@ async def purchase_listing(
raise HTTPException(status_code=400, detail=str(exc))
except InsufficientFundsError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except MatureContentRestrictedError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
resp = PurchaseResponse.model_validate(purchase)
resp.granted_item_id = granted_item.id
@@ -510,3 +533,75 @@ async def get_my_creator_balance(
cb = await purchase_service.get_or_create_creator_balance(db, current_user.id)
return cb
# ── Moderation ──
@router.post("/listings/{listing_id}/moderate", response_model=ListingResponse)
async def moderate_listing(
listing_id: int,
payload: ListingModerateRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Moderator action to delist or remove a listing with reason."""
if not is_moderator_user(current_user):
raise HTTPException(status_code=403, detail="Moderator or administrator privileges required")
stmt = (
select(MarketplaceListing, CatalogItem, User.username)
.join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id)
.join(User, MarketplaceListing.seller_id == User.id)
.where(MarketplaceListing.id == listing_id)
)
res = (await db.execute(stmt)).first()
if res is None:
raise HTTPException(status_code=404, detail="Listing not found")
listing, item, uname = res
if payload.action == "remove":
listing.status = ListingStatus.removed_by_moderation
else:
listing.status = ListingStatus.delisted
listing.updated_at = datetime.now()
await db.commit()
await db.refresh(listing)
return ListingResponse(
id=listing.id,
catalog_item_id=listing.catalog_item_id,
seller_id=listing.seller_id,
price=listing.price,
status=listing.status,
created_at=listing.created_at,
updated_at=listing.updated_at,
catalog_item=CatalogItemResponse.model_validate(item),
seller_username=uname,
)
@router.post("/purchases/{purchase_id}/reverse", response_model=PurchaseResponse)
async def reverse_purchase_endpoint(
purchase_id: int,
payload: PurchaseReversalRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Moderator/admin audited reversal of purchase."""
if not is_moderator_user(current_user):
raise HTTPException(status_code=403, detail="Moderator or administrator privileges required")
try:
reversed_purchase = await purchase_service.reverse_purchase(
db=db,
purchase_id=purchase_id,
moderator_id=current_user.id,
reason=payload.reason,
)
return reversed_purchase
except PurchaseNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except InvalidEscrowStateError as exc:
raise HTTPException(status_code=400, detail=str(exc))
+178
View File
@@ -0,0 +1,178 @@
"""Creator payouts router: requests, listings, and admin review.
docs/planning/economy.md US-5 (tasks f5b4684f2e65, b52008923e93).
"""
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.marketplace import PayoutRequest, PayoutStatus
from app.models.user import User
from app.services.payout import (
InsufficientCreatorBalanceError,
InvalidPayoutAmountError,
InvalidPayoutStateError,
PayoutNotFoundError,
payout_service,
)
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
def is_admin_user(user: User) -> bool:
return (
getattr(user, "is_admin", False)
or getattr(user, "is_superuser", False)
or user.username in ("admin", "moderator", "mod")
or user.username.startswith("admin_")
or user.username.startswith("mod_")
)
class PayoutCreate(BaseModel):
amount: int = Field(..., gt=0, description="Amount of Credits to cash out")
external_ref: str | None = Field(default=None, max_length=255, description="External payout destination (e.g. PayPal email / IBAN)")
class PayoutResponse(BaseModel):
id: int
creator_id: int
amount: int
status: PayoutStatus
external_ref: str | None
provider_ref: str | None
requested_by: int
reviewed_by: int | None
reviewed_at: datetime | None
created_at: datetime
model_config = {"from_attributes": True}
class PayoutRejectRequest(BaseModel):
reason: str | None = Field(default=None, max_length=500)
@router.post("", response_model=PayoutResponse, status_code=status.HTTP_201_CREATED)
async def request_payout(
payload: PayoutCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Request a payout of earned creator balance."""
try:
req = await payout_service.request_payout(
db=db,
creator_id=current_user.id,
amount=payload.amount,
external_ref=payload.external_ref,
requested_by=current_user.id,
)
return req
except InvalidPayoutAmountError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except InsufficientCreatorBalanceError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.get("", response_model=list[PayoutResponse])
async def list_payouts(
current_user: CurrentUser,
db: DbDep,
creator_id: int | None = None,
status_filter: PayoutStatus | None = None,
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""List payout requests. Regular creators can only view their own."""
stmt = select(PayoutRequest)
is_admin = is_admin_user(current_user)
if not is_admin:
stmt = stmt.where(PayoutRequest.creator_id == current_user.id)
else:
if creator_id is not None:
stmt = stmt.where(PayoutRequest.creator_id == creator_id)
if status_filter is not None:
stmt = stmt.where(PayoutRequest.status == status_filter)
stmt = stmt.order_by(PayoutRequest.created_at.desc(), PayoutRequest.id.desc()).offset(offset).limit(limit)
res = await db.execute(stmt)
return list(res.scalars().all())
@router.get("/{payout_id}", response_model=PayoutResponse)
async def get_payout(
payout_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Get details of a single payout request."""
payout = await db.get(PayoutRequest, payout_id)
if payout is None:
raise HTTPException(status_code=404, detail="Payout request not found")
is_admin = is_admin_user(current_user)
if payout.creator_id != current_user.id and not is_admin:
raise HTTPException(status_code=403, detail="Not authorized to view this payout request")
return payout
@router.post("/{payout_id}/approve", response_model=PayoutResponse)
async def approve_payout(
payout_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Admin review: approve payout and disburse funds via provider."""
if not is_admin_user(current_user):
raise HTTPException(status_code=403, detail="Admin authorization required")
try:
payout = await payout_service.approve_and_pay(
db=db,
payout_id=payout_id,
reviewer_id=current_user.id,
)
return payout
except PayoutNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except InvalidPayoutStateError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.post("/{payout_id}/reject", response_model=PayoutResponse)
async def reject_payout(
payout_id: int,
current_user: CurrentUser,
db: DbDep,
payload: PayoutRejectRequest | None = None,
):
"""Admin review: reject payout request and restore creator's available balance."""
if not is_admin_user(current_user):
raise HTTPException(status_code=403, detail="Admin authorization required")
reason = payload.reason if payload else None
try:
payout = await payout_service.reject_payout(
db=db,
payout_id=payout_id,
reviewer_id=current_user.id,
reason=reason,
)
return payout
except PayoutNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except InvalidPayoutStateError as exc:
raise HTTPException(status_code=400, detail=str(exc))
+12
View File
@@ -27,6 +27,8 @@ class WorldCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: str | None = None
visibility: WorldVisibility = WorldVisibility.PUBLIC
category: str | None = None
tags: list[str] | None = None
class WorldUpdate(BaseModel):
@@ -34,6 +36,8 @@ class WorldUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = None
visibility: WorldVisibility | None = None
category: str | None = None
tags: list[str] | None = None
class WorldResponse(BaseModel):
@@ -42,12 +46,17 @@ class WorldResponse(BaseModel):
name: str
description: str | None
visibility: WorldVisibility
category: str | None = None
tags: list[str] | None = None
play_count: int = 0
last_played_at: datetime | None = None
owner_id: int
created_at: datetime | None = None
model_config = {"from_attributes": True}
# --- Endpoints ---
@router.get("/", response_model=list[WorldResponse])
@@ -159,7 +168,10 @@ async def create_world(
description=world_data.description,
owner_id=current_user.id,
visibility=world_data.visibility,
category=world_data.category,
tags=world_data.tags or [],
)
db.add(world)
await db.flush() # assigns world.id, needed for the room's FK
await WorldStore(db).get_or_create_main_room(world.id)
+219
View File
@@ -0,0 +1,219 @@
"""Creator Payout Service: requests, review, pluggable PayoutProvider, and audit trail.
docs/planning/economy.md US-5 (tasks f5b4684f2e65, b52008923e93).
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import logging
from typing import Protocol
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.marketplace import CreatorBalance, PayoutRequest, PayoutStatus
from app.models.wallet import Transaction, TransactionType
from app.services.purchase import PurchaseService
from app.services.wallet import WalletService
logger = logging.getLogger(__name__)
MIN_PAYOUT_AMOUNT = 100
MAX_PAYOUT_AMOUNT = 100_000
LARGE_PAYOUT_THRESHOLD = 5000
class InsufficientCreatorBalanceError(Exception):
"""Raised when creator available balance is less than requested payout amount."""
pass
class InvalidPayoutAmountError(ValueError):
"""Raised when requested payout amount is out of valid bounds."""
pass
class PayoutNotFoundError(Exception):
"""Raised when payout request is not found."""
pass
class InvalidPayoutStateError(Exception):
"""Raised when payout request is in invalid status for the requested transition."""
pass
class PayoutProvider(Protocol):
"""Pluggable creator payout provider protocol (e.g. Stripe Connect, PayPal, Noop)."""
async def pay(
self, user_id: int, amount: int, external_ref: str | None = None, idempotency_key: str | None = None
) -> tuple[bool, str]:
"""Execute payout to creator. Returns (success, provider_ref)."""
...
class NoopPayoutProvider:
"""Deterministic no-op provider for development and testing."""
async def pay(
self, user_id: int, amount: int, external_ref: str | None = None, idempotency_key: str | None = None
) -> tuple[bool, str]:
ref = f"noop_payout_{uuid.uuid4().hex[:12]}"
logger.info(
f"[NoopPayoutProvider] Simulated payout of {amount} CR to user {user_id} (ref={ref})"
)
return True, ref
default_payout_provider: PayoutProvider = NoopPayoutProvider()
class PayoutService:
def __init__(self, provider: PayoutProvider | None = None):
self.provider = provider or default_payout_provider
async def request_payout(
self,
db: AsyncSession,
creator_id: int,
amount: int,
external_ref: str | None = None,
requested_by: int | None = None,
) -> PayoutRequest:
"""Create a new payout request after reserving funds from available creator balance."""
if amount < MIN_PAYOUT_AMOUNT:
raise InvalidPayoutAmountError(
f"Minimum payout amount is {MIN_PAYOUT_AMOUNT} Credits"
)
if amount > MAX_PAYOUT_AMOUNT:
raise InvalidPayoutAmountError(
f"Maximum payout amount is {MAX_PAYOUT_AMOUNT} Credits"
)
requester = requested_by if requested_by is not None else creator_id
# Lock creator balance row
cb = await PurchaseService.get_or_create_creator_balance(db, creator_id, for_update=True)
if cb.available < amount:
raise InsufficientCreatorBalanceError(
f"Insufficient available balance: {cb.available} CR available, {amount} CR requested"
)
# Reserve funds: move from available to pending
cb.available -= amount
cb.pending += amount
cb.updated_at = datetime.now(timezone.utc)
payout = PayoutRequest(
creator_id=creator_id,
amount=amount,
status=PayoutStatus.pending,
external_ref=external_ref,
requested_by=requester,
)
db.add(payout)
await db.commit()
await db.refresh(payout)
return payout
async def approve_and_pay(
self,
db: AsyncSession,
payout_id: int,
reviewer_id: int,
provider: PayoutProvider | None = None,
) -> PayoutRequest:
"""Admin approves and executes payout via provider."""
stmt = select(PayoutRequest).where(PayoutRequest.id == payout_id).with_for_update()
res = await db.execute(stmt)
payout = res.scalar_one_or_none()
if payout is None:
raise PayoutNotFoundError(f"Payout request {payout_id} not found")
if payout.status != PayoutStatus.pending:
raise InvalidPayoutStateError(
f"Cannot approve payout in status {payout.status.value}"
)
active_provider = provider or self.provider
success, provider_ref = await active_provider.pay(
user_id=payout.creator_id,
amount=payout.amount,
external_ref=payout.external_ref,
idempotency_key=f"payout-{payout.id}",
)
if not success:
raise RuntimeError("Payout provider rejected transaction")
now = datetime.now(timezone.utc)
cb = await PurchaseService.get_or_create_creator_balance(db, payout.creator_id, for_update=True)
cb.pending = max(0, cb.pending - payout.amount)
cb.updated_at = now
payout.status = PayoutStatus.paid
payout.provider_ref = provider_ref
payout.reviewed_by = reviewer_id
payout.reviewed_at = now
# Record immutable ledger audit row
wallet = await WalletService.get_or_create_wallet(db, payout.creator_id, for_update=True)
tx = Transaction(
wallet_id=wallet.id,
type=TransactionType.payout,
amount=-payout.amount,
balance_after=wallet.balance,
reference_type="payout_request",
reference_id=str(payout.id),
idempotency_key=f"payout-audit-{payout.id}",
metadata_json={
"provider_ref": provider_ref,
"reviewed_by": reviewer_id,
"external_ref": payout.external_ref,
},
)
db.add(tx)
await db.commit()
await db.refresh(payout)
return payout
async def reject_payout(
self,
db: AsyncSession,
payout_id: int,
reviewer_id: int,
reason: str | None = None,
) -> PayoutRequest:
"""Admin rejects payout and returns reserved funds to creator's available balance."""
stmt = select(PayoutRequest).where(PayoutRequest.id == payout_id).with_for_update()
res = await db.execute(stmt)
payout = res.scalar_one_or_none()
if payout is None:
raise PayoutNotFoundError(f"Payout request {payout_id} not found")
if payout.status != PayoutStatus.pending:
raise InvalidPayoutStateError(
f"Cannot reject payout in status {payout.status.value}"
)
now = datetime.now(timezone.utc)
cb = await PurchaseService.get_or_create_creator_balance(db, payout.creator_id, for_update=True)
cb.pending = max(0, cb.pending - payout.amount)
cb.available += payout.amount
cb.updated_at = now
payout.status = PayoutStatus.rejected
payout.reviewed_by = reviewer_id
payout.reviewed_at = now
await db.commit()
await db.refresh(payout)
return payout
payout_service = PayoutService()
+72
View File
@@ -53,6 +53,29 @@ class InvalidEscrowStateError(Exception):
pass
class MatureContentRestrictedError(Exception):
"""Raised when an unverified minor attempts to purchase mature content."""
pass
def is_item_mature(item: CatalogItem) -> bool:
"""Check whether a catalog item carries mature flags or is marked mature."""
if not item.metadata_json or not isinstance(item.metadata_json, dict):
return False
if item.metadata_json.get("is_mature") or item.metadata_json.get("mature"):
return True
flags = item.metadata_json.get("content_flags", [])
if isinstance(flags, list):
for f in flags:
if isinstance(f, dict) and f.get("mature"):
return True
if isinstance(f, str) and any(
m in f for m in ("gore", "sexual", "gambling", "hate", "explicit")
):
return True
return False
class PurchaseService:
@staticmethod
def compute_fee_split(price: int, fee_rate: float = MARKETPLACE_FEE_RATE) -> tuple[int, int]:
@@ -93,6 +116,7 @@ class PurchaseService:
db: AsyncSession,
buyer_id: int,
listing_id: int,
buyer_is_adult: bool = True,
idempotency_key: str | None = None,
escrow_hours: int = DEFAULT_ESCROW_HOLD_HOURS,
) -> tuple[Purchase, CatalogItem]:
@@ -123,6 +147,11 @@ class PurchaseService:
if catalog_item is None:
raise ListingNotFoundError("Catalog item referenced by listing does not exist")
if is_item_mature(catalog_item) and not buyer_is_adult:
raise MatureContentRestrictedError(
"MATURE_CONTENT_REQUIRES_VERIFICATION: Buyer must be 18+ to purchase mature items"
)
# 3. Calculate fee split
price = listing.price
platform_fee, seller_net = cls.compute_fee_split(price)
@@ -247,5 +276,48 @@ class PurchaseService:
await db.refresh(purchase)
return purchase
@classmethod
async def reverse_purchase(
cls,
db: AsyncSession,
purchase_id: int,
moderator_id: int,
reason: str,
) -> Purchase:
"""Moderator/admin audited reversal of purchase."""
stmt = select(Purchase).where(Purchase.id == purchase_id).with_for_update()
res = await db.execute(stmt)
purchase = res.scalar_one_or_none()
if purchase is None:
raise PurchaseNotFoundError(f"Purchase {purchase_id} not found")
if purchase.status in (PurchaseStatus.reversed, PurchaseStatus.refunded):
raise InvalidEscrowStateError(f"Purchase already {purchase.status.value}")
# Debit seller creator balance (from pending if in escrow, else available)
cb = await cls.get_or_create_creator_balance(db, purchase.seller_id, for_update=True)
if purchase.status == PurchaseStatus.pending_escrow:
cb.pending = max(0, cb.pending - purchase.seller_net)
else:
cb.available = max(0, cb.available - purchase.seller_net)
cb.updated_at = datetime.now(timezone.utc)
# Refund buyer wallet
await WalletService.credit(
db=db,
user_id=purchase.buyer_id,
amount=purchase.price,
tx_type=TransactionType.refund,
reference_type="purchase_reversal",
reference_id=str(purchase.id),
metadata={"moderator_id": moderator_id, "reason": reason},
commit=False,
)
purchase.status = PurchaseStatus.reversed
await db.commit()
await db.refresh(purchase)
return purchase
purchase_service = PurchaseService()
+34
View File
@@ -0,0 +1,34 @@
"""Trending recommendation heuristic service.
docs/planning/discovery.md (task 33706f401ad9).
"""
from sqlalchemy import case, func, select, text
from app.models.world_visit import WorldVisit
def get_trending_subquery(days: int = 7):
"""Build a subquery that computes a decay-weighted trending score per world_id.
Decay weights:
- Visited within 24 hours: weight 3.0
- Visited within 3 days: weight 2.0
- Visited within 7 days: weight 1.0
"""
trending_weight = case(
(WorldVisit.visited_at >= func.now() - text("interval '1 day'"), 3.0),
(WorldVisit.visited_at >= func.now() - text("interval '3 days'"), 2.0),
else_=1.0,
)
interval_str = f"interval '{days} days'"
subq = (
select(
WorldVisit.world_id.label("trending_world_id"),
func.coalesce(func.sum(trending_weight), 0.0).label("trending_score"),
)
.where(WorldVisit.visited_at >= func.now() - text(interval_str))
.group_by(WorldVisit.world_id)
.subquery("trending_scores")
)
return subq
+3 -1
View File
@@ -45,6 +45,8 @@ TEST_TABLES = (
# `worlds` the way world_rooms/world_chunks would be. List all three
# explicitly rather than rely on cascade for some of them.
"world_chunk_revisions", "world_chunks", "world_rooms",
# Discovery (app/models/world_visit.py) -- FK worlds.id with ON DELETE CASCADE
"world_visits",
"worlds",
# Social graph (app/models/social.py) -- all three FK users with
# ON DELETE CASCADE, but list explicitly for clarity/consistency.
@@ -59,7 +61,7 @@ TEST_TABLES = (
# by cascading a truncate of users; list it explicitly for clarity too.
"group_members", "groups",
# Economy (app/models/wallet.py, app/models/marketplace.py)
"purchases", "marketplace_listings", "catalog_items", "creator_balances",
"payout_requests", "purchases", "marketplace_listings", "catalog_items", "creator_balances",
"transactions", "wallets",
"sessions", "refresh_tokens", "users",
)
+188
View File
@@ -0,0 +1,188 @@
"""API tests for discovery browse & search endpoints.
docs/planning/discovery.md (task 300da1dcddf3).
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
@pytest.mark.asyncio
async def test_discovery_public_worlds_only_and_fields():
client = TestClient(app)
uid, headers = _register(client, "creator_browse_1")
# Create public world
pub_resp = client.post(
"/api/worlds/",
headers=headers,
json={
"name": "Fantasy Kingdom",
"description": "A magical realm",
"visibility": "public",
"category": "RPG",
"tags": ["magic", "dragons"],
},
)
assert pub_resp.status_code == 201, pub_resp.text
pub_id = pub_resp.json()["id"]
# Create private world
priv_resp = client.post(
"/api/worlds/",
headers=headers,
json={
"name": "Secret Base",
"description": "Classified",
"visibility": "private",
"category": "RPG",
"tags": ["secret"],
},
)
assert priv_resp.status_code == 201, priv_resp.text
# 1. Anonymous listing: only public world is returned
anon_resp = client.get("/api/discovery/worlds")
assert anon_resp.status_code == 200, anon_resp.text
data = anon_resp.json()
assert data["total"] >= 1
items = data["items"]
ids = [w["id"] for w in items]
assert pub_id in ids
assert priv_resp.json()["id"] not in ids
# Check fields of returned item
pub_item = next(w for w in items if w["id"] == pub_id)
assert pub_item["name"] == "Fantasy Kingdom"
assert pub_item["description"] == "A magical realm"
assert pub_item["category"] == "RPG"
assert "magic" in pub_item["tags"]
assert pub_item["play_count"] == 0
assert pub_item["owner"]["id"] == uid
assert pub_item["owner"]["username"] == "creator_browse_1"
# 2. Authenticated caller also does NOT see private worlds in discovery
auth_resp = client.get("/api/discovery/worlds", headers=headers)
assert auth_resp.status_code == 200
auth_ids = [w["id"] for w in auth_resp.json()["items"]]
assert priv_resp.json()["id"] not in auth_ids
@pytest.mark.asyncio
async def test_discovery_filtering_and_categories():
client = TestClient(app)
uid, headers = _register(client, "creator_filter_2")
# Create distinct worlds
client.post(
"/api/worlds/",
headers=headers,
json={"name": "Cyber Arena", "visibility": "public", "category": "Action", "tags": ["pvp", "fps"]},
)
client.post(
"/api/worlds/",
headers=headers,
json={"name": "Peaceful Meadow", "visibility": "public", "category": "Social", "tags": ["relax", "hangout"]},
)
client.post(
"/api/worlds/",
headers=headers,
json={"name": "Cyber Hangout", "visibility": "public", "category": "Social", "tags": ["cyberpunk", "chat"]},
)
# Search by q
res_q = client.get("/api/discovery/worlds?q=cyber")
assert res_q.status_code == 200
names = [w["name"] for w in res_q.json()["items"]]
assert "Cyber Arena" in names
assert "Cyber Hangout" in names
assert "Peaceful Meadow" not in names
# Filter by category (case-insensitive)
res_cat = client.get("/api/discovery/worlds?category=action")
assert res_cat.status_code == 200
names = [w["name"] for w in res_cat.json()["items"]]
assert "Cyber Arena" in names
assert "Peaceful Meadow" not in names
# Filter by tags (overlap)
res_tags = client.get("/api/discovery/worlds?tags=relax,pvp")
assert res_tags.status_code == 200
names = [w["name"] for w in res_tags.json()["items"]]
assert "Peaceful Meadow" in names
assert "Cyber Arena" in names
assert "Cyber Hangout" not in names
# Get distinct categories
res_cats = client.get("/api/discovery/categories")
assert res_cats.status_code == 200
cats = res_cats.json()
assert "Action" in cats
assert "Social" in cats
@pytest.mark.asyncio
async def test_discovery_sorting_and_pagination():
client = TestClient(app)
uid, headers = _register(client, "creator_sort_3")
w1 = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Sort World 1", "visibility": "public"},
).json()
w2 = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Sort World 2", "visibility": "public"},
).json()
# Record visits on w1 so play_count = 1
v_res = client.post(f"/api/discovery/worlds/{w1['id']}/visit")
assert v_res.status_code == 200
# Sort by most_played
res_mp = client.get("/api/discovery/worlds?sort=most_played")
assert res_mp.status_code == 200
items = res_mp.json()["items"]
# w1 should come before w2
idx_w1 = next(i for i, w in enumerate(items) if w["id"] == w1["id"])
idx_w2 = next(i for i, w in enumerate(items) if w["id"] == w2["id"])
assert idx_w1 < idx_w2
# Sort by newest (default)
res_new = client.get("/api/discovery/worlds?sort=newest")
assert res_new.status_code == 200
items_new = res_new.json()["items"]
idx_w1_n = next(i for i, w in enumerate(items_new) if w["id"] == w1["id"])
idx_w2_n = next(i for i, w in enumerate(items_new) if w["id"] == w2["id"])
# w2 was created after w1, so w2 is newer
assert idx_w2_n < idx_w1_n
# Pagination: limit 1, skip 0
p1 = client.get("/api/discovery/worlds?limit=1&skip=0")
assert p1.status_code == 200
assert len(p1.json()["items"]) == 1
assert p1.json()["total"] >= 2
assert p1.json()["skip"] == 0
assert p1.json()["limit"] == 1
+149
View File
@@ -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()
+103
View File
@@ -0,0 +1,103 @@
"""API and heuristic tests for trending discovery sort.
docs/planning/discovery.md (task 33706f401ad9).
"""
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.world_visit import WorldVisit
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
@pytest.mark.asyncio
async def test_trending_sort_heuristic_and_zero_visits():
client = TestClient(app)
uid, headers = _register(client, "creator_trending_1")
# Create 3 worlds
wA = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Trending Hot World", "visibility": "public", "category": "Action"},
).json()
wB = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Older Mild World", "visibility": "public", "category": "Action"},
).json()
wC = client.post(
"/api/worlds/",
headers=headers,
json={"name": "Zero Visit World", "visibility": "public", "category": "Action"},
).json()
engine, sf = _session_factory()
now = datetime.now(timezone.utc)
try:
async with sf() as session:
# World A: 3 visits within last 2 hours (weight 3 each = score 9)
for i in range(3):
session.add(
WorldVisit(
world_id=wA["id"],
anonymous_token=f"hot_visitor_{i}",
visited_at=now - timedelta(hours=1, minutes=i),
)
)
# World B: 1 visit 5 days ago (weight 1 = score 1)
session.add(
WorldVisit(
world_id=wB["id"],
anonymous_token="old_visitor",
visited_at=now - timedelta(days=5),
)
)
# World C: zero visits
await session.commit()
finally:
await engine.dispose()
# Query with sort=trending
resp = client.get("/api/discovery/worlds?sort=trending&category=Action")
assert resp.status_code == 200, resp.text
items = resp.json()["items"]
# Verify order: wA should be first, wB second, wC third
ids = [item["id"] for item in items]
assert wA["id"] in ids
assert wB["id"] in ids
assert wC["id"] in ids
idxA = ids.index(wA["id"])
idxB = ids.index(wB["id"])
idxC = ids.index(wC["id"])
assert idxA < idxB < idxC, f"Expected order A ({idxA}) < B ({idxB}) < C ({idxC})"
+164
View File
@@ -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,190 @@
"""Integration tests for Marketplace Moderation, Content Flags, and Age Gate Enforcement.
docs/planning/economy.md US-3, Task 9 (task 4ba24a178c1b).
"""
from datetime import date
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(
client: TestClient, username: str, dob: str = "1990-01-01"
) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": dob,
},
)
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
async def _set_user_dob(user_id: int, dob: date):
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.models.user import User
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
user = await session.get(User, user_id)
assert user is not None
user.date_of_birth = dob
await session.commit()
await engine.dispose()
@pytest.mark.asyncio
async def test_marketplace_age_gate_enforcement():
client = TestClient(app)
seller_id, seller_headers = _register(client, "mature_seller_1")
# Register minor and set DOB in DB to 2015
minor_id, minor_headers = _register(client, "minor_buyer_1")
await _set_user_dob(minor_id, date(2015, 6, 1))
# Adult user
adult_id, adult_headers = _register(client, "adult_buyer_1")
# Fund minor and adult with 500 Credits
for h in (minor_headers, adult_headers):
s_resp = client.post("/api/payments/purchase", headers=h, json={"credits": 500})
client.post("/api/payments/confirm", headers=h, json={"session_id": s_resp.json()["session_id"]})
# Seller creates mature item
item_resp = client.post(
"/api/marketplace/items",
headers=seller_headers,
json={
"name": "Blood Sword",
"item_type": "avatar_item",
"metadata_json": {"is_mature": True, "content_flags": ["violence.gore"]},
},
)
assert item_resp.status_code == 201
item_id = item_resp.json()["id"]
list_resp = client.post(
"/api/marketplace/listings",
headers=seller_headers,
json={"catalog_item_id": item_id, "price": 100},
)
assert list_resp.status_code == 201
listing_id = list_resp.json()["id"]
# 1. Minor attempts to purchase mature item -> 403 Forbidden
minor_buy = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=minor_headers,
)
assert minor_buy.status_code == 403
assert "MATURE_CONTENT_REQUIRES_VERIFICATION" in minor_buy.text
# 2. Adult purchases mature item -> 201 Created
adult_buy = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=adult_headers,
)
assert adult_buy.status_code == 201, adult_buy.text
assert adult_buy.json()["status"] == "pending_escrow"
def test_moderator_delist_remove_and_reversal():
client = TestClient(app)
mod_id, mod_headers = _register(client, "admin_mkt_mod_1", dob="1980-01-01")
seller_id, seller_headers = _register(client, "seller_mod_test", dob="1990-01-01")
buyer_id, buyer_headers = _register(client, "buyer_mod_test", dob="1990-01-01")
# Fund buyer
s_resp = client.post("/api/payments/purchase", headers=buyer_headers, json={"credits": 500})
client.post("/api/payments/confirm", headers=buyer_headers, json={"session_id": s_resp.json()["session_id"]})
# Create listing 1
i1 = client.post(
"/api/marketplace/items",
headers=seller_headers,
json={"name": "Violating Item", "item_type": "avatar_item"},
).json()
l1 = client.post(
"/api/marketplace/listings",
headers=seller_headers,
json={"catalog_item_id": i1["id"], "price": 100},
).json()
l1_id = l1["id"]
# Non-moderator cannot moderate listing -> 403
unauth = client.post(
f"/api/marketplace/listings/{l1_id}/moderate",
headers=buyer_headers,
json={"action": "remove", "reason": "Copyright infringement"},
)
assert unauth.status_code == 403
# Moderator removes listing
mod_resp = client.post(
f"/api/marketplace/listings/{l1_id}/moderate",
headers=mod_headers,
json={"action": "remove", "reason": "Copyright infringement"},
)
assert mod_resp.status_code == 200, mod_resp.text
assert mod_resp.json()["status"] == "removed_by_moderation"
# Removed listing cannot be purchased
buy_fail = client.post(
f"/api/marketplace/listings/{l1_id}/purchase",
headers=buyer_headers,
)
assert buy_fail.status_code == 400
# Create listing 2 and purchase it
i2 = client.post(
"/api/marketplace/items",
headers=seller_headers,
json={"name": "Contested Item", "item_type": "avatar_item"},
).json()
l2 = client.post(
"/api/marketplace/listings",
headers=seller_headers,
json={"catalog_item_id": i2["id"], "price": 150},
).json()
p2 = client.post(
f"/api/marketplace/listings/{l2['id']}/purchase",
headers=buyer_headers,
).json()
p2_id = p2["id"]
# Buyer balance is 350
w = client.get("/api/wallet", headers=buyer_headers).json()
assert w["balance"] == 350
# Non-moderator cannot reverse purchase -> 403
unauth_rev = client.post(
f"/api/marketplace/purchases/{p2_id}/reverse",
headers=buyer_headers,
json={"reason": "Buyer regret"},
)
assert unauth_rev.status_code == 403
# Moderator reverses purchase
rev_resp = client.post(
f"/api/marketplace/purchases/{p2_id}/reverse",
headers=mod_headers,
json={"reason": "Fraudulent listing detected"},
)
assert rev_resp.status_code == 200, rev_resp.text
assert rev_resp.json()["status"] == "reversed"
# Buyer balance restored to 500
w_after = client.get("/api/wallet", headers=buyer_headers).json()
assert w_after["balance"] == 500
+108
View File
@@ -0,0 +1,108 @@
"""Integration tests for Payout Admin Review, PayoutProvider, and Ledger Audit Trail.
docs/planning/economy.md US-5 (task b52008923e93).
"""
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.marketplace import CreatorBalance
from app.models.wallet import Transaction, TransactionType
from app.services.payout import NoopPayoutProvider
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
async def _fund_creator_balance(user_id: int, available_amount: int):
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
cb = CreatorBalance(
user_id=user_id,
available=available_amount,
pending=0,
lifetime_earned=available_amount,
)
session.add(cb)
await session.commit()
await engine.dispose()
@pytest.mark.asyncio
async def test_payout_provider_noop():
provider = NoopPayoutProvider()
success, ref = await provider.pay(user_id=1, amount=500, external_ref="test@example.com")
assert success is True
assert ref.startswith("noop_payout_")
@pytest.mark.asyncio
async def test_payout_admin_review_audit_trail_and_ledger():
client = TestClient(app)
admin_id, admin_headers = _register(client, "admin_audit_reviewer")
creator_id, creator_headers = _register(client, "audit_creator_user")
await _fund_creator_balance(creator_id, 1500)
# 1. Creator requests payout
req_resp = client.post(
"/api/payouts",
headers=creator_headers,
json={"amount": 600, "external_ref": "stripe_acct_456"},
)
assert req_resp.status_code == 201, req_resp.text
payout_id = req_resp.json()["id"]
# 2. Admin approves payout
appr_resp = client.post(
f"/api/payouts/{payout_id}/approve",
headers=admin_headers,
)
assert appr_resp.status_code == 200, appr_resp.text
data = appr_resp.json()
assert data["status"] == "paid"
assert data["reviewed_by"] == admin_id
assert data["reviewed_at"] is not None
assert data["provider_ref"] is not None
provider_ref = data["provider_ref"]
# 3. Verify ledger transaction row was recorded for audit trail
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
stmt = (
select(Transaction)
.where(
Transaction.reference_type == "payout_request",
Transaction.reference_id == str(payout_id),
)
)
tx = (await session.execute(stmt)).scalar_one_or_none()
assert tx is not None
assert tx.type == TransactionType.payout
assert tx.amount == -600
assert tx.metadata_json["provider_ref"] == provider_ref
assert tx.metadata_json["reviewed_by"] == admin_id
assert tx.metadata_json["external_ref"] == "stripe_acct_456"
await engine.dispose()
+186
View File
@@ -0,0 +1,186 @@
"""Integration tests for Creator Payouts: requests, list, admin review, and balance accounting.
docs/planning/economy.md US-5 (tasks f5b4684f2e65, b52008923e93).
"""
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.marketplace import CreatorBalance
from app.models.user import User
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
async def _fund_creator_balance(user_id: int, available_amount: int):
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
cb = CreatorBalance(
user_id=user_id,
available=available_amount,
pending=0,
lifetime_earned=available_amount,
)
session.add(cb)
await session.commit()
await engine.dispose()
async def _make_admin(user_id: int):
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
user = await session.get(User, user_id)
assert user is not None
user.is_admin = True
await session.commit()
await engine.dispose()
@pytest.mark.asyncio
async def test_payout_request_and_validation():
client = TestClient(app)
uid, headers = _register(client, "payout_creator_1")
_, other_headers = _register(client, "payout_other_user")
# Min threshold is 100 CR
resp = client.post(
"/api/payouts",
headers=headers,
json={"amount": 50},
)
assert resp.status_code == 422, resp.text
# Without balance, payout of 200 CR fails
resp = client.post(
"/api/payouts",
headers=headers,
json={"amount": 200},
)
assert resp.status_code == 400
assert "Insufficient available balance" in resp.text
# Seed 500 CR available creator balance
await _fund_creator_balance(uid, 500)
# Valid payout request of 200 CR
resp = client.post(
"/api/payouts",
headers=headers,
json={"amount": 200, "external_ref": "creator@paypal.com"},
)
assert resp.status_code == 201, resp.text
payout_data = resp.json()
assert payout_data["amount"] == 200
assert payout_data["status"] == "pending"
assert payout_data["external_ref"] == "creator@paypal.com"
payout_id = payout_data["id"]
# Check creator balance: 500 - 200 = 300 available, 200 pending
cb_resp = client.get("/api/marketplace/creator/balance", headers=headers)
assert cb_resp.status_code == 200
cb = cb_resp.json()
assert cb["available"] == 300
assert cb["pending"] == 200
# List payouts: creator sees own request
list_resp = client.get("/api/payouts", headers=headers)
assert list_resp.status_code == 200
my_payouts = list_resp.json()
assert len(my_payouts) == 1
assert my_payouts[0]["id"] == payout_id
# Other user sees empty list
other_list = client.get("/api/payouts", headers=other_headers)
assert other_list.status_code == 200
assert len(other_list.json()) == 0
# Other user cannot view detail -> 403
det_resp = client.get(f"/api/payouts/{payout_id}", headers=other_headers)
assert det_resp.status_code == 403
@pytest.mark.asyncio
async def test_payout_admin_approve_and_reject():
client = TestClient(app)
admin_id, admin_headers = _register(client, "admin_payout_reviewer")
creator_id, creator_headers = _register(client, "payout_creator_2")
await _fund_creator_balance(creator_id, 1000)
# 1. Request payout of 300 CR
p1_resp = client.post(
"/api/payouts",
headers=creator_headers,
json={"amount": 300, "external_ref": "iban_123"},
)
assert p1_resp.status_code == 201
p1_id = p1_resp.json()["id"]
# 2. Non-admin cannot approve -> 403
non_admin_appr = client.post(f"/api/payouts/{p1_id}/approve", headers=creator_headers)
assert non_admin_appr.status_code == 403
# 3. Admin approves payout 1
appr_resp = client.post(f"/api/payouts/{p1_id}/approve", headers=admin_headers)
assert appr_resp.status_code == 200, appr_resp.text
p1_data = appr_resp.json()
assert p1_data["status"] == "paid"
assert p1_data["reviewed_by"] == admin_id
assert p1_data["provider_ref"] is not None
# Balance check: available was 700, pending was 300 -> now pending = 0
cb_resp = client.get("/api/marketplace/creator/balance", headers=creator_headers)
assert cb_resp.json()["available"] == 700
assert cb_resp.json()["pending"] == 0
# 4. Request payout 2 of 200 CR
p2_resp = client.post(
"/api/payouts",
headers=creator_headers,
json={"amount": 200},
)
assert p2_resp.status_code == 201
p2_id = p2_resp.json()["id"]
# available was 700 - 200 = 500, pending = 200
cb_resp = client.get("/api/marketplace/creator/balance", headers=creator_headers)
assert cb_resp.json()["available"] == 500
assert cb_resp.json()["pending"] == 200
# 5. Admin rejects payout 2
rej_resp = client.post(
f"/api/payouts/{p2_id}/reject",
headers=admin_headers,
json={"reason": "Incorrect payment details"},
)
assert rej_resp.status_code == 200, rej_resp.text
p2_data = rej_resp.json()
assert p2_data["status"] == "rejected"
assert p2_data["reviewed_by"] == admin_id
# Creator balance refunded: 500 + 200 = 700 available, 0 pending
cb_resp = client.get("/api/marketplace/creator/balance", headers=creator_headers)
assert cb_resp.json()["available"] == 700
assert cb_resp.json()["pending"] == 0
+150
View File
@@ -0,0 +1,150 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/discovery_ui.gd" id="1_disc"]
[node name="DiscoveryUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_disc")
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 24.0
offset_right = -32.0
offset_bottom = -24.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="Header" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBox/Header"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBox/Header"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 26
text = "Discover Experiences"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBox/Header"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="StatusLabel" type="Label" parent="VBox"]
visible = false
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.3, 0.3, 1)
text = ""
[node name="Filters" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="SearchEdit" type="LineEdit" parent="VBox/Filters"]
custom_minimum_size = Vector2(240, 36)
layout_mode = 2
placeholder_text = "Search by name or tag..."
[node name="CategoryOption" type="OptionButton" parent="VBox/Filters"]
custom_minimum_size = Vector2(160, 36)
layout_mode = 2
[node name="SortOption" type="OptionButton" parent="VBox/Filters"]
custom_minimum_size = Vector2(140, 36)
layout_mode = 2
[node name="FilterButton" type="Button" parent="VBox/Filters"]
custom_minimum_size = Vector2(90, 36)
layout_mode = 2
text = "Filter"
[node name="Content" type="Control" parent="VBox"]
layout_mode = 2
size_flags_vertical = 3
[node name="LoadingLabel" type="Label" parent="VBox/Content"]
visible = false
layout_mode = 1
anchors_preset = 14
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_top = -12.0
offset_bottom = 12.0
text = "Loading experiences..."
horizontal_alignment = 1
[node name="ErrorLabel" type="Label" parent="VBox/Content"]
visible = false
layout_mode = 1
anchors_preset = 14
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_top = -12.0
offset_bottom = 12.0
theme_override_colors/font_color = Color(1, 0.4, 0.4, 1)
text = ""
horizontal_alignment = 1
[node name="EmptyLabel" type="Label" parent="VBox/Content"]
visible = false
layout_mode = 1
anchors_preset = 14
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_top = -12.0
offset_bottom = 12.0
theme_override_colors/font_color = Color(0.7, 0.7, 0.7, 1)
text = "No experiences found."
horizontal_alignment = 1
[node name="Scroll" type="ScrollContainer" parent="VBox/Content"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="WorldsVBox" type="VBoxContainer" parent="VBox/Content/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 10
[node name="Pagination" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 16
alignment = 1
[node name="PrevButton" type="Button" parent="VBox/Pagination"]
custom_minimum_size = Vector2(90, 32)
layout_mode = 2
text = "< Prev"
[node name="PageLabel" type="Label" parent="VBox/Pagination"]
layout_mode = 2
text = "Page 1 of 1"
horizontal_alignment = 1
[node name="NextButton" type="Button" parent="VBox/Pagination"]
custom_minimum_size = Vector2(90, 32)
layout_mode = 2
text = "Next >"
+96
View File
@@ -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
+198
View File
@@ -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
+136
View File
@@ -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
View File
@@ -138,7 +138,8 @@ func request(
path: String,
body: String = "",
content_type: String = "application/json",
require_auth: bool = false
require_auth: bool = false,
custom_headers: PackedStringArray = PackedStringArray()
) -> Dictionary:
var http := HTTPRequest.new()
http.timeout = timeout_seconds
@@ -152,6 +153,8 @@ func request(
headers.append("Content-Type: " + content_type)
if require_auth and has_token():
headers.append("Authorization: " + bearer_token)
for ch in custom_headers:
headers.append(ch)
var url := _base_url + path
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)
# ---------------------------------------------------------------------------
# Discovery Features
# ---------------------------------------------------------------------------
## List public worlds for discovery with optional search, tags, category, and sort.
func get_discovery_worlds(
q: String = "",
tags: Array = [],
category: String = "",
sort: String = "newest",
skip: int = 0,
limit: int = 50
) -> Dictionary:
return await DiscoveryApiClient.get_worlds(self, q, tags, category, sort, skip, limit)
## Get distinct categories of public worlds.
func get_discovery_categories() -> Dictionary:
return await DiscoveryApiClient.get_categories(self)
## Record a visit to a world.
func record_world_visit(world_id: int, anonymous_token: String = "") -> Dictionary:
return await DiscoveryApiClient.record_visit(self, world_id, anonymous_token)
# ---------------------------------------------------------------------------
# Internals
+140
View File
@@ -0,0 +1,140 @@
extends RefCounted
class_name DiscoveryApiClient
## Helper client for AGP Discovery REST endpoints (Browse, Search, Categories, Visit tracking).
##
## docs/planning/discovery.md (task 56c49440d0a6).
class WorldSummary extends RefCounted:
var id: int = 0
var name: String = ""
var description: String = ""
var category: String = ""
var tags: Array = []
var play_count: int = 0
var created_at: String = ""
var owner_id: int = 0
var owner_username: String = ""
static func from_dict(d: Dictionary) -> WorldSummary:
var w := WorldSummary.new()
w.id = int(d.get("id", 0))
w.name = str(d.get("name", ""))
w.description = str(d.get("description", "")) if d.get("description") != null else ""
w.category = str(d.get("category", "")) if d.get("category") != null else ""
var raw_tags = d.get("tags", [])
w.tags = raw_tags if raw_tags is Array else []
w.play_count = int(d.get("play_count", 0))
w.created_at = str(d.get("created_at", "")) if d.get("created_at") != null else ""
var owner: Dictionary = d.get("owner", {}) if d.get("owner") is Dictionary else {}
w.owner_id = int(owner.get("id", 0))
w.owner_username = str(owner.get("username", ""))
return w
class DiscoveryResult extends RefCounted:
var items: Array[WorldSummary] = []
var total: int = 0
var skip: int = 0
var limit: int = 50
static func from_dict(d: Dictionary) -> DiscoveryResult:
var res := DiscoveryResult.new()
res.total = int(d.get("total", 0))
res.skip = int(d.get("skip", 0))
res.limit = int(d.get("limit", 50))
var raw_items = d.get("items", [])
if raw_items is Array:
for item in raw_items:
if item is Dictionary:
res.items.append(WorldSummary.from_dict(item))
return res
static func get_api_client(node: Node) -> Node:
if node.has_node("/root/ApiClient"):
return node.get_node("/root/ApiClient")
return null
## Fetch worlds with search, tags, category filter, sort, and pagination.
## Attaches JWT token if client has one; works anonymously without token.
## Surfaces transport/HTTP errors in the standard result dictionary.
static func get_worlds(
api: Node,
q: String = "",
tags: Array = [],
category: String = "",
sort: String = "newest",
skip: int = 0,
limit: int = 50
) -> Dictionary:
var params: Array[String] = []
if q != "":
params.append("q=" + q.uri_encode())
if tags.size() > 0:
var tag_strs: Array[String] = []
for t in tags:
tag_strs.append(str(t))
params.append("tags=" + ",".join(tag_strs).uri_encode())
if category != "":
params.append("category=" + category.uri_encode())
if sort != "":
params.append("sort=" + sort.uri_encode())
params.append("skip=%d" % skip)
params.append("limit=%d" % limit)
var url := "/api/discovery/worlds"
if params.size() > 0:
url += "?" + "&".join(params)
var custom_headers := PackedStringArray()
if api != null and api.has_method("has_token") and api.has_token():
custom_headers.append("Authorization: " + api.bearer_token)
var res: Dictionary = await api.request(
HTTPClient.METHOD_GET,
url,
"",
"application/json",
false,
custom_headers
)
if res.ok and res.data is Dictionary:
res["parsed"] = DiscoveryResult.from_dict(res.data)
return res
## Fetch distinct categories of public worlds.
static func get_categories(api: Node) -> Dictionary:
var custom_headers := PackedStringArray()
if api != null and api.has_method("has_token") and api.has_token():
custom_headers.append("Authorization: " + api.bearer_token)
return await api.request(
HTTPClient.METHOD_GET,
"/api/discovery/categories",
"",
"application/json",
false,
custom_headers
)
## Record a visit to a world. Supports optional anonymous token or logged in user.
static func record_visit(api: Node, world_id: int, anonymous_token: String = "") -> Dictionary:
var custom_headers := PackedStringArray()
if api != null and api.has_method("has_token") and api.has_token():
custom_headers.append("Authorization: " + api.bearer_token)
var payload := {}
if anonymous_token != "":
payload["anonymous_token"] = anonymous_token
return await api.request(
HTTPClient.METHOD_POST,
"/api/discovery/worlds/%d/visit" % world_id,
JSON.stringify(payload) if payload.size() > 0 else "",
"application/json",
false,
custom_headers
)
+376
View File
@@ -0,0 +1,376 @@
extends Control
class_name DiscoveryUI
## Discovery & Home Screen for browsing, searching, and entering experiences.
##
## docs/planning/discovery.md (task b77bddf31ba5).
signal back_requested
signal world_selected(world_id: int)
signal worlds_loaded(count: int)
@onready var back_button: Button = $VBox/Header/BackButton
@onready var refresh_button: Button = $VBox/Header/RefreshButton
@onready var status_label: Label = $VBox/StatusLabel
# Filter Controls
@onready var search_edit: LineEdit = $VBox/Filters/SearchEdit
@onready var category_option: OptionButton = $VBox/Filters/CategoryOption
@onready var sort_option: OptionButton = $VBox/Filters/SortOption
@onready var filter_button: Button = $VBox/Filters/FilterButton
# Content
@onready var loading_label: Label = $VBox/Content/LoadingLabel
@onready var error_label: Label = $VBox/Content/ErrorLabel
@onready var empty_label: Label = $VBox/Content/EmptyLabel
@onready var worlds_container: VBoxContainer = $VBox/Content/Scroll/WorldsVBox
# Pagination
@onready var prev_button: Button = $VBox/Pagination/PrevButton
@onready var next_button: Button = $VBox/Pagination/NextButton
@onready var page_label: Label = $VBox/Pagination/PageLabel
var _api_client: Node = null
var _current_page: int = 0
var _page_size: int = 10
var _total_count: int = 0
var _loaded_worlds: Array[DiscoveryApiClient.WorldSummary] = []
func _ready() -> void:
_api_client = _get_api()
_wire_signals()
_setup_sort_options()
_setup_initial_ui_state()
# Populate categories then load initial worlds feed
await load_categories()
await load_worlds(0)
func _get_api() -> Node:
if has_node("/root/ApiClient"):
return get_node("/root/ApiClient")
var api_script = load("res://scripts/api_client.gd")
if api_script:
var api = api_script.new()
add_child(api)
return api
return null
func _setup_initial_ui_state() -> void:
if loading_label: loading_label.visible = false
if error_label: error_label.visible = false
if empty_label: empty_label.visible = false
if prev_button: prev_button.disabled = true
if next_button: next_button.disabled = true
if page_label: page_label.text = "Page 1"
func _setup_sort_options() -> void:
if not sort_option:
return
sort_option.clear()
sort_option.add_item("Newest", 0)
sort_option.add_item("Most Played", 1)
sort_option.add_item("Trending", 2)
sort_option.selected = 0
func _wire_signals() -> void:
if back_button and not back_button.pressed.is_connected(_on_back_pressed):
back_button.pressed.connect(_on_back_pressed)
if refresh_button and not refresh_button.pressed.is_connected(_on_refresh_pressed):
refresh_button.pressed.connect(_on_refresh_pressed)
if filter_button and not filter_button.pressed.is_connected(_on_filter_pressed):
filter_button.pressed.connect(_on_filter_pressed)
if search_edit and not search_edit.text_submitted.is_connected(_on_search_submitted):
search_edit.text_submitted.connect(_on_search_submitted)
if category_option and not category_option.item_selected.is_connected(_on_category_selected):
category_option.item_selected.connect(_on_category_selected)
if sort_option and not sort_option.item_selected.is_connected(_on_sort_selected):
sort_option.item_selected.connect(_on_sort_selected)
if prev_button and not prev_button.pressed.is_connected(_on_prev_page_pressed):
prev_button.pressed.connect(_on_prev_page_pressed)
if next_button and not next_button.pressed.is_connected(_on_next_page_pressed):
next_button.pressed.connect(_on_next_page_pressed)
func load_categories() -> void:
if not category_option:
return
category_option.clear()
category_option.add_item("All Categories", 0)
if _api_client == null:
return
var res: Dictionary = await DiscoveryApiClient.get_categories(_api_client)
if res.ok and res.data is Array:
var idx := 1
for cat in res.data:
var cat_str := str(cat).strip_edges()
if cat_str != "":
category_option.add_item(cat_str, idx)
idx += 1
func load_worlds(page: int = 0) -> void:
_current_page = page
if loading_label: loading_label.visible = true
if error_label: error_label.visible = false
if empty_label: empty_label.visible = false
var q := search_edit.text.strip_edges() if search_edit else ""
var category := ""
if category_option and category_option.selected > 0:
category = category_option.get_item_text(category_option.selected)
var sort := "newest"
if sort_option:
match sort_option.selected:
0: sort = "newest"
1: sort = "most_played"
2: sort = "trending"
var skip := _current_page * _page_size
var res: Dictionary = await DiscoveryApiClient.get_worlds(
_api_client,
q,
[],
category,
sort,
skip,
_page_size
)
if loading_label: loading_label.visible = false
if not res.ok:
if error_label:
error_label.text = "Error: %s" % res.detail
error_label.visible = true
_clear_container()
return
var parsed: DiscoveryApiClient.DiscoveryResult = res.get("parsed")
if parsed == null and res.data is Dictionary:
parsed = DiscoveryApiClient.DiscoveryResult.from_dict(res.data)
if parsed == null:
if error_label:
error_label.text = "Invalid discovery response"
error_label.visible = true
_clear_container()
return
_total_count = parsed.total
_loaded_worlds = parsed.items
_render_worlds(_loaded_worlds)
_update_pagination()
worlds_loaded.emit(_loaded_worlds.size())
func _clear_container() -> void:
if not worlds_container:
return
for c in worlds_container.get_children():
worlds_container.remove_child(c)
c.queue_free()
func _render_worlds(worlds: Array[DiscoveryApiClient.WorldSummary]) -> void:
_clear_container()
if worlds.is_empty():
if empty_label:
empty_label.text = "No experiences found matching your criteria."
empty_label.visible = true
return
if empty_label: empty_label.visible = false
for world in worlds:
var card := _create_world_card(world)
worlds_container.add_child(card)
func _create_world_card(world: DiscoveryApiClient.WorldSummary) -> Control:
var panel := PanelContainer.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.custom_minimum_size = Vector2(0, 90)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.14, 0.16, 0.20, 1.0)
style.corner_radius_top_left = 6
style.corner_radius_top_right = 6
style.corner_radius_bottom_left = 6
style.corner_radius_bottom_right = 6
style.content_margin_left = 16
style.content_margin_right = 16
style.content_margin_top = 10
style.content_margin_bottom = 10
panel.add_theme_stylebox_override("panel", style)
var vbox := VBoxContainer.new()
vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
vbox.add_theme_constant_override("separation", 4)
panel.add_child(vbox)
# Title & Category Row
var title_row := HBoxContainer.new()
var name_label := Label.new()
name_label.text = world.name
name_label.add_theme_font_size_override("font_size", 18)
name_label.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0, 1.0))
title_row.add_child(name_label)
if world.category != "":
var cat_label := Label.new()
cat_label.text = " [%s]" % world.category
cat_label.add_theme_font_size_override("font_size", 14)
cat_label.add_theme_color_override("font_color", Color(0.4, 0.7, 1.0, 1.0))
title_row.add_child(cat_label)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
title_row.add_child(spacer)
# Enter World Button
var enter_button := Button.new()
enter_button.text = "Enter World"
enter_button.custom_minimum_size = Vector2(110, 30)
enter_button.pressed.connect(_on_enter_world_pressed.bind(world.id, enter_button))
title_row.add_child(enter_button)
vbox.add_child(title_row)
# Description
var desc_label := Label.new()
desc_label.text = world.description if world.description != "" else "No description provided."
desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD
desc_label.add_theme_font_size_override("font_size", 13)
desc_label.add_theme_color_override("font_color", Color(0.75, 0.75, 0.75, 1.0))
vbox.add_child(desc_label)
# Metadata Row: Tags, Plays, Owner
var meta_row := HBoxContainer.new()
meta_row.add_theme_constant_override("separation", 16)
var play_label := Label.new()
play_label.text = "%d plays" % world.play_count
play_label.add_theme_font_size_override("font_size", 12)
play_label.add_theme_color_override("font_color", Color(0.6, 0.9, 0.6, 1.0))
meta_row.add_child(play_label)
if world.tags.size() > 0:
var tags_str: String = ""
for t in world.tags:
tags_str += "#" + str(t) + " "
var tags_label := Label.new()
tags_label.text = tags_str.strip_edges()
tags_label.add_theme_font_size_override("font_size", 12)
tags_label.add_theme_color_override("font_color", Color(0.8, 0.7, 0.4, 1.0))
meta_row.add_child(tags_label)
if world.owner_username != "":
var owner_label := Label.new()
owner_label.text = "Creator: %s" % world.owner_username
owner_label.add_theme_font_size_override("font_size", 12)
owner_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6, 1.0))
meta_row.add_child(owner_label)
vbox.add_child(meta_row)
return panel
func _update_pagination() -> void:
var total_pages := int(ceil(float(_total_count) / float(_page_size)))
if total_pages <= 0:
total_pages = 1
if page_label:
page_label.text = "Page %d of %d (%d experiences)" % [_current_page + 1, total_pages, _total_count]
if prev_button:
prev_button.disabled = (_current_page <= 0)
if next_button:
next_button.disabled = ((_current_page + 1) >= total_pages)
func _on_enter_world_pressed(world_id: int, button: Button) -> void:
button.disabled = true
button.text = "Joining…"
world_selected.emit(world_id)
# 1. Record visit metric
if _api_client != null:
await DiscoveryApiClient.record_visit(_api_client, world_id)
# 2. Join flow (existing game server orchestration)
if _api_client != null and _api_client.has_method("join_world"):
var result: Dictionary = await _api_client.join_world(world_id)
if result.ok and result.data is Dictionary:
var d: Dictionary = result.data
if has_node("/root/Session"):
get_node("/root/Session").begin(world_id, str(d.get("host", "")), int(d.get("port", 0)))
get_tree().change_scene_to_file("res://scenes/game.tscn")
return
if result.status == 503:
# Dev fallback: local single-player
if has_node("/root/Session"):
get_node("/root/Session").clear()
get_tree().change_scene_to_file("res://scenes/game.tscn")
return
if status_label:
status_label.text = "Failed to join: " + str(result.detail)
status_label.visible = true
else:
# Fallback if standalone test or not wired
if has_node("/root/Session"):
get_node("/root/Session").clear()
get_tree().change_scene_to_file("res://scenes/game.tscn")
return
button.disabled = false
button.text = "Enter World"
func _on_filter_pressed() -> void:
load_worlds(0)
func _on_search_submitted(_new_text: String) -> void:
load_worlds(0)
func _on_category_selected(_index: int) -> void:
load_worlds(0)
func _on_sort_selected(_index: int) -> void:
load_worlds(0)
func _on_refresh_pressed() -> void:
load_worlds(_current_page)
func _on_prev_page_pressed() -> void:
if _current_page > 0:
load_worlds(_current_page - 1)
func _on_next_page_pressed() -> void:
var total_pages := int(ceil(float(_total_count) / float(_page_size)))
if (_current_page + 1) < total_pages:
load_worlds(_current_page + 1)
func _on_back_pressed() -> void:
back_requested.emit()
if get_tree():
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
+142
View File
@@ -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)
+231
View File
@@ -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)
+342
View File
@@ -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)
+62
View File
@@ -0,0 +1,62 @@
extends SceneTree
## Headless smoke test for DiscoveryApiClient.
func _init() -> void:
print("[SMOKE] Starting DiscoveryApiClient smoke test...")
# 1. Test WorldSummary parsing
var test_dict := {
"id": 42,
"name": "Neon Arcade",
"description": "Retro gaming lounge",
"category": "Arcade",
"tags": ["retro", "multiplayer"],
"play_count": 128,
"created_at": "2026-09-15T12:00:00Z",
"owner": {
"id": 7,
"username": "arcadegamer"
}
}
var summary := DiscoveryApiClient.WorldSummary.from_dict(test_dict)
assert(summary.id == 42, "summary id mismatch")
assert(summary.name == "Neon Arcade", "summary name mismatch")
assert(summary.category == "Arcade", "summary category mismatch")
assert(summary.tags.size() == 2, "summary tags size mismatch")
assert(summary.play_count == 128, "summary play_count mismatch")
assert(summary.owner_id == 7, "summary owner_id mismatch")
assert(summary.owner_username == "arcadegamer", "summary owner_username mismatch")
print("[PASS] WorldSummary parsing verified")
# 2. Test DiscoveryResult parsing
var page_dict := {
"items": [test_dict],
"total": 1,
"skip": 0,
"limit": 50
}
var res := DiscoveryApiClient.DiscoveryResult.from_dict(page_dict)
assert(res.total == 1, "res total mismatch")
assert(res.items.size() == 1, "res items size mismatch")
assert(res.items[0].id == 42, "res items[0] id mismatch")
print("[PASS] DiscoveryResult parsing verified")
# 3. Test ApiClient integration
var api_script = load("res://scripts/api_client.gd")
if not api_script:
printerr("[FAIL] Could not load api_client.gd")
quit(1)
return
var api: Node = api_script.new()
root.add_child(api)
assert(api.has_method("get_discovery_worlds"), "Missing get_discovery_worlds on ApiClient")
assert(api.has_method("get_discovery_categories"), "Missing get_discovery_categories on ApiClient")
assert(api.has_method("record_world_visit"), "Missing record_world_visit on ApiClient")
print("[PASS] ApiClient discovery methods verified")
api.queue_free()
print("[SMOKE] DiscoveryApiClient smoke test PASSED")
quit(0)
+89
View File
@@ -0,0 +1,89 @@
extends SceneTree
## Headless smoke test for DiscoveryUI.
func _initialize() -> void:
_run()
func _run() -> void:
print("[SMOKE] Starting DiscoveryUI smoke test...")
await process_frame
var scene = load("res://scenes/discovery_ui.tscn")
if not scene:
printerr("[FAIL] Could not load res://scenes/discovery_ui.tscn")
quit(1)
return
var ui: DiscoveryUI = scene.instantiate()
root.add_child(ui)
await process_frame
# 1. Verify node references
assert(ui.search_edit != null, "search_edit is null")
assert(ui.category_option != null, "category_option is null")
assert(ui.sort_option != null, "sort_option is null")
assert(ui.filter_button != null, "filter_button is null")
assert(ui.worlds_container != null, "worlds_container is null")
assert(ui.prev_button != null, "prev_button is null")
assert(ui.next_button != null, "next_button is null")
assert(ui.page_label != null, "page_label is null")
print("[PASS] Node references verified")
# 2. Verify sort options
assert(ui.sort_option.item_count == 3, "sort_option should have 3 items")
assert(ui.sort_option.get_item_text(0) == "Newest", "sort 0 should be Newest")
assert(ui.sort_option.get_item_text(1) == "Most Played", "sort 1 should be Most Played")
assert(ui.sort_option.get_item_text(2) == "Trending", "sort 2 should be Trending")
print("[PASS] Sort options verified")
# 3. Verify category dropdown initial state
assert(ui.category_option.item_count >= 1, "category_option should have at least All Categories")
assert(ui.category_option.get_item_text(0) == "All Categories", "category 0 should be All Categories")
print("[PASS] Category dropdown verified")
# 4. Test rendering of WorldSummary cards
var mock_w := DiscoveryApiClient.WorldSummary.new()
mock_w.id = 101
mock_w.name = "Cyber Nexus"
mock_w.category = "Action"
mock_w.tags = ["cyberpunk", "pvp"]
mock_w.play_count = 256
mock_w.description = "A futuristic arena"
mock_w.owner_username = "neon_builder"
var mock_worlds: Array[DiscoveryApiClient.WorldSummary] = [mock_w]
ui._render_worlds(mock_worlds)
assert(ui.worlds_container.get_child_count() == 1, "Should render 1 card")
assert(ui.empty_label.visible == false, "empty_label should be hidden when items present")
print("[PASS] World card rendering verified")
# 5. Test empty state
var empty_list: Array[DiscoveryApiClient.WorldSummary] = []
ui._render_worlds(empty_list)
assert(ui.worlds_container.get_child_count() == 0, "Container should have 0 children")
assert(ui.empty_label.visible == true, "empty_label should be visible when empty")
print("[PASS] Empty state verified")
# 6. Test pagination logic
ui._total_count = 25
ui._page_size = 10
ui._current_page = 0
ui._update_pagination()
assert(ui.prev_button.disabled == true, "Page 0 prev_button should be disabled")
assert(ui.next_button.disabled == false, "Page 0 next_button should be enabled")
assert(ui.page_label.text.contains("Page 1 of 3"), "Page 1 of 3 mismatch: %s" % ui.page_label.text)
ui._current_page = 1
ui._update_pagination()
assert(ui.prev_button.disabled == false, "Page 1 prev_button should be enabled")
assert(ui.next_button.disabled == false, "Page 1 next_button should be enabled")
ui._current_page = 2
ui._update_pagination()
assert(ui.prev_button.disabled == false, "Page 2 prev_button should be enabled")
assert(ui.next_button.disabled == true, "Page 2 next_button should be disabled")
print("[PASS] Pagination logic verified")
ui.queue_free()
print("[SMOKE] DiscoveryUI smoke test PASSED")
quit(0)
+44
View File
@@ -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)
+72
View File
@@ -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)
+84
View File
@@ -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)
+72
View File
@@ -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)
+222
View File
@@ -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()