Compare commits

..

4 Commits

Author SHA1 Message Date
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
10 changed files with 890 additions and 4 deletions
@@ -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")
+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",
]
+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")
+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)
+2
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.
+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()
+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
+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)
+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)