Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38dc0cab21 | |||
| 892e330456 | |||
| e3188b6123 | |||
| f8b1fc4ea7 | |||
| 041b5f907e | |||
| 603ecdf5e2 | |||
| ffa9a9e6da | |||
| a4aa50edb9 | |||
| 30ff63f3fd | |||
| 5c4a45f3c9 |
@@ -0,0 +1,62 @@
|
||||
"""add discovery metadata to worlds and create world_visits table
|
||||
|
||||
Revision ID: 0015_add_discovery_metadata
|
||||
Revises: 0014_add_payout_requests
|
||||
Create Date: 2026-09-15 00:00:00.000000
|
||||
|
||||
docs/planning/discovery.md (task b377353b8167).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0015_add_discovery_metadata"
|
||||
down_revision: Union[str, None] = "0014_add_payout_requests"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Add discovery metadata columns to worlds table
|
||||
op.add_column("worlds", sa.Column("category", sa.String(length=100), nullable=True))
|
||||
op.add_column("worlds", sa.Column("tags", postgresql.ARRAY(sa.String()), server_default="{}", nullable=True))
|
||||
op.add_column("worlds", sa.Column("play_count", sa.Integer(), server_default="0", nullable=False))
|
||||
op.add_column("worlds", sa.Column("last_played_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
op.create_index("ix_worlds_category", "worlds", ["category"], unique=False)
|
||||
op.create_index("ix_worlds_tags", "worlds", ["tags"], unique=False, postgresql_using="gin")
|
||||
|
||||
# 2. Create world_visits table
|
||||
op.create_table(
|
||||
"world_visits",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("world_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("anonymous_token", sa.String(length=255), nullable=True),
|
||||
sa.Column("visited_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["world_id"], ["worlds.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_world_visits_world_id", "world_visits", ["world_id"], unique=False)
|
||||
op.create_index("ix_world_visits_user_id", "world_visits", ["user_id"], unique=False)
|
||||
op.create_index("ix_world_visits_visited_at", "world_visits", ["visited_at"], unique=False)
|
||||
op.create_index("ix_world_visits_world_visited_at", "world_visits", ["world_id", "visited_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_world_visits_world_visited_at", table_name="world_visits")
|
||||
op.drop_index("ix_world_visits_visited_at", table_name="world_visits")
|
||||
op.drop_index("ix_world_visits_user_id", table_name="world_visits")
|
||||
op.drop_index("ix_world_visits_world_id", table_name="world_visits")
|
||||
op.drop_table("world_visits")
|
||||
|
||||
op.drop_index("ix_worlds_tags", table_name="worlds", postgresql_using="gin")
|
||||
op.drop_index("ix_worlds_category", table_name="worlds")
|
||||
op.drop_column("worlds", "last_played_at")
|
||||
op.drop_column("worlds", "play_count")
|
||||
op.drop_column("worlds", "tags")
|
||||
op.drop_column("worlds", "category")
|
||||
+3
-1
@@ -9,7 +9,7 @@ from app.config import settings
|
||||
from app.routers import (
|
||||
auth, users, worlds, assets, avatar, world_content, internal,
|
||||
presence, groups, social_friends, profiles, chat_dm, chat_reports,
|
||||
chat_world, wallet, payments, marketplace, payouts,
|
||||
chat_world, wallet, payments, marketplace, payouts, discovery,
|
||||
)
|
||||
from app.gateway import ws
|
||||
|
||||
@@ -62,7 +62,9 @@ app.include_router(wallet.router, prefix="/api/wallet", tags=["wallet"])
|
||||
app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
|
||||
app.include_router(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"])
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""World visit tracking model for discovery analytics.
|
||||
|
||||
docs/planning/discovery.md (task b377353b8167).
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class WorldVisit(Base):
|
||||
__tablename__ = "world_visits"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
world_id = Column(Integer, ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
anonymous_token = Column(String(255), nullable=True)
|
||||
visited_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_world_visits_world_visited_at", "world_id", "visited_at"),
|
||||
)
|
||||
|
||||
world = relationship("World", back_populates="visits")
|
||||
user = relationship("User")
|
||||
@@ -0,0 +1,134 @@
|
||||
"""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 app.database import get_db
|
||||
from app.dependencies import OptionalUser
|
||||
from app.models.world import World, WorldVisibility
|
||||
from app.models.world_visit import WorldVisit
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Model-level integration tests for Discovery metadata and WorldVisit.
|
||||
|
||||
docs/planning/discovery.md (task b377353b8167).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.models.user import User
|
||||
from app.models.world import World, WorldVisibility
|
||||
from app.models.world_visit import WorldVisit
|
||||
|
||||
|
||||
def _register(client: TestClient, username: str) -> int:
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
assert me.status_code == 200, me.text
|
||||
return me.json()["id"]
|
||||
|
||||
|
||||
def _session_factory():
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
return engine, async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_world_discovery_metadata_and_visits():
|
||||
client = TestClient(app)
|
||||
user_id = _register(client, "discoverer_1")
|
||||
visitor_id = _register(client, "visitor_1")
|
||||
|
||||
engine, sf = _session_factory()
|
||||
try:
|
||||
async with sf() as session:
|
||||
# 1. Create a world with discovery metadata
|
||||
world = World(
|
||||
name="Cyber Adventure",
|
||||
description="An epic adventure world",
|
||||
owner_id=user_id,
|
||||
visibility=WorldVisibility.PUBLIC,
|
||||
category="Adventure",
|
||||
tags=["cyberpunk", "rpg", "quest"],
|
||||
)
|
||||
session.add(world)
|
||||
await session.commit()
|
||||
await session.refresh(world)
|
||||
|
||||
assert world.id is not None
|
||||
assert world.category == "Adventure"
|
||||
assert world.tags == ["cyberpunk", "rpg", "quest"]
|
||||
assert world.play_count == 0
|
||||
assert world.last_played_at is None
|
||||
|
||||
# 2. Add authenticated visit
|
||||
visit1 = WorldVisit(
|
||||
world_id=world.id,
|
||||
user_id=visitor_id,
|
||||
)
|
||||
# 3. Add anonymous visit
|
||||
visit2 = WorldVisit(
|
||||
world_id=world.id,
|
||||
user_id=None,
|
||||
anonymous_token="anon_token_12345",
|
||||
)
|
||||
session.add_all([visit1, visit2])
|
||||
await session.commit()
|
||||
|
||||
# Query world with visits
|
||||
res = await session.execute(
|
||||
select(World).where(World.id == world.id).options(selectinload(World.visits))
|
||||
)
|
||||
w = res.scalar_one()
|
||||
assert len(w.visits) == 2
|
||||
v_user = next(v for v in w.visits if v.user_id == visitor_id)
|
||||
assert v_user.visited_at is not None
|
||||
v_anon = next(v for v in w.visits if v.user_id is None)
|
||||
assert v_anon.anonymous_token == "anon_token_12345"
|
||||
|
||||
# 4. Verify cascade delete on world
|
||||
async with sf() as session:
|
||||
res = await session.execute(select(World).where(World.id == world.id))
|
||||
w = res.scalar_one()
|
||||
await session.delete(w)
|
||||
await session.commit()
|
||||
|
||||
# Visits should be gone
|
||||
res_v = await session.execute(select(WorldVisit).where(WorldVisit.world_id == world.id))
|
||||
assert len(res_v.scalars().all()) == 0
|
||||
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_delete_sets_null_on_world_visits():
|
||||
client = TestClient(app)
|
||||
owner_id = _register(client, "world_owner_2")
|
||||
visitor_id = _register(client, "temp_visitor_2")
|
||||
|
||||
engine, sf = _session_factory()
|
||||
try:
|
||||
async with sf() as session:
|
||||
world = World(
|
||||
name="Chill Lobby",
|
||||
owner_id=owner_id,
|
||||
visibility=WorldVisibility.PUBLIC,
|
||||
category="Social",
|
||||
tags=["hangout"],
|
||||
)
|
||||
session.add(world)
|
||||
await session.commit()
|
||||
await session.refresh(world)
|
||||
|
||||
visit = WorldVisit(world_id=world.id, user_id=visitor_id)
|
||||
session.add(visit)
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
visit_id = visit.id
|
||||
|
||||
# Delete visitor user
|
||||
visitor_user = await session.get(User, visitor_id)
|
||||
await session.delete(visitor_user)
|
||||
await session.commit()
|
||||
|
||||
# In a new session, query from database to see ON DELETE SET NULL effect
|
||||
async with sf() as session:
|
||||
v = await session.get(WorldVisit, visit_id)
|
||||
assert v is not None
|
||||
assert v.user_id is None
|
||||
assert v.world_id == world.id
|
||||
|
||||
finally:
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,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,96 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/marketplace_ui.gd" id="1_mpu"]
|
||||
|
||||
[node name="MarketplaceUI" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_mpu")
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 32.0
|
||||
offset_top = 32.0
|
||||
offset_right = -32.0
|
||||
offset_bottom = -32.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="HeaderHBox" type="HBoxContainer" parent="VBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="BackButton" type="Button" parent="VBox/HeaderHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "< Back"
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBox/HeaderHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 24
|
||||
text = "Marketplace"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="BalanceLabel" type="Label" parent="VBox/HeaderHBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_colors/font_color = Color(1, 0.84, 0, 1)
|
||||
text = "Wallet: 0 CR"
|
||||
|
||||
[node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Refresh"
|
||||
|
||||
[node name="FilterBar" type="HBoxContainer" parent="VBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="SearchEdit" type="LineEdit" parent="VBox/FilterBar"]
|
||||
custom_minimum_size = Vector2(200, 36)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Search items by name or description..."
|
||||
|
||||
[node name="TypeOption" type="OptionButton" parent="VBox/FilterBar"]
|
||||
custom_minimum_size = Vector2(140, 36)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="MinPriceEdit" type="LineEdit" parent="VBox/FilterBar"]
|
||||
custom_minimum_size = Vector2(90, 36)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Min CR"
|
||||
|
||||
[node name="MaxPriceEdit" type="LineEdit" parent="VBox/FilterBar"]
|
||||
custom_minimum_size = Vector2(90, 36)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Max CR"
|
||||
|
||||
[node name="SearchBtn" type="Button" parent="VBox/FilterBar"]
|
||||
custom_minimum_size = Vector2(90, 36)
|
||||
layout_mode = 2
|
||||
text = "Search"
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="VBox"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="VBox"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="ListingsGrid" type="GridContainer" parent="VBox/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/h_separation = 16
|
||||
theme_override_constants/v_separation = 16
|
||||
columns = 4
|
||||
@@ -0,0 +1,198 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/selling_payout_ui.gd" id="1_spu"]
|
||||
|
||||
[node name="SellingPayoutUI" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_spu")
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 32.0
|
||||
offset_top = 32.0
|
||||
offset_right = -32.0
|
||||
offset_bottom = -32.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="HeaderHBox" type="HBoxContainer" parent="VBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="BackButton" type="Button" parent="VBox/HeaderHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "< Back"
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBox/HeaderHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 24
|
||||
text = "Creator Studio — Selling & Payouts"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Refresh"
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="VBox"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TabContainer" type="TabContainer" parent="VBox"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="Selling" type="MarginContainer" parent="VBox/TabContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 12
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 12
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="VBox/TabContainer/Selling"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="CreateListingBox" type="HBoxContainer" parent="VBox/TabContainer/Selling/VBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="NameInput" type="LineEdit" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
||||
custom_minimum_size = Vector2(160, 36)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Item Name..."
|
||||
|
||||
[node name="TypeOption" type="OptionButton" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
||||
custom_minimum_size = Vector2(130, 36)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="DescInput" type="LineEdit" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
||||
custom_minimum_size = Vector2(180, 36)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Description..."
|
||||
|
||||
[node name="PriceInput" type="LineEdit" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
||||
custom_minimum_size = Vector2(90, 36)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Price (CR)"
|
||||
|
||||
[node name="CreateBtn" type="Button" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"]
|
||||
custom_minimum_size = Vector2(110, 36)
|
||||
layout_mode = 2
|
||||
text = "List for Sale"
|
||||
|
||||
[node name="ListingsTitle" type="Label" parent="VBox/TabContainer/Selling/VBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 16
|
||||
text = "Active Listings"
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="VBox/TabContainer/Selling/VBox"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="ListingsList" type="VBoxContainer" parent="VBox/TabContainer/Selling/VBox/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="Payouts" type="MarginContainer" parent="VBox/TabContainer"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 12
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 12
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="VBox/TabContainer/Payouts"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="BalanceCard" type="PanelContainer" parent="VBox/TabContainer/Payouts/VBox"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="VBox/TabContainer/Payouts/VBox/BalanceCard"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 16
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="HBox" type="HBoxContainer" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 24
|
||||
|
||||
[node name="AvailTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||
layout_mode = 2
|
||||
text = "Available to Payout:"
|
||||
|
||||
[node name="AvailableLbl" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_colors/font_color = Color(0.2, 0.9, 0.2, 1)
|
||||
text = "0 CR"
|
||||
|
||||
[node name="PendTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||
layout_mode = 2
|
||||
text = "In Escrow:"
|
||||
|
||||
[node name="PendingLbl" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_colors/font_color = Color(1, 0.8, 0.2, 1)
|
||||
text = "0 CR"
|
||||
|
||||
[node name="LifeTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||
layout_mode = 2
|
||||
text = "Lifetime Earned:"
|
||||
|
||||
[node name="LifetimeLbl" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_colors/font_color = Color(0.4, 0.7, 1, 1)
|
||||
text = "0 CR"
|
||||
|
||||
[node name="RequestBox" type="HBoxContainer" parent="VBox/TabContainer/Payouts/VBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="AmountInput" type="LineEdit" parent="VBox/TabContainer/Payouts/VBox/RequestBox"]
|
||||
custom_minimum_size = Vector2(130, 36)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Amount (min 100)"
|
||||
|
||||
[node name="DestInput" type="LineEdit" parent="VBox/TabContainer/Payouts/VBox/RequestBox"]
|
||||
custom_minimum_size = Vector2(220, 36)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Payout Destination (e.g. PayPal / IBAN)"
|
||||
|
||||
[node name="SubmitBtn" type="Button" parent="VBox/TabContainer/Payouts/VBox/RequestBox"]
|
||||
custom_minimum_size = Vector2(140, 36)
|
||||
layout_mode = 2
|
||||
text = "Request Payout"
|
||||
|
||||
[node name="PayoutsTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 16
|
||||
text = "Payout Requests History"
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="VBox/TabContainer/Payouts/VBox"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="PayoutsList" type="VBoxContainer" parent="VBox/TabContainer/Payouts/VBox/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 6
|
||||
@@ -0,0 +1,136 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/wallet_ui.gd" id="1_wui"]
|
||||
|
||||
[node name="WalletUI" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_wui")
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 32.0
|
||||
offset_top = 32.0
|
||||
offset_right = -32.0
|
||||
offset_bottom = -32.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="HeaderHBox" type="HBoxContainer" parent="VBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="BackButton" type="Button" parent="VBox/HeaderHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "< Back"
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBox/HeaderHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 24
|
||||
text = "Wallet & Credits"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Refresh"
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="VBox"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="BalanceCard" type="PanelContainer" parent="VBox"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="VBox/BalanceCard"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_top = 16
|
||||
theme_override_constants/margin_right = 16
|
||||
theme_override_constants/margin_bottom = 16
|
||||
|
||||
[node name="HBox" type="HBoxContainer" parent="VBox/BalanceCard/Margin"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 20
|
||||
|
||||
[node name="BalTitle" type="Label" parent="VBox/BalanceCard/Margin/HBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Available Balance:"
|
||||
|
||||
[node name="BalanceLabel" type="Label" parent="VBox/BalanceCard/Margin/HBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "0 CR"
|
||||
|
||||
[node name="Spacer" type="Control" parent="VBox/BalanceCard/Margin/HBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="PacksHBox" type="HBoxContainer" parent="VBox/BalanceCard/Margin/HBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Buy100Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
|
||||
layout_mode = 2
|
||||
text = "+100 CR ($0.99)"
|
||||
|
||||
[node name="Buy500Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
|
||||
layout_mode = 2
|
||||
text = "+500 CR ($4.99)"
|
||||
|
||||
[node name="Buy1000Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
|
||||
layout_mode = 2
|
||||
text = "+1,000 CR ($9.99)"
|
||||
|
||||
[node name="Buy5000Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
|
||||
layout_mode = 2
|
||||
text = "+5,000 CR ($44.99)"
|
||||
|
||||
[node name="LedgerSection" type="VBoxContainer" parent="VBox"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="LedgerTitle" type="Label" parent="VBox/LedgerSection"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Ledger History"
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="VBox/LedgerSection"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="TxList" type="VBoxContainer" parent="VBox/LedgerSection/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="PagingHBox" type="HBoxContainer" parent="VBox/LedgerSection"]
|
||||
layout_mode = 2
|
||||
alignment = 1
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="PrevPageBtn" type="Button" parent="VBox/LedgerSection/PagingHBox"]
|
||||
custom_minimum_size = Vector2(80, 32)
|
||||
layout_mode = 2
|
||||
text = "Previous"
|
||||
|
||||
[node name="PageLabel" type="Label" parent="VBox/LedgerSection/PagingHBox"]
|
||||
layout_mode = 2
|
||||
text = "Page 1 of 1"
|
||||
|
||||
[node name="NextPageBtn" type="Button" parent="VBox/LedgerSection/PagingHBox"]
|
||||
custom_minimum_size = Vector2(80, 32)
|
||||
layout_mode = 2
|
||||
text = "Next"
|
||||
@@ -0,0 +1,231 @@
|
||||
extends Control
|
||||
class_name MarketplaceUI
|
||||
## Marketplace Browse & Buy UI panel.
|
||||
##
|
||||
## Displays:
|
||||
## - Search bar with keyword, item type, and price filters
|
||||
## - Marketplace active listings cards
|
||||
## - Item details and direct atomic purchase flow
|
||||
## - Wallet balance and real-time status feedback
|
||||
##
|
||||
## docs/planning/economy.md US-6 (task 9797a0cfd9e6).
|
||||
|
||||
signal back_requested
|
||||
signal item_purchased(listing_id: int)
|
||||
|
||||
@onready var back_button: Button = $VBox/HeaderHBox/BackButton
|
||||
@onready var refresh_button: Button = $VBox/HeaderHBox/RefreshButton
|
||||
@onready var balance_label: Label = $VBox/HeaderHBox/BalanceLabel
|
||||
@onready var status_label: Label = $VBox/StatusLabel
|
||||
|
||||
# Filter inputs
|
||||
@onready var search_edit: LineEdit = $VBox/FilterBar/SearchEdit
|
||||
@onready var type_option: OptionButton = $VBox/FilterBar/TypeOption
|
||||
@onready var min_price_edit: LineEdit = $VBox/FilterBar/MinPriceEdit
|
||||
@onready var max_price_edit: LineEdit = $VBox/FilterBar/MaxPriceEdit
|
||||
@onready var search_btn: Button = $VBox/FilterBar/SearchBtn
|
||||
|
||||
# Listings container
|
||||
@onready var listings_container: GridContainer = $VBox/Scroll/ListingsGrid
|
||||
|
||||
var _api_client: Node = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_api_client = _get_api()
|
||||
_setup_type_options()
|
||||
_wire_signals()
|
||||
if _api_client != null and _api_client.has_token():
|
||||
refresh_balance()
|
||||
search_listings()
|
||||
|
||||
|
||||
func _get_api() -> Node:
|
||||
if has_node("/root/ApiClient"):
|
||||
return get_node("/root/ApiClient")
|
||||
return null
|
||||
|
||||
|
||||
func _setup_type_options() -> void:
|
||||
if not type_option:
|
||||
return
|
||||
type_option.clear()
|
||||
type_option.add_item("All Types", 0)
|
||||
type_option.add_item("Avatar Item", 1)
|
||||
type_option.add_item("Asset", 2)
|
||||
type_option.add_item("Experience Pass", 3)
|
||||
|
||||
|
||||
func _wire_signals() -> void:
|
||||
if back_button and not back_button.pressed.is_connected(_on_back_pressed):
|
||||
back_button.pressed.connect(_on_back_pressed)
|
||||
if refresh_button and not refresh_button.pressed.is_connected(refresh_all):
|
||||
refresh_button.pressed.connect(refresh_all)
|
||||
if search_btn and not search_btn.pressed.is_connected(search_listings):
|
||||
search_btn.pressed.connect(search_listings)
|
||||
if search_edit and not search_edit.text_submitted.is_connected(func(_txt): search_listings()):
|
||||
search_edit.text_submitted.connect(func(_txt): search_listings())
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
back_requested.emit()
|
||||
|
||||
|
||||
func show_status(text: String, is_error: bool = false) -> void:
|
||||
if not status_label:
|
||||
return
|
||||
status_label.text = text
|
||||
if is_error:
|
||||
status_label.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3))
|
||||
else:
|
||||
status_label.add_theme_color_override("font_color", Color(0.3, 0.9, 0.3))
|
||||
|
||||
|
||||
func refresh_all() -> void:
|
||||
refresh_balance()
|
||||
search_listings()
|
||||
|
||||
|
||||
func refresh_balance() -> void:
|
||||
var api = _get_api()
|
||||
if not api or not api.has_token():
|
||||
return
|
||||
var res: Dictionary = await EconomyApiClient.get_wallet(api)
|
||||
if res.ok and res.data is Dictionary:
|
||||
var balance: int = int(res.data.get("balance", 0))
|
||||
if balance_label:
|
||||
balance_label.text = "Wallet: %d CR" % balance
|
||||
|
||||
|
||||
func search_listings() -> void:
|
||||
var api = _get_api()
|
||||
if not api:
|
||||
return
|
||||
|
||||
var q: String = search_edit.text.strip_edges() if search_edit else ""
|
||||
var item_type: String = ""
|
||||
if type_option and type_option.selected > 0:
|
||||
match type_option.selected:
|
||||
1: item_type = "avatar_item"
|
||||
2: item_type = "asset"
|
||||
3: item_type = "experience_pass"
|
||||
|
||||
var min_price: int = -1
|
||||
if min_price_edit and min_price_edit.text.is_valid_int():
|
||||
min_price = min_price_edit.text.to_int()
|
||||
|
||||
var max_price: int = -1
|
||||
if max_price_edit and max_price_edit.text.is_valid_int():
|
||||
max_price = max_price_edit.text.to_int()
|
||||
|
||||
show_status("Loading listings...")
|
||||
var res: Dictionary = await EconomyApiClient.search_listings(api, q, item_type, min_price, max_price)
|
||||
if res.ok and res.data is Dictionary:
|
||||
var items: Array = res.data.get("listings", [])
|
||||
render_listings(items)
|
||||
show_status("Found %d active listings" % items.size())
|
||||
else:
|
||||
show_status("Search failed: " + res.detail, true)
|
||||
|
||||
|
||||
func render_listings(listings: Array) -> void:
|
||||
if not listings_container:
|
||||
return
|
||||
|
||||
for c in listings_container.get_children():
|
||||
listings_container.remove_child(c)
|
||||
c.queue_free()
|
||||
|
||||
if listings.is_empty():
|
||||
var empty_lbl := Label.new()
|
||||
empty_lbl.text = "No listings matching your search."
|
||||
empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
|
||||
listings_container.add_child(empty_lbl)
|
||||
return
|
||||
|
||||
for l in listings:
|
||||
if not l is Dictionary:
|
||||
continue
|
||||
var card := _create_listing_card(l)
|
||||
listings_container.add_child(card)
|
||||
|
||||
|
||||
func _create_listing_card(listing: Dictionary) -> Control:
|
||||
var card := PanelContainer.new()
|
||||
card.custom_minimum_size = Vector2(240, 200)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 12)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_right", 12)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
card.add_child(margin)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 8)
|
||||
margin.add_child(vbox)
|
||||
|
||||
var cat_item: Dictionary = listing.get("catalog_item", {})
|
||||
var title_lbl := Label.new()
|
||||
title_lbl.text = str(cat_item.get("name", "Unnamed Item"))
|
||||
title_lbl.add_theme_font_size_override("font_size", 16)
|
||||
vbox.add_child(title_lbl)
|
||||
|
||||
var type_lbl := Label.new()
|
||||
var itype: String = str(cat_item.get("item_type", "")).replace("_", " ").capitalize()
|
||||
type_lbl.text = itype
|
||||
type_lbl.add_theme_color_override("font_color", Color(0.4, 0.7, 1.0))
|
||||
type_lbl.add_theme_font_size_override("font_size", 12)
|
||||
vbox.add_child(type_lbl)
|
||||
|
||||
var seller_lbl := Label.new()
|
||||
var seller_name: String = str(listing.get("seller_username", "Unknown"))
|
||||
seller_lbl.text = "By " + seller_name
|
||||
seller_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
|
||||
seller_lbl.add_theme_font_size_override("font_size", 12)
|
||||
vbox.add_child(seller_lbl)
|
||||
|
||||
var desc_lbl := Label.new()
|
||||
var desc: String = str(cat_item.get("description", ""))
|
||||
if desc.length() > 60:
|
||||
desc = desc.substr(0, 57) + "..."
|
||||
desc_lbl.text = desc
|
||||
desc_lbl.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
desc_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
vbox.add_child(desc_lbl)
|
||||
|
||||
var bottom_hbox := HBoxContainer.new()
|
||||
vbox.add_child(bottom_hbox)
|
||||
|
||||
var price: int = int(listing.get("price", 0))
|
||||
var price_lbl := Label.new()
|
||||
price_lbl.text = "%d CR" % price
|
||||
price_lbl.add_theme_font_size_override("font_size", 16)
|
||||
price_lbl.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2))
|
||||
price_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
bottom_hbox.add_child(price_lbl)
|
||||
|
||||
var buy_btn := Button.new()
|
||||
buy_btn.text = "Buy"
|
||||
buy_btn.custom_minimum_size = Vector2(80, 32)
|
||||
var listing_id: int = int(listing.get("id", 0))
|
||||
buy_btn.pressed.connect(_on_buy_clicked.bind(listing_id, str(cat_item.get("name", "Item")), price))
|
||||
bottom_hbox.add_child(buy_btn)
|
||||
|
||||
return card
|
||||
|
||||
|
||||
func _on_buy_clicked(listing_id: int, item_name: String, price: int) -> void:
|
||||
var api = _get_api()
|
||||
if not api or not api.has_token():
|
||||
show_status("Please login to purchase items", true)
|
||||
return
|
||||
|
||||
show_status("Purchasing '%s' for %d CR..." % [item_name, price])
|
||||
var res: Dictionary = await EconomyApiClient.purchase_listing(api, listing_id)
|
||||
if res.ok:
|
||||
show_status("Successfully purchased '%s'! Item added to inventory." % item_name)
|
||||
item_purchased.emit(listing_id)
|
||||
refresh_all()
|
||||
else:
|
||||
show_status("Purchase failed: " + res.detail, true)
|
||||
@@ -0,0 +1,342 @@
|
||||
extends Control
|
||||
class_name SellingPayoutUI
|
||||
## Selling & Creator Payout UI panel.
|
||||
##
|
||||
## Displays:
|
||||
## - Tab 1 (Selling): Create new catalog item & marketplace listing, manage my active listings (update/delist)
|
||||
## - Tab 2 (Payouts): Creator balance breakdown (available/pending/lifetime), submit payout request, view payout history
|
||||
##
|
||||
## docs/planning/economy.md US-6 (task 96e5f11d7896).
|
||||
|
||||
signal back_requested
|
||||
|
||||
@onready var back_button: Button = $VBox/HeaderHBox/BackButton
|
||||
@onready var refresh_button: Button = $VBox/HeaderHBox/RefreshButton
|
||||
@onready var status_label: Label = $VBox/StatusLabel
|
||||
@onready var tab_container: TabContainer = $VBox/TabContainer
|
||||
|
||||
# Tab 1: Selling
|
||||
@onready var item_name_input: LineEdit = $VBox/TabContainer/Selling/VBox/CreateListingBox/NameInput
|
||||
@onready var item_type_option: OptionButton = $VBox/TabContainer/Selling/VBox/CreateListingBox/TypeOption
|
||||
@onready var item_desc_input: LineEdit = $VBox/TabContainer/Selling/VBox/CreateListingBox/DescInput
|
||||
@onready var price_input: LineEdit = $VBox/TabContainer/Selling/VBox/CreateListingBox/PriceInput
|
||||
@onready var create_listing_btn: Button = $VBox/TabContainer/Selling/VBox/CreateListingBox/CreateBtn
|
||||
@onready var my_listings_container: VBoxContainer = $VBox/TabContainer/Selling/VBox/Scroll/ListingsList
|
||||
|
||||
# Tab 2: Payouts
|
||||
@onready var available_lbl: Label = $VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox/AvailableLbl
|
||||
@onready var pending_lbl: Label = $VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox/PendingLbl
|
||||
@onready var lifetime_lbl: Label = $VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox/LifetimeLbl
|
||||
|
||||
@onready var payout_amount_input: LineEdit = $VBox/TabContainer/Payouts/VBox/RequestBox/AmountInput
|
||||
@onready var payout_dest_input: LineEdit = $VBox/TabContainer/Payouts/VBox/RequestBox/DestInput
|
||||
@onready var request_payout_btn: Button = $VBox/TabContainer/Payouts/VBox/RequestBox/SubmitBtn
|
||||
@onready var payouts_container: VBoxContainer = $VBox/TabContainer/Payouts/VBox/Scroll/PayoutsList
|
||||
|
||||
var _api_client: Node = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_api_client = _get_api()
|
||||
_setup_type_options()
|
||||
_wire_signals()
|
||||
if _api_client != null and _api_client.has_token():
|
||||
refresh_all()
|
||||
|
||||
|
||||
func _get_api() -> Node:
|
||||
if has_node("/root/ApiClient"):
|
||||
return get_node("/root/ApiClient")
|
||||
return null
|
||||
|
||||
|
||||
func _setup_type_options() -> void:
|
||||
if not item_type_option:
|
||||
return
|
||||
item_type_option.clear()
|
||||
item_type_option.add_item("Avatar Item", 0)
|
||||
item_type_option.add_item("Asset", 1)
|
||||
item_type_option.add_item("Experience Pass", 2)
|
||||
|
||||
|
||||
func _wire_signals() -> void:
|
||||
if back_button and not back_button.pressed.is_connected(_on_back_pressed):
|
||||
back_button.pressed.connect(_on_back_pressed)
|
||||
if refresh_button and not refresh_button.pressed.is_connected(refresh_all):
|
||||
refresh_button.pressed.connect(refresh_all)
|
||||
if create_listing_btn and not create_listing_btn.pressed.is_connected(_on_create_listing_pressed):
|
||||
create_listing_btn.pressed.connect(_on_create_listing_pressed)
|
||||
if request_payout_btn and not request_payout_btn.pressed.is_connected(_on_request_payout_pressed):
|
||||
request_payout_btn.pressed.connect(_on_request_payout_pressed)
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
back_requested.emit()
|
||||
|
||||
|
||||
func show_status(text: String, is_error: bool = false) -> void:
|
||||
if not status_label:
|
||||
return
|
||||
status_label.text = text
|
||||
if is_error:
|
||||
status_label.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3))
|
||||
else:
|
||||
status_label.add_theme_color_override("font_color", Color(0.3, 0.9, 0.3))
|
||||
|
||||
|
||||
func refresh_all() -> void:
|
||||
refresh_my_listings()
|
||||
refresh_creator_balance()
|
||||
refresh_payouts()
|
||||
|
||||
|
||||
# ── Selling / Listings ──
|
||||
|
||||
func refresh_my_listings() -> void:
|
||||
var api = _get_api()
|
||||
if not api or not api.has_token():
|
||||
return
|
||||
|
||||
# Fetch my items
|
||||
var items_res: Dictionary = await EconomyApiClient.get_catalog_items(api, true)
|
||||
# Fetch active listings
|
||||
var list_res: Dictionary = await EconomyApiClient.search_listings(api, "", "", -1, -1, 100, 0)
|
||||
|
||||
if list_res.ok and list_res.data is Dictionary:
|
||||
var all_listings: Array = list_res.data.get("listings", [])
|
||||
# Filter for seller_id or user's items
|
||||
render_my_listings(all_listings)
|
||||
|
||||
|
||||
func _on_create_listing_pressed() -> void:
|
||||
var api = _get_api()
|
||||
if not api or not api.has_token():
|
||||
show_status("Please login to create listings", true)
|
||||
return
|
||||
|
||||
var item_name: String = item_name_input.text.strip_edges() if item_name_input else ""
|
||||
if item_name == "":
|
||||
show_status("Item name is required", true)
|
||||
return
|
||||
|
||||
var itype: String = "avatar_item"
|
||||
if item_type_option:
|
||||
match item_type_option.selected:
|
||||
0: itype = "avatar_item"
|
||||
1: itype = "asset"
|
||||
2: itype = "experience_pass"
|
||||
|
||||
var desc: String = item_desc_input.text.strip_edges() if item_desc_input else ""
|
||||
var price_text: String = price_input.text.strip_edges() if price_input else ""
|
||||
if not price_text.is_valid_int() or price_text.to_int() <= 0:
|
||||
show_status("Valid positive price in Credits is required", true)
|
||||
return
|
||||
var price: int = price_text.to_int()
|
||||
|
||||
show_status("Creating item and listing...")
|
||||
# 1. Create Catalog Item
|
||||
var item_res: Dictionary = await EconomyApiClient.create_catalog_item(api, item_name, itype, desc)
|
||||
if not item_res.ok or not item_res.data is Dictionary:
|
||||
show_status("Failed to create catalog item: " + item_res.detail, true)
|
||||
return
|
||||
|
||||
var item_id: int = int(item_res.data.get("id", 0))
|
||||
|
||||
# 2. Create Listing
|
||||
var list_res: Dictionary = await EconomyApiClient.create_listing(api, item_id, price)
|
||||
if list_res.ok:
|
||||
show_status("Successfully listed '%s' for %d Credits!" % [item_name, price])
|
||||
if item_name_input: item_name_input.text = ""
|
||||
if item_desc_input: item_desc_input.text = ""
|
||||
if price_input: price_input.text = ""
|
||||
refresh_my_listings()
|
||||
else:
|
||||
show_status("Failed to create listing: " + list_res.detail, true)
|
||||
|
||||
|
||||
func render_my_listings(listings: Array) -> void:
|
||||
if not my_listings_container:
|
||||
return
|
||||
|
||||
for c in my_listings_container.get_children():
|
||||
my_listings_container.remove_child(c)
|
||||
c.queue_free()
|
||||
|
||||
if listings.is_empty():
|
||||
var empty_lbl := Label.new()
|
||||
empty_lbl.text = "No listings found."
|
||||
empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
|
||||
my_listings_container.add_child(empty_lbl)
|
||||
return
|
||||
|
||||
for l in listings:
|
||||
if not l is Dictionary:
|
||||
continue
|
||||
var row := PanelContainer.new()
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 12)
|
||||
row.add_child(hbox)
|
||||
|
||||
var cat_item: Dictionary = l.get("catalog_item", {})
|
||||
var name_lbl := Label.new()
|
||||
name_lbl.text = str(cat_item.get("name", "Item"))
|
||||
name_lbl.custom_minimum_size = Vector2(160, 0)
|
||||
name_lbl.add_theme_font_size_override("font_size", 14)
|
||||
hbox.add_child(name_lbl)
|
||||
|
||||
var type_lbl := Label.new()
|
||||
type_lbl.text = str(cat_item.get("item_type", "")).replace("_", " ").capitalize()
|
||||
type_lbl.custom_minimum_size = Vector2(120, 0)
|
||||
type_lbl.add_theme_color_override("font_color", Color(0.5, 0.7, 1.0))
|
||||
hbox.add_child(type_lbl)
|
||||
|
||||
var price_lbl := Label.new()
|
||||
price_lbl.text = "%d CR" % int(l.get("price", 0))
|
||||
price_lbl.custom_minimum_size = Vector2(80, 0)
|
||||
price_lbl.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2))
|
||||
hbox.add_child(price_lbl)
|
||||
|
||||
var status_lbl_item := Label.new()
|
||||
var l_status: String = str(l.get("status", ""))
|
||||
status_lbl_item.text = l_status.capitalize()
|
||||
status_lbl_item.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
hbox.add_child(status_lbl_item)
|
||||
|
||||
var listing_id: int = int(l.get("id", 0))
|
||||
if l_status == "active":
|
||||
var delist_btn := Button.new()
|
||||
delist_btn.text = "Delist"
|
||||
delist_btn.pressed.connect(_on_delist_pressed.bind(listing_id))
|
||||
hbox.add_child(delist_btn)
|
||||
|
||||
my_listings_container.add_child(row)
|
||||
|
||||
|
||||
func _on_delist_pressed(listing_id: int) -> void:
|
||||
var api = _get_api()
|
||||
if not api or not api.has_token():
|
||||
return
|
||||
show_status("Delisting listing #%d..." % listing_id)
|
||||
var res: Dictionary = await EconomyApiClient.delist_listing(api, listing_id)
|
||||
if res.ok:
|
||||
show_status("Listing #%d delisted" % listing_id)
|
||||
refresh_my_listings()
|
||||
else:
|
||||
show_status("Failed to delist: " + res.detail, true)
|
||||
|
||||
|
||||
# ── Creator Balances & Payouts ──
|
||||
|
||||
func refresh_creator_balance() -> void:
|
||||
var api = _get_api()
|
||||
if not api or not api.has_token():
|
||||
return
|
||||
var res: Dictionary = await EconomyApiClient.get_creator_balance(api)
|
||||
if res.ok and res.data is Dictionary:
|
||||
var avail: int = int(res.data.get("available", 0))
|
||||
var pend: int = int(res.data.get("pending", 0))
|
||||
var life: int = int(res.data.get("lifetime_earned", 0))
|
||||
|
||||
if available_lbl: available_lbl.text = "%d CR" % avail
|
||||
if pending_lbl: pending_lbl.text = "%d CR" % pend
|
||||
if lifetime_lbl: lifetime_lbl.text = "%d CR" % life
|
||||
|
||||
|
||||
func refresh_payouts() -> void:
|
||||
var api = _get_api()
|
||||
if not api or not api.has_token():
|
||||
return
|
||||
var res: Dictionary = await EconomyApiClient.get_payouts(api)
|
||||
if res.ok and res.data is Array:
|
||||
render_payouts(res.data)
|
||||
|
||||
|
||||
func _on_request_payout_pressed() -> void:
|
||||
var api = _get_api()
|
||||
if not api or not api.has_token():
|
||||
show_status("Please login to request payouts", true)
|
||||
return
|
||||
|
||||
var amt_str: String = payout_amount_input.text.strip_edges() if payout_amount_input else ""
|
||||
if not amt_str.is_valid_int() or amt_str.to_int() < 100:
|
||||
show_status("Minimum payout amount is 100 Credits", true)
|
||||
return
|
||||
var amount: int = amt_str.to_int()
|
||||
|
||||
var dest: String = payout_dest_input.text.strip_edges() if payout_dest_input else ""
|
||||
show_status("Submitting payout request for %d CR..." % amount)
|
||||
var res: Dictionary = await EconomyApiClient.request_payout(api, amount, dest)
|
||||
if res.ok:
|
||||
show_status("Payout request for %d CR submitted successfully!" % amount)
|
||||
if payout_amount_input: payout_amount_input.text = ""
|
||||
if payout_dest_input: payout_dest_input.text = ""
|
||||
refresh_creator_balance()
|
||||
refresh_payouts()
|
||||
else:
|
||||
show_status("Payout request failed: " + res.detail, true)
|
||||
|
||||
|
||||
func render_payouts(payouts: Array) -> void:
|
||||
if not payouts_container:
|
||||
return
|
||||
|
||||
for c in payouts_container.get_children():
|
||||
payouts_container.remove_child(c)
|
||||
c.queue_free()
|
||||
|
||||
if payouts.is_empty():
|
||||
var empty_lbl := Label.new()
|
||||
empty_lbl.text = "No payout history."
|
||||
empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
|
||||
payouts_container.add_child(empty_lbl)
|
||||
return
|
||||
|
||||
for p in payouts:
|
||||
if not p is Dictionary:
|
||||
continue
|
||||
var row := PanelContainer.new()
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 12)
|
||||
row.add_child(hbox)
|
||||
|
||||
var date_str: String = str(p.get("created_at", ""))
|
||||
if date_str.length() > 19:
|
||||
date_str = date_str.substr(0, 19).replace("T", " ")
|
||||
var date_lbl := Label.new()
|
||||
date_lbl.text = date_str
|
||||
date_lbl.custom_minimum_size = Vector2(140, 0)
|
||||
date_lbl.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
|
||||
hbox.add_child(date_lbl)
|
||||
|
||||
var amt_lbl := Label.new()
|
||||
amt_lbl.text = "%d CR" % int(p.get("amount", 0))
|
||||
amt_lbl.custom_minimum_size = Vector2(90, 0)
|
||||
amt_lbl.add_theme_font_size_override("font_size", 14)
|
||||
hbox.add_child(amt_lbl)
|
||||
|
||||
var status_str: String = str(p.get("status", "")).capitalize()
|
||||
var stat_lbl := Label.new()
|
||||
stat_lbl.text = status_str
|
||||
stat_lbl.custom_minimum_size = Vector2(100, 0)
|
||||
if status_str == "Paid":
|
||||
stat_lbl.add_theme_color_override("font_color", Color(0.2, 0.9, 0.2))
|
||||
elif status_str == "Rejected":
|
||||
stat_lbl.add_theme_color_override("font_color", Color(0.9, 0.3, 0.3))
|
||||
else:
|
||||
stat_lbl.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2))
|
||||
hbox.add_child(stat_lbl)
|
||||
|
||||
var ref_lbl := Label.new()
|
||||
var ext_ref: String = str(p.get("external_ref", ""))
|
||||
var prov_ref: String = str(p.get("provider_ref", ""))
|
||||
if prov_ref != "":
|
||||
ref_lbl.text = "Ref: %s" % prov_ref
|
||||
elif ext_ref != "":
|
||||
ref_lbl.text = "Dest: %s" % ext_ref
|
||||
else:
|
||||
ref_lbl.text = "-"
|
||||
ref_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
ref_lbl.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5))
|
||||
hbox.add_child(ref_lbl)
|
||||
|
||||
payouts_container.add_child(row)
|
||||
@@ -0,0 +1,72 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for MarketplaceUI scene and script.
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
print("[SMOKE] Starting MarketplaceUI smoke test...")
|
||||
|
||||
var scene = load("res://scenes/marketplace_ui.tscn")
|
||||
if not scene:
|
||||
printerr("[FAIL] Could not load scenes/marketplace_ui.tscn")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var ui: Control = scene.instantiate()
|
||||
root.add_child(ui)
|
||||
await process_frame
|
||||
|
||||
# Verify onready nodes
|
||||
assert(ui.back_button != null, "Missing back_button")
|
||||
assert(ui.refresh_button != null, "Missing refresh_button")
|
||||
assert(ui.balance_label != null, "Missing balance_label")
|
||||
assert(ui.status_label != null, "Missing status_label")
|
||||
assert(ui.search_edit != null, "Missing search_edit")
|
||||
assert(ui.type_option != null, "Missing type_option")
|
||||
assert(ui.min_price_edit != null, "Missing min_price_edit")
|
||||
assert(ui.max_price_edit != null, "Missing max_price_edit")
|
||||
assert(ui.search_btn != null, "Missing search_btn")
|
||||
assert(ui.listings_container != null, "Missing listings_container")
|
||||
|
||||
# Verify type options populated
|
||||
assert(ui.type_option.item_count >= 4, "Type options not populated")
|
||||
|
||||
# Test rendering empty listings
|
||||
ui.render_listings([])
|
||||
assert(ui.listings_container.get_child_count() == 1, "Should show empty notice")
|
||||
|
||||
# Test rendering sample listings
|
||||
var sample_listings = [
|
||||
{
|
||||
"id": 10,
|
||||
"price": 200,
|
||||
"seller_username": "merchant_bob",
|
||||
"catalog_item": {
|
||||
"name": "Wizard Robe",
|
||||
"item_type": "avatar_item",
|
||||
"description": "Flowing velvet robe"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"price": 500,
|
||||
"seller_username": "dungeon_master",
|
||||
"catalog_item": {
|
||||
"name": "Castle Map",
|
||||
"item_type": "asset",
|
||||
"description": "Detailed 3D fortress map"
|
||||
}
|
||||
}
|
||||
]
|
||||
ui.render_listings(sample_listings)
|
||||
assert(ui.listings_container.get_child_count() == 2, "Should render 2 listing cards")
|
||||
|
||||
# Verify signals
|
||||
assert(ui.has_signal("back_requested"), "Missing back_requested signal")
|
||||
assert(ui.has_signal("item_purchased"), "Missing item_purchased signal")
|
||||
|
||||
ui.queue_free()
|
||||
print("[SMOKE] MarketplaceUI smoke test PASSED")
|
||||
quit(0)
|
||||
@@ -0,0 +1,84 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for SellingPayoutUI scene and script.
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
print("[SMOKE] Starting SellingPayoutUI smoke test...")
|
||||
|
||||
var scene = load("res://scenes/selling_payout_ui.tscn")
|
||||
if not scene:
|
||||
printerr("[FAIL] Could not load scenes/selling_payout_ui.tscn")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var ui: Control = scene.instantiate()
|
||||
root.add_child(ui)
|
||||
await process_frame
|
||||
|
||||
# Verify onready nodes
|
||||
assert(ui.back_button != null, "Missing back_button")
|
||||
assert(ui.refresh_button != null, "Missing refresh_button")
|
||||
assert(ui.status_label != null, "Missing status_label")
|
||||
assert(ui.tab_container != null, "Missing tab_container")
|
||||
|
||||
# Selling nodes
|
||||
assert(ui.item_name_input != null, "Missing item_name_input")
|
||||
assert(ui.item_type_option != null, "Missing item_type_option")
|
||||
assert(ui.item_desc_input != null, "Missing item_desc_input")
|
||||
assert(ui.price_input != null, "Missing price_input")
|
||||
assert(ui.create_listing_btn != null, "Missing create_listing_btn")
|
||||
assert(ui.my_listings_container != null, "Missing my_listings_container")
|
||||
|
||||
# Payout nodes
|
||||
assert(ui.available_lbl != null, "Missing available_lbl")
|
||||
assert(ui.pending_lbl != null, "Missing pending_lbl")
|
||||
assert(ui.lifetime_lbl != null, "Missing lifetime_lbl")
|
||||
assert(ui.payout_amount_input != null, "Missing payout_amount_input")
|
||||
assert(ui.payout_dest_input != null, "Missing payout_dest_input")
|
||||
assert(ui.request_payout_btn != null, "Missing request_payout_btn")
|
||||
assert(ui.payouts_container != null, "Missing payouts_container")
|
||||
|
||||
# Test rendering sample listings
|
||||
var sample_listings = [
|
||||
{
|
||||
"id": 101,
|
||||
"price": 300,
|
||||
"status": "active",
|
||||
"catalog_item": {
|
||||
"name": "Knight Helmet",
|
||||
"item_type": "avatar_item"
|
||||
}
|
||||
}
|
||||
]
|
||||
ui.render_my_listings(sample_listings)
|
||||
assert(ui.my_listings_container.get_child_count() == 1, "Should render 1 listing row")
|
||||
|
||||
# Test rendering sample payouts
|
||||
var sample_payouts = [
|
||||
{
|
||||
"id": 5,
|
||||
"amount": 250,
|
||||
"status": "paid",
|
||||
"created_at": "2026-09-15T15:00:00Z",
|
||||
"provider_ref": "stripe_123"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"amount": 100,
|
||||
"status": "pending",
|
||||
"created_at": "2026-09-15T16:00:00Z",
|
||||
"external_ref": "paypal@creator.com"
|
||||
}
|
||||
]
|
||||
ui.render_payouts(sample_payouts)
|
||||
assert(ui.payouts_container.get_child_count() == 2, "Should render 2 payout rows")
|
||||
|
||||
# Verify signals
|
||||
assert(ui.has_signal("back_requested"), "Missing back_requested signal")
|
||||
|
||||
ui.queue_free()
|
||||
print("[SMOKE] SellingPayoutUI smoke test PASSED")
|
||||
quit(0)
|
||||
@@ -0,0 +1,72 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for WalletUI scene and script.
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
print("[SMOKE] Starting WalletUI smoke test...")
|
||||
|
||||
var scene = load("res://scenes/wallet_ui.tscn")
|
||||
if not scene:
|
||||
printerr("[FAIL] Could not load scenes/wallet_ui.tscn")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var ui: Control = scene.instantiate()
|
||||
root.add_child(ui)
|
||||
await process_frame
|
||||
|
||||
# Verify onready nodes
|
||||
assert(ui.back_button != null, "Missing back_button")
|
||||
assert(ui.refresh_button != null, "Missing refresh_button")
|
||||
assert(ui.balance_label != null, "Missing balance_label")
|
||||
assert(ui.buy_100_btn != null, "Missing buy_100_btn")
|
||||
assert(ui.buy_500_btn != null, "Missing buy_500_btn")
|
||||
assert(ui.buy_1000_btn != null, "Missing buy_1000_btn")
|
||||
assert(ui.buy_5000_btn != null, "Missing buy_5000_btn")
|
||||
assert(ui.tx_container != null, "Missing tx_container")
|
||||
assert(ui.prev_page_btn != null, "Missing prev_page_btn")
|
||||
assert(ui.next_page_btn != null, "Missing next_page_btn")
|
||||
assert(ui.page_label != null, "Missing page_label")
|
||||
|
||||
# Test rendering empty transactions
|
||||
ui.render_transactions([])
|
||||
assert(ui.tx_container.get_child_count() == 1, "Should have 1 empty notice child")
|
||||
|
||||
# Test rendering sample transactions
|
||||
var sample_txs = [
|
||||
{
|
||||
"id": 1,
|
||||
"created_at": "2026-09-15T12:00:00Z",
|
||||
"type": "credit_purchase",
|
||||
"amount": 500,
|
||||
"balance_after": 500,
|
||||
"reference_type": "payment",
|
||||
"reference_id": "sess_123"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"created_at": "2026-09-15T12:15:00Z",
|
||||
"type": "marketplace_purchase",
|
||||
"amount": -200,
|
||||
"balance_after": 300,
|
||||
"reference_type": "marketplace_listing",
|
||||
"reference_id": "45"
|
||||
}
|
||||
]
|
||||
ui.render_transactions(sample_txs)
|
||||
assert(ui.tx_container.get_child_count() == 2, "Should have 2 transaction rows")
|
||||
|
||||
# Test paging logic
|
||||
ui.total_tx_count = 45
|
||||
ui.current_page = 0
|
||||
ui._update_paging_controls()
|
||||
assert(ui.prev_page_btn.disabled == true, "Prev should be disabled on page 0")
|
||||
assert(ui.next_page_btn.disabled == false, "Next should be enabled on page 0")
|
||||
assert("Page 1 of 3" in ui.page_label.text, "Page label text mismatch")
|
||||
|
||||
ui.queue_free()
|
||||
print("[SMOKE] WalletUI smoke test PASSED")
|
||||
quit(0)
|
||||
@@ -0,0 +1,222 @@
|
||||
extends Control
|
||||
class_name WalletUI
|
||||
## Wallet & Transaction History UI panel.
|
||||
##
|
||||
## Displays:
|
||||
## - Current Credit balance
|
||||
## - Credit pack quick purchases (100, 500, 1000, 5000 CR)
|
||||
## - Paged ledger transaction history with status/amounts/references
|
||||
##
|
||||
## docs/planning/economy.md US-6 (task 34c725431645).
|
||||
|
||||
signal back_requested
|
||||
|
||||
@onready var back_button: Button = $VBox/HeaderHBox/BackButton
|
||||
@onready var refresh_button: Button = $VBox/HeaderHBox/RefreshButton
|
||||
@onready var status_label: Label = $VBox/StatusLabel
|
||||
|
||||
@onready var balance_label: Label = $VBox/BalanceCard/Margin/HBox/BalanceLabel
|
||||
@onready var buy_100_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy100Btn
|
||||
@onready var buy_500_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy500Btn
|
||||
@onready var buy_1000_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy1000Btn
|
||||
@onready var buy_5000_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy5000Btn
|
||||
|
||||
@onready var tx_container: VBoxContainer = $VBox/LedgerSection/Scroll/TxList
|
||||
@onready var prev_page_btn: Button = $VBox/LedgerSection/PagingHBox/PrevPageBtn
|
||||
@onready var next_page_btn: Button = $VBox/LedgerSection/PagingHBox/NextPageBtn
|
||||
@onready var page_label: Label = $VBox/LedgerSection/PagingHBox/PageLabel
|
||||
|
||||
var _api_client: Node = null
|
||||
var current_page: int = 0
|
||||
const PAGE_SIZE: int = 20
|
||||
var total_tx_count: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_api_client = _get_api()
|
||||
_wire_signals()
|
||||
if _api_client != null and _api_client.has_token():
|
||||
refresh_all()
|
||||
|
||||
|
||||
func _get_api() -> Node:
|
||||
if has_node("/root/ApiClient"):
|
||||
return get_node("/root/ApiClient")
|
||||
return null
|
||||
|
||||
|
||||
func _wire_signals() -> void:
|
||||
if back_button and not back_button.pressed.is_connected(_on_back_pressed):
|
||||
back_button.pressed.connect(_on_back_pressed)
|
||||
if refresh_button and not refresh_button.pressed.is_connected(refresh_all):
|
||||
refresh_button.pressed.connect(refresh_all)
|
||||
|
||||
if buy_100_btn and not buy_100_btn.pressed.is_connected(_buy_pack.bind(100)):
|
||||
buy_100_btn.pressed.connect(_buy_pack.bind(100))
|
||||
if buy_500_btn and not buy_500_btn.pressed.is_connected(_buy_pack.bind(500)):
|
||||
buy_500_btn.pressed.connect(_buy_pack.bind(500))
|
||||
if buy_1000_btn and not buy_1000_btn.pressed.is_connected(_buy_pack.bind(1000)):
|
||||
buy_1000_btn.pressed.connect(_buy_pack.bind(1000))
|
||||
if buy_5000_btn and not buy_5000_btn.pressed.is_connected(_buy_pack.bind(5000)):
|
||||
buy_5000_btn.pressed.connect(_buy_pack.bind(5000))
|
||||
|
||||
if prev_page_btn and not prev_page_btn.pressed.is_connected(_on_prev_page):
|
||||
prev_page_btn.pressed.connect(_on_prev_page)
|
||||
if next_page_btn and not next_page_btn.pressed.is_connected(_on_next_page):
|
||||
next_page_btn.pressed.connect(_on_next_page)
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
back_requested.emit()
|
||||
|
||||
|
||||
func show_status(text: String, is_error: bool = false) -> void:
|
||||
if not status_label:
|
||||
return
|
||||
status_label.text = text
|
||||
if is_error:
|
||||
status_label.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3))
|
||||
else:
|
||||
status_label.add_theme_color_override("font_color", Color(0.3, 0.9, 0.3))
|
||||
|
||||
|
||||
func refresh_all() -> void:
|
||||
refresh_wallet()
|
||||
refresh_transactions()
|
||||
|
||||
|
||||
func refresh_wallet() -> void:
|
||||
var api = _get_api()
|
||||
if not api or not api.has_token():
|
||||
return
|
||||
var res: Dictionary = await EconomyApiClient.get_wallet(api)
|
||||
if res.ok and res.data is Dictionary:
|
||||
var balance: int = int(res.data.get("balance", 0))
|
||||
if balance_label:
|
||||
balance_label.text = "%d CR" % balance
|
||||
else:
|
||||
show_status("Failed to fetch wallet: " + res.detail, true)
|
||||
|
||||
|
||||
func refresh_transactions() -> void:
|
||||
var api = _get_api()
|
||||
if not api or not api.has_token():
|
||||
return
|
||||
var offset: int = current_page * PAGE_SIZE
|
||||
var res: Dictionary = await EconomyApiClient.get_wallet_transactions(api, PAGE_SIZE, offset)
|
||||
if res.ok and res.data is Dictionary:
|
||||
var items: Array = res.data.get("items", [])
|
||||
total_tx_count = int(res.data.get("total", 0))
|
||||
render_transactions(items)
|
||||
_update_paging_controls()
|
||||
else:
|
||||
show_status("Failed to fetch transactions: " + res.detail, true)
|
||||
|
||||
|
||||
func _buy_pack(credits: int) -> void:
|
||||
var api = _get_api()
|
||||
if not api or not api.has_token():
|
||||
show_status("Please login to purchase Credits", true)
|
||||
return
|
||||
show_status("Processing purchase of %d Credits..." % credits)
|
||||
var res: Dictionary = await EconomyApiClient.purchase_credits(api, credits)
|
||||
if res.ok and res.data is Dictionary:
|
||||
var new_bal: int = int(res.data.get("new_balance", 0))
|
||||
if balance_label:
|
||||
balance_label.text = "%d CR" % new_bal
|
||||
show_status("Successfully purchased %d Credits! Balance: %d CR" % [credits, new_bal])
|
||||
refresh_transactions()
|
||||
else:
|
||||
show_status("Purchase failed: " + res.detail, true)
|
||||
|
||||
|
||||
func render_transactions(tx_list: Array) -> void:
|
||||
if not tx_container:
|
||||
return
|
||||
for c in tx_container.get_children():
|
||||
tx_container.remove_child(c)
|
||||
c.queue_free()
|
||||
|
||||
if tx_list.is_empty():
|
||||
var empty_lbl := Label.new()
|
||||
empty_lbl.text = "No transactions found."
|
||||
empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
|
||||
tx_container.add_child(empty_lbl)
|
||||
return
|
||||
|
||||
for tx in tx_list:
|
||||
if not tx is Dictionary:
|
||||
continue
|
||||
var row := PanelContainer.new()
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 12)
|
||||
row.add_child(hbox)
|
||||
|
||||
var date_str: String = str(tx.get("created_at", ""))
|
||||
if date_str.length() > 19:
|
||||
date_str = date_str.substr(0, 19).replace("T", " ")
|
||||
var date_lbl := Label.new()
|
||||
date_lbl.text = date_str
|
||||
date_lbl.custom_minimum_size = Vector2(140, 0)
|
||||
date_lbl.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
|
||||
hbox.add_child(date_lbl)
|
||||
|
||||
var type_lbl := Label.new()
|
||||
type_lbl.text = str(tx.get("type", "")).replace("_", " ").capitalize()
|
||||
type_lbl.custom_minimum_size = Vector2(160, 0)
|
||||
hbox.add_child(type_lbl)
|
||||
|
||||
var amount: int = int(tx.get("amount", 0))
|
||||
var amt_lbl := Label.new()
|
||||
if amount > 0:
|
||||
amt_lbl.text = "+%d CR" % amount
|
||||
amt_lbl.add_theme_color_override("font_color", Color(0.2, 0.9, 0.2))
|
||||
else:
|
||||
amt_lbl.text = "%d CR" % amount
|
||||
amt_lbl.add_theme_color_override("font_color", Color(0.9, 0.3, 0.3))
|
||||
amt_lbl.custom_minimum_size = Vector2(100, 0)
|
||||
hbox.add_child(amt_lbl)
|
||||
|
||||
var bal_lbl := Label.new()
|
||||
bal_lbl.text = "Bal: %s CR" % str(tx.get("balance_after", 0))
|
||||
bal_lbl.custom_minimum_size = Vector2(100, 0)
|
||||
bal_lbl.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8))
|
||||
hbox.add_child(bal_lbl)
|
||||
|
||||
var ref_lbl := Label.new()
|
||||
var ref_type: String = str(tx.get("reference_type", ""))
|
||||
var ref_id: String = str(tx.get("reference_id", ""))
|
||||
if ref_type != "":
|
||||
ref_lbl.text = "%s #%s" % [ref_type, ref_id]
|
||||
else:
|
||||
ref_lbl.text = "-"
|
||||
ref_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
ref_lbl.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5))
|
||||
hbox.add_child(ref_lbl)
|
||||
|
||||
tx_container.add_child(row)
|
||||
|
||||
|
||||
func _update_paging_controls() -> void:
|
||||
var total_pages: int = int(ceil(float(total_tx_count) / float(PAGE_SIZE)))
|
||||
if total_pages < 1:
|
||||
total_pages = 1
|
||||
if page_label:
|
||||
page_label.text = "Page %d of %d" % [current_page + 1, total_pages]
|
||||
if prev_page_btn:
|
||||
prev_page_btn.disabled = current_page <= 0
|
||||
if next_page_btn:
|
||||
next_page_btn.disabled = current_page >= total_pages - 1
|
||||
|
||||
|
||||
func _on_prev_page() -> void:
|
||||
if current_page > 0:
|
||||
current_page -= 1
|
||||
refresh_transactions()
|
||||
|
||||
|
||||
func _on_next_page() -> void:
|
||||
var total_pages: int = int(ceil(float(total_tx_count) / float(PAGE_SIZE)))
|
||||
if current_page < total_pages - 1:
|
||||
current_page += 1
|
||||
refresh_transactions()
|
||||
Reference in New Issue
Block a user