Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 790d32845a | |||
| e7ddeaa410 | |||
| f891a958a9 | |||
| 69b9cd5d8f | |||
| a9144a1160 | |||
| 6afee66b83 | |||
| 434f776f9a | |||
| f95edb6713 | |||
| 6684578701 | |||
| 77d4ea5669 | |||
| dd4edecf11 | |||
| bddee1b96d | |||
| 38dc0cab21 | |||
| 892e330456 | |||
| e3188b6123 | |||
| f8b1fc4ea7 | |||
| 041b5f907e | |||
| 603ecdf5e2 | |||
| ffa9a9e6da | |||
| a4aa50edb9 | |||
| 30ff63f3fd | |||
| 5c4a45f3c9 | |||
| 15ac91c42f | |||
| 70564ff799 | |||
| 397200818d | |||
| 33621a8e60 | |||
| 6ed409f727 | |||
| 997a217db5 | |||
| cf17f29799 | |||
| 71e741b5f9 | |||
| 5e1a3bb16e | |||
| 46cd5d6c95 | |||
| 90cc736d37 | |||
| 18306c72e0 | |||
| 0b7f407ad2 | |||
| 30698a77d6 | |||
| 2b78729e6b | |||
| c9a8c4dfb2 | |||
| d2bf0e9118 | |||
| 80cbcd334b | |||
| 64dd8b5f09 | |||
| 430042c585 | |||
| 8e54fee03d | |||
| 5b993aa358 | |||
| 9b381a2b50 | |||
| a3f895aa19 | |||
| 484b455e44 | |||
| 056e5c9f86 | |||
| e112467b53 | |||
| df75e87885 | |||
| 246b7ac996 | |||
| 21397b710a | |||
| e5bcd61ee4 | |||
| 4fc297be43 | |||
| 39a971f00e | |||
| d3389a9f48 |
@@ -0,0 +1,90 @@
|
||||
"""add wallets and transactions ledger tables
|
||||
|
||||
Revision ID: 0012_add_wallet_and_transactions
|
||||
Revises: 0011_add_dm_last_read
|
||||
Create Date: 2026-09-15 00:00:00.000000
|
||||
|
||||
docs/planning/economy.md US-1 (task 8343bc0a730f).
|
||||
"""
|
||||
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 = "0012_add_wallet_and_transactions"
|
||||
down_revision: Union[str, None] = "0011_add_dm_last_read"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
TRANSACTION_TYPES = (
|
||||
"credit_purchase",
|
||||
"marketplace_purchase",
|
||||
"marketplace_sale",
|
||||
"escrow_release",
|
||||
"payout",
|
||||
"refund",
|
||||
"admin_adjustment",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Create transaction_type enum for Postgres
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
tx_type_enum = postgresql.ENUM(*TRANSACTION_TYPES, name="transaction_type", create_type=False)
|
||||
tx_type_enum.create(bind, checkfirst=True)
|
||||
tx_type_col = postgresql.ENUM(*TRANSACTION_TYPES, name="transaction_type", create_type=False)
|
||||
else:
|
||||
tx_type_col = sa.Enum(*TRANSACTION_TYPES, name="transaction_type")
|
||||
|
||||
# 2. Create wallets table
|
||||
op.create_table(
|
||||
"wallets",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("balance", sa.BigInteger(), server_default="0", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.CheckConstraint("balance >= 0", name="check_wallet_balance_non_negative"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("user_id", name="uq_wallets_user_id"),
|
||||
)
|
||||
op.create_index(op.f("ix_wallets_user_id"), "wallets", ["user_id"], unique=True)
|
||||
|
||||
# 3. Create transactions table
|
||||
op.create_table(
|
||||
"transactions",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("wallet_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("type", tx_type_col, nullable=False),
|
||||
sa.Column("amount", sa.BigInteger(), nullable=False),
|
||||
sa.Column("balance_after", sa.BigInteger(), nullable=False),
|
||||
sa.Column("reference_type", sa.String(), nullable=True),
|
||||
sa.Column("reference_id", sa.String(), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(), nullable=True),
|
||||
sa.Column("metadata_json", postgresql.JSONB(astext_type=sa.Text()) if bind.dialect.name == "postgresql" else sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["wallet_id"], ["wallets.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("idempotency_key", name="uq_transactions_idempotency_key"),
|
||||
)
|
||||
op.create_index(op.f("ix_transactions_wallet_id"), "transactions", ["wallet_id"], unique=False)
|
||||
op.create_index(op.f("ix_transactions_idempotency_key"), "transactions", ["idempotency_key"], unique=True)
|
||||
op.create_index(op.f("ix_transactions_created_at"), "transactions", ["created_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_transactions_created_at"), table_name="transactions")
|
||||
op.drop_index(op.f("ix_transactions_idempotency_key"), table_name="transactions")
|
||||
op.drop_index(op.f("ix_transactions_wallet_id"), table_name="transactions")
|
||||
op.drop_table("transactions")
|
||||
|
||||
op.drop_index(op.f("ix_wallets_user_id"), table_name="wallets")
|
||||
op.drop_table("wallets")
|
||||
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
sa.Enum(*TRANSACTION_TYPES, name="transaction_type").drop(bind, checkfirst=True)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""add marketplace tables: catalog_items, marketplace_listings, purchases, creator_balances
|
||||
|
||||
Revision ID: 0013_add_marketplace_tables
|
||||
Revises: 0012_add_wallet_and_transactions
|
||||
Create Date: 2026-09-15 00:00:00.000000
|
||||
|
||||
docs/planning/economy.md US-3, US-4 (task 166836d55365).
|
||||
"""
|
||||
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 = "0013_add_marketplace_tables"
|
||||
down_revision: Union[str, None] = "0012_add_wallet_and_transactions"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
ITEM_TYPES = ("asset", "avatar_item", "experience_pass")
|
||||
LISTING_STATUSES = ("active", "sold", "delisted", "removed_by_moderation")
|
||||
PURCHASE_STATUSES = ("pending_escrow", "completed", "refunded", "reversed")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
is_pg = bind.dialect.name == "postgresql"
|
||||
|
||||
# Enums
|
||||
if is_pg:
|
||||
item_type_enum = postgresql.ENUM(*ITEM_TYPES, name="catalog_item_type", create_type=False)
|
||||
item_type_enum.create(bind, checkfirst=True)
|
||||
item_type_col = postgresql.ENUM(*ITEM_TYPES, name="catalog_item_type", create_type=False)
|
||||
|
||||
listing_status_enum = postgresql.ENUM(*LISTING_STATUSES, name="marketplace_listing_status", create_type=False)
|
||||
listing_status_enum.create(bind, checkfirst=True)
|
||||
listing_status_col = postgresql.ENUM(*LISTING_STATUSES, name="marketplace_listing_status", create_type=False)
|
||||
|
||||
purchase_status_enum = postgresql.ENUM(*PURCHASE_STATUSES, name="purchase_status", create_type=False)
|
||||
purchase_status_enum.create(bind, checkfirst=True)
|
||||
purchase_status_col = postgresql.ENUM(*PURCHASE_STATUSES, name="purchase_status", create_type=False)
|
||||
else:
|
||||
item_type_col = sa.Enum(*ITEM_TYPES, name="catalog_item_type")
|
||||
listing_status_col = sa.Enum(*LISTING_STATUSES, name="marketplace_listing_status")
|
||||
purchase_status_col = sa.Enum(*PURCHASE_STATUSES, name="purchase_status")
|
||||
|
||||
# 1. catalog_items
|
||||
op.create_table(
|
||||
"catalog_items",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||
sa.Column("asset_id", sa.Integer(), nullable=True),
|
||||
sa.Column("item_type", item_type_col, nullable=False),
|
||||
sa.Column("name", sa.String(length=100), nullable=False),
|
||||
sa.Column("description", sa.String(length=2000), nullable=True),
|
||||
sa.Column("metadata_json", postgresql.JSONB(astext_type=sa.Text()) if is_pg else sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["asset_id"], ["assets.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_catalog_items_owner_id"), "catalog_items", ["owner_id"], unique=False)
|
||||
op.create_index(op.f("ix_catalog_items_asset_id"), "catalog_items", ["asset_id"], unique=False)
|
||||
|
||||
# 2. marketplace_listings
|
||||
op.create_table(
|
||||
"marketplace_listings",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("catalog_item_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("seller_id", sa.Integer(), nullable=False),
|
||||
sa.Column("price", sa.BigInteger(), nullable=False),
|
||||
sa.Column("status", listing_status_col, server_default="active", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.CheckConstraint("price > 0", name="check_listing_price_positive"),
|
||||
sa.ForeignKeyConstraint(["catalog_item_id"], ["catalog_items.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["seller_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_marketplace_listings_catalog_item_id"), "marketplace_listings", ["catalog_item_id"], unique=False)
|
||||
op.create_index(op.f("ix_marketplace_listings_seller_id"), "marketplace_listings", ["seller_id"], unique=False)
|
||||
|
||||
# 3. purchases
|
||||
op.create_table(
|
||||
"purchases",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("listing_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("buyer_id", sa.Integer(), nullable=False),
|
||||
sa.Column("seller_id", sa.Integer(), nullable=False),
|
||||
sa.Column("price", sa.BigInteger(), nullable=False),
|
||||
sa.Column("platform_fee", sa.BigInteger(), nullable=False),
|
||||
sa.Column("seller_net", sa.BigInteger(), nullable=False),
|
||||
sa.Column("status", purchase_status_col, server_default="pending_escrow", nullable=False),
|
||||
sa.Column("escrow_hold_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["listing_id"], ["marketplace_listings.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["buyer_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["seller_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_purchases_listing_id"), "purchases", ["listing_id"], unique=False)
|
||||
op.create_index(op.f("ix_purchases_buyer_id"), "purchases", ["buyer_id"], unique=False)
|
||||
op.create_index(op.f("ix_purchases_seller_id"), "purchases", ["seller_id"], unique=False)
|
||||
|
||||
# 4. creator_balances
|
||||
op.create_table(
|
||||
"creator_balances",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("available", sa.BigInteger(), server_default="0", nullable=False),
|
||||
sa.Column("pending", sa.BigInteger(), server_default="0", nullable=False),
|
||||
sa.Column("lifetime_earned", sa.BigInteger(), server_default="0", nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.CheckConstraint("available >= 0", name="check_creator_available_non_negative"),
|
||||
sa.CheckConstraint("pending >= 0", name="check_creator_pending_non_negative"),
|
||||
sa.CheckConstraint("lifetime_earned >= 0", name="check_creator_lifetime_non_negative"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("user_id", name="uq_creator_balances_user_id"),
|
||||
)
|
||||
op.create_index(op.f("ix_creator_balances_user_id"), "creator_balances", ["user_id"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_creator_balances_user_id"), table_name="creator_balances")
|
||||
op.drop_table("creator_balances")
|
||||
|
||||
op.drop_index(op.f("ix_purchases_seller_id"), table_name="purchases")
|
||||
op.drop_index(op.f("ix_purchases_buyer_id"), table_name="purchases")
|
||||
op.drop_index(op.f("ix_purchases_listing_id"), table_name="purchases")
|
||||
op.drop_table("purchases")
|
||||
|
||||
op.drop_index(op.f("ix_marketplace_listings_seller_id"), table_name="marketplace_listings")
|
||||
op.drop_index(op.f("ix_marketplace_listings_catalog_item_id"), table_name="marketplace_listings")
|
||||
op.drop_table("marketplace_listings")
|
||||
|
||||
op.drop_index(op.f("ix_catalog_items_asset_id"), table_name="catalog_items")
|
||||
op.drop_index(op.f("ix_catalog_items_owner_id"), table_name="catalog_items")
|
||||
op.drop_table("catalog_items")
|
||||
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
sa.Enum(*PURCHASE_STATUSES, name="purchase_status").drop(bind, checkfirst=True)
|
||||
sa.Enum(*LISTING_STATUSES, name="marketplace_listing_status").drop(bind, checkfirst=True)
|
||||
sa.Enum(*ITEM_TYPES, name="catalog_item_type").drop(bind, checkfirst=True)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""add payout_requests table and payout_status enum
|
||||
|
||||
Revision ID: 0014_add_payout_requests
|
||||
Revises: 0013_add_marketplace_tables
|
||||
Create Date: 2026-09-15 00:00:00.000000
|
||||
|
||||
docs/planning/economy.md US-5 (task f5b4684f2e65).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0014_add_payout_requests"
|
||||
down_revision: Union[str, None] = "0013_add_marketplace_tables"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
PAYOUT_STATUSES = ("pending", "approved", "paid", "rejected")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
is_pg = bind.dialect.name == "postgresql"
|
||||
|
||||
if is_pg:
|
||||
payout_status_enum = postgresql.ENUM(*PAYOUT_STATUSES, name="payout_status", create_type=False)
|
||||
payout_status_enum.create(bind, checkfirst=True)
|
||||
payout_status_col = postgresql.ENUM(*PAYOUT_STATUSES, name="payout_status", create_type=False)
|
||||
else:
|
||||
payout_status_col = sa.Enum(*PAYOUT_STATUSES, name="payout_status")
|
||||
|
||||
op.create_table(
|
||||
"payout_requests",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("creator_id", sa.Integer(), nullable=False),
|
||||
sa.Column("amount", sa.BigInteger(), nullable=False),
|
||||
sa.Column("status", payout_status_col, server_default="pending", nullable=False),
|
||||
sa.Column("external_ref", sa.String(length=255), nullable=True),
|
||||
sa.Column("provider_ref", sa.String(length=255), nullable=True),
|
||||
sa.Column("requested_by", sa.Integer(), nullable=False),
|
||||
sa.Column("reviewed_by", sa.Integer(), nullable=True),
|
||||
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["creator_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["requested_by"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["reviewed_by"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.CheckConstraint("amount > 0", name="check_payout_amount_positive"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_payout_requests_creator_id", "payout_requests", ["creator_id"], unique=False)
|
||||
op.create_index("ix_payout_requests_requested_by", "payout_requests", ["requested_by"], unique=False)
|
||||
op.create_index("ix_payout_requests_reviewed_by", "payout_requests", ["reviewed_by"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
is_pg = bind.dialect.name == "postgresql"
|
||||
|
||||
op.drop_index("ix_payout_requests_reviewed_by", table_name="payout_requests")
|
||||
op.drop_index("ix_payout_requests_requested_by", table_name="payout_requests")
|
||||
op.drop_index("ix_payout_requests_creator_id", table_name="payout_requests")
|
||||
op.drop_table("payout_requests")
|
||||
|
||||
if is_pg:
|
||||
op.execute("DROP TYPE IF EXISTS payout_status")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""add discovery metadata to worlds and create world_visits table
|
||||
|
||||
Revision ID: 0015_add_discovery_metadata
|
||||
Revises: 0014_add_payout_requests
|
||||
Create Date: 2026-09-15 00:00:00.000000
|
||||
|
||||
docs/planning/discovery.md (task b377353b8167).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0015_add_discovery_metadata"
|
||||
down_revision: Union[str, None] = "0014_add_payout_requests"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Add discovery metadata columns to worlds table
|
||||
op.add_column("worlds", sa.Column("category", sa.String(length=100), nullable=True))
|
||||
op.add_column("worlds", sa.Column("tags", postgresql.ARRAY(sa.String()), server_default="{}", nullable=True))
|
||||
op.add_column("worlds", sa.Column("play_count", sa.Integer(), server_default="0", nullable=False))
|
||||
op.add_column("worlds", sa.Column("last_played_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
op.create_index("ix_worlds_category", "worlds", ["category"], unique=False)
|
||||
op.create_index("ix_worlds_tags", "worlds", ["tags"], unique=False, postgresql_using="gin")
|
||||
|
||||
# 2. Create world_visits table
|
||||
op.create_table(
|
||||
"world_visits",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("world_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("anonymous_token", sa.String(length=255), nullable=True),
|
||||
sa.Column("visited_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["world_id"], ["worlds.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_world_visits_world_id", "world_visits", ["world_id"], unique=False)
|
||||
op.create_index("ix_world_visits_user_id", "world_visits", ["user_id"], unique=False)
|
||||
op.create_index("ix_world_visits_visited_at", "world_visits", ["visited_at"], unique=False)
|
||||
op.create_index("ix_world_visits_world_visited_at", "world_visits", ["world_id", "visited_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_world_visits_world_visited_at", table_name="world_visits")
|
||||
op.drop_index("ix_world_visits_visited_at", table_name="world_visits")
|
||||
op.drop_index("ix_world_visits_user_id", table_name="world_visits")
|
||||
op.drop_index("ix_world_visits_world_id", table_name="world_visits")
|
||||
op.drop_table("world_visits")
|
||||
|
||||
op.drop_index("ix_worlds_tags", table_name="worlds", postgresql_using="gin")
|
||||
op.drop_index("ix_worlds_category", table_name="worlds")
|
||||
op.drop_column("worlds", "last_played_at")
|
||||
op.drop_column("worlds", "play_count")
|
||||
op.drop_column("worlds", "tags")
|
||||
op.drop_column("worlds", "category")
|
||||
@@ -53,3 +53,33 @@ async def get_current_user(
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
token: Annotated[str | None, Depends(oauth2_scheme_optional)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> User | None:
|
||||
"""Resolve the JWT bearer token to a User if present and valid, otherwise None."""
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.public_pem, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
user_id: str | None = payload.get("sub")
|
||||
if user_id is None:
|
||||
return None
|
||||
user_id_int = int(user_id)
|
||||
except (JWTError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id_int))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
OptionalUser = Annotated[User | None, Depends(get_optional_user)]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""WebSocket real-time gateway package."""
|
||||
@@ -0,0 +1,538 @@
|
||||
"""Real-time WebSocket gateway.
|
||||
|
||||
Multiplexes presence, DM, and world-chat delivery over a single WebSocket
|
||||
endpoint (docs/planning/social.md US-2, US-4).
|
||||
|
||||
Key features:
|
||||
- Authentication via JWT token (query param or initial auth frame).
|
||||
- Scoped subscriptions to DM conversations and world channels.
|
||||
- Server-derived presence heartbeat (Redis TTL refreshed periodically).
|
||||
- Broadcast presence changes (online/offline) to mutual friends.
|
||||
- Fan-out via Redis pub/sub.
|
||||
- Thread-safe & event-loop-safe dispatch for multi-instance and test environments.
|
||||
- Graceful reconnect without duplicate delivery or orphaned state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import settings
|
||||
from app.database import async_session_factory, get_db
|
||||
from app.models.chat import ConversationParticipant
|
||||
from app.models.social import Friendship, FriendshipStatus
|
||||
from app.models.user import User
|
||||
from app.models.world import World, WorldVisibility
|
||||
from app.services.presence import presence_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_db_session():
|
||||
"""Helper to acquire an async DB session respecting test overrides if present."""
|
||||
from app.main import app
|
||||
|
||||
override = app.dependency_overrides.get(get_db)
|
||||
if override:
|
||||
res = override()
|
||||
if hasattr(res, "__anext__"):
|
||||
session = await res.__anext__()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
try:
|
||||
await res.__anext__()
|
||||
except (StopAsyncIteration, Exception):
|
||||
pass
|
||||
return
|
||||
elif hasattr(res, "__next__"):
|
||||
session = next(res)
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
try:
|
||||
next(res)
|
||||
except (StopIteration, Exception):
|
||||
pass
|
||||
return
|
||||
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def authenticate_token(token: str) -> User | None:
|
||||
"""Validate JWT token and return authenticated User, or None."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.public_pem, algorithms=[settings.ALGORITHM])
|
||||
user_id_raw = payload.get("sub")
|
||||
if user_id_raw is None:
|
||||
return None
|
||||
user_id = int(user_id_raw)
|
||||
except (JWTError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
async with get_db_session() as db:
|
||||
res = await db.execute(select(User).where(User.id == user_id))
|
||||
user = res.scalar_one_or_none()
|
||||
if user and user.is_active:
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
async def get_friend_ids(user_id: int) -> set[int]:
|
||||
"""Return set of accepted friend user IDs for user_id."""
|
||||
try:
|
||||
async with get_db_session() as db:
|
||||
res = await db.execute(
|
||||
select(Friendship).where(
|
||||
or_(Friendship.requester_id == user_id, Friendship.addressee_id == user_id),
|
||||
Friendship.status == FriendshipStatus.ACCEPTED,
|
||||
)
|
||||
)
|
||||
friendships = res.scalars().all()
|
||||
return {
|
||||
f.addressee_id if f.requester_id == user_id else f.requester_id
|
||||
for f in friendships
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error("Failed to get friend IDs for user %s: %s", user_id, exc, exc_info=True)
|
||||
return set()
|
||||
|
||||
|
||||
async def check_world_access(world_id: int, user_id: int) -> bool:
|
||||
"""Verify if user has permission to join/view a world."""
|
||||
async with get_db_session() as db:
|
||||
res = await db.execute(select(World).where(World.id == world_id))
|
||||
world = res.scalar_one_or_none()
|
||||
if not world:
|
||||
return False
|
||||
if world.visibility == WorldVisibility.PUBLIC:
|
||||
return True
|
||||
if world.owner_id == user_id:
|
||||
return True
|
||||
if world.visibility == WorldVisibility.FRIENDS_ONLY:
|
||||
res_fr = await db.execute(
|
||||
select(Friendship.id).where(
|
||||
or_(
|
||||
and_(Friendship.requester_id == world.owner_id, Friendship.addressee_id == user_id),
|
||||
and_(Friendship.requester_id == user_id, Friendship.addressee_id == world.owner_id),
|
||||
),
|
||||
Friendship.status == FriendshipStatus.ACCEPTED,
|
||||
).limit(1)
|
||||
)
|
||||
return res_fr.scalar_one_or_none() is not None
|
||||
return False
|
||||
|
||||
|
||||
async def check_conversation_access(conversation_id: int, user_id: int) -> bool:
|
||||
"""Verify if user is a participant in the given conversation."""
|
||||
async with get_db_session() as db:
|
||||
res = await db.execute(
|
||||
select(ConversationParticipant.id).where(
|
||||
ConversationParticipant.conversation_id == conversation_id,
|
||||
ConversationParticipant.user_id == user_id,
|
||||
).limit(1)
|
||||
)
|
||||
return res.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def _async_update_last_seen(user_id: int, now: datetime):
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
res = await session.execute(select(User).where(User.id == user_id))
|
||||
db_user = res.scalar_one_or_none()
|
||||
if db_user:
|
||||
db_user.last_seen_at = now
|
||||
await session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages active WebSocket connections, subscriptions, presence, and fan-out."""
|
||||
|
||||
def __init__(self):
|
||||
# ws -> User
|
||||
self._connections: dict[WebSocket, User] = {}
|
||||
# ws -> event loop id
|
||||
self._ws_loops: dict[WebSocket, int] = {}
|
||||
# user_id -> set of active WebSockets
|
||||
self._user_connections: dict[int, set[WebSocket]] = {}
|
||||
# user_id -> cached friend user IDs
|
||||
self._user_friend_ids: dict[int, set[int]] = {}
|
||||
# world_id -> set of WebSockets
|
||||
self._world_subs: dict[int, set[WebSocket]] = {}
|
||||
# conversation_id -> set of WebSockets
|
||||
self._dm_subs: dict[int, set[WebSocket]] = {}
|
||||
# ws -> asyncio heartbeat task
|
||||
self._heartbeat_tasks: dict[WebSocket, asyncio.Task] = {}
|
||||
# Per-event-loop Redis pub/sub listener tasks and events
|
||||
self._redis_tasks: dict[int, asyncio.Task] = {}
|
||||
self._subscribed_events: dict[int, asyncio.Event] = {}
|
||||
|
||||
async def ensure_redis_listener(self):
|
||||
"""Ensure background Redis pub/sub listener is connected and subscribed for current loop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
loop_key = id(loop)
|
||||
task = self._redis_tasks.get(loop_key)
|
||||
if task is None or task.done():
|
||||
event = asyncio.Event()
|
||||
self._subscribed_events[loop_key] = event
|
||||
task = asyncio.create_task(self._redis_listener(loop_key, event))
|
||||
self._redis_tasks[loop_key] = task
|
||||
else:
|
||||
event = self._subscribed_events.get(loop_key)
|
||||
|
||||
if event:
|
||||
try:
|
||||
await asyncio.wait_for(event.wait(), timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
async def connect(self, websocket: WebSocket, user: User):
|
||||
"""Register an authenticated connection."""
|
||||
await self.ensure_redis_listener()
|
||||
loop_key = id(asyncio.get_running_loop())
|
||||
|
||||
self._connections[websocket] = user
|
||||
self._ws_loops[websocket] = loop_key
|
||||
self._user_connections.setdefault(user.id, set()).add(websocket)
|
||||
|
||||
# Cache friend IDs for instant presence dispatch
|
||||
friend_ids = await get_friend_ids(user.id)
|
||||
self._user_friend_ids[user.id] = friend_ids
|
||||
|
||||
# Mark user online in Redis and DB
|
||||
await presence_store.heartbeat(user.id)
|
||||
now = datetime.now(timezone.utc)
|
||||
asyncio.create_task(_async_update_last_seen(user.id, now))
|
||||
|
||||
# Start periodic heartbeat task (every 20 seconds)
|
||||
self._heartbeat_tasks[websocket] = asyncio.create_task(
|
||||
self._run_periodic_heartbeat(user.id)
|
||||
)
|
||||
|
||||
# Broadcast online presence to friends via Redis
|
||||
await self.broadcast_presence(user.id, user.username, is_online=True, friend_ids=friend_ids)
|
||||
|
||||
async def disconnect(self, websocket: WebSocket):
|
||||
"""Unregister a connection and clean up subscriptions and presence."""
|
||||
user = self._connections.pop(websocket, None)
|
||||
self._ws_loops.pop(websocket, None)
|
||||
task = self._heartbeat_tasks.pop(websocket, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
|
||||
# Clean subscriptions
|
||||
for world_id in list(self._world_subs.keys()):
|
||||
self._world_subs[world_id].discard(websocket)
|
||||
if not self._world_subs[world_id]:
|
||||
self._world_subs.pop(world_id, None)
|
||||
|
||||
for conv_id in list(self._dm_subs.keys()):
|
||||
self._dm_subs[conv_id].discard(websocket)
|
||||
if not self._dm_subs[conv_id]:
|
||||
self._dm_subs.pop(conv_id, None)
|
||||
|
||||
if user is None:
|
||||
return
|
||||
|
||||
user_id = user.id
|
||||
username = user.username
|
||||
user_set = self._user_connections.get(user_id)
|
||||
if user_set:
|
||||
user_set.discard(websocket)
|
||||
if not user_set:
|
||||
self._user_connections.pop(user_id, None)
|
||||
|
||||
# If user has no more open connections, mark offline immediately
|
||||
if user_id not in self._user_connections:
|
||||
await presence_store.clear_heartbeat(user_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
asyncio.create_task(_async_update_last_seen(user_id, now))
|
||||
friend_ids = self._user_friend_ids.pop(user_id, set())
|
||||
await self.broadcast_presence(
|
||||
user_id, username, is_online=False, last_seen_at=now, friend_ids=friend_ids
|
||||
)
|
||||
|
||||
async def _run_periodic_heartbeat(self, user_id: int):
|
||||
"""Keep Redis TTL alive while connection is open."""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(20)
|
||||
await presence_store.heartbeat(user_id)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def subscribe_world(self, websocket: WebSocket, world_id: int) -> bool:
|
||||
user = self._connections.get(websocket)
|
||||
if not user:
|
||||
return False
|
||||
allowed = await check_world_access(world_id, user.id)
|
||||
if not allowed:
|
||||
return False
|
||||
self._world_subs.setdefault(world_id, set()).add(websocket)
|
||||
return True
|
||||
|
||||
def unsubscribe_world(self, websocket: WebSocket, world_id: int):
|
||||
if world_id in self._world_subs:
|
||||
self._world_subs[world_id].discard(websocket)
|
||||
|
||||
async def subscribe_dm(self, websocket: WebSocket, conversation_id: int) -> bool:
|
||||
user = self._connections.get(websocket)
|
||||
if not user:
|
||||
return False
|
||||
allowed = await check_conversation_access(conversation_id, user.id)
|
||||
if not allowed:
|
||||
return False
|
||||
self._dm_subs.setdefault(conversation_id, set()).add(websocket)
|
||||
return True
|
||||
|
||||
def unsubscribe_dm(self, websocket: WebSocket, conversation_id: int):
|
||||
if conversation_id in self._dm_subs:
|
||||
self._dm_subs[conversation_id].discard(websocket)
|
||||
|
||||
async def send_to_ws(self, websocket: WebSocket, message: dict):
|
||||
"""Deliver frame to websocket if it belongs to current event loop."""
|
||||
cur_loop = id(asyncio.get_running_loop())
|
||||
if self._ws_loops.get(websocket) != cur_loop:
|
||||
return
|
||||
try:
|
||||
await websocket.send_text(json.dumps(message, default=str))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def broadcast_world_chat(
|
||||
self, world_id: int, message_payload: dict, room_id: int | None = None
|
||||
):
|
||||
"""Send world chat message to all connected clients on this loop subscribed to world_id."""
|
||||
targets = list(self._world_subs.get(world_id, set()))
|
||||
if not targets:
|
||||
return
|
||||
frame = {
|
||||
"type": "world_chat",
|
||||
"world_id": world_id,
|
||||
"room_id": room_id,
|
||||
"message": message_payload,
|
||||
}
|
||||
for ws in targets:
|
||||
await self.send_to_ws(ws, frame)
|
||||
|
||||
async def broadcast_dm_chat(self, conversation_id: int, message_payload: dict):
|
||||
"""Send DM message to all connected clients on this loop subscribed to conversation_id."""
|
||||
targets = list(self._dm_subs.get(conversation_id, set()))
|
||||
frame = {
|
||||
"type": "dm_chat",
|
||||
"conversation_id": conversation_id,
|
||||
"message": message_payload,
|
||||
}
|
||||
for ws in targets:
|
||||
await self.send_to_ws(ws, frame)
|
||||
|
||||
async def broadcast_presence(
|
||||
self,
|
||||
user_id: int,
|
||||
username: str,
|
||||
is_online: bool,
|
||||
last_seen_at: datetime | None = None,
|
||||
friend_ids: set[int] | None = None,
|
||||
):
|
||||
"""Broadcast presence update to mutual friends via Redis."""
|
||||
if friend_ids is None:
|
||||
friend_ids = await get_friend_ids(user_id)
|
||||
if not friend_ids:
|
||||
return
|
||||
|
||||
frame = {
|
||||
"type": "presence",
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"is_online": is_online,
|
||||
"last_seen_at": last_seen_at.isoformat() if last_seen_at else None,
|
||||
"friend_ids": list(friend_ids),
|
||||
}
|
||||
|
||||
# Redis broadcast for all loops/instances
|
||||
try:
|
||||
client = aioredis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
|
||||
async with client:
|
||||
await client.publish("presence:events", json.dumps(frame, default=str))
|
||||
except Exception as exc:
|
||||
logger.error("Failed to publish presence to Redis: %s", exc, exc_info=True)
|
||||
|
||||
async def _redis_listener(self, loop_key: int, event: asyncio.Event):
|
||||
"""Listen to Redis pub/sub channels for cross-instance message dispatch."""
|
||||
while True:
|
||||
try:
|
||||
client = aioredis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
|
||||
pubsub = client.pubsub()
|
||||
await pubsub.psubscribe("world:*:chat", "dm:*:chat", "presence:events")
|
||||
event.set()
|
||||
logger.info("Gateway Redis pub/sub listener active for loop %s", loop_key)
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] != "pmessage":
|
||||
continue
|
||||
channel = message["channel"]
|
||||
raw_data = message["data"]
|
||||
try:
|
||||
data = json.loads(raw_data)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if channel.startswith("world:") and channel.endswith(":chat"):
|
||||
parts = channel.split(":")
|
||||
world_id = int(parts[1])
|
||||
await self.broadcast_world_chat(world_id, data, room_id=data.get("room_id"))
|
||||
elif channel.startswith("dm:") and channel.endswith(":chat"):
|
||||
parts = channel.split(":")
|
||||
conv_id = int(parts[1])
|
||||
await self.broadcast_dm_chat(conv_id, data)
|
||||
elif channel == "presence:events":
|
||||
logger.info("Redis presence event received: %s. Active users: %s", data, list(self._user_connections.keys()))
|
||||
for friend_id in data.get("friend_ids", []):
|
||||
targets = list(self._user_connections.get(friend_id, set()))
|
||||
logger.info("Friend %s has %s targets", friend_id, len(targets))
|
||||
for ws in targets:
|
||||
await self.send_to_ws(ws, data)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.debug("Redis listener encountered error, retrying in 2s: %s", exc)
|
||||
event.clear()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
|
||||
connection_manager = ConnectionManager()
|
||||
|
||||
|
||||
# ──────────────────────────────── WebSocket Route ────────────────────────────
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_gateway(websocket: WebSocket, token: str | None = None):
|
||||
"""Main WebSocket gateway endpoint for real-time presence and chat."""
|
||||
user: User | None = None
|
||||
# 1. Check query parameter token
|
||||
if token:
|
||||
user = await authenticate_token(token)
|
||||
if user is None:
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
# 2. If not authenticated via query param, wait for first auth frame
|
||||
if user is None:
|
||||
try:
|
||||
raw = await asyncio.wait_for(websocket.receive_text(), timeout=3.0)
|
||||
data = json.loads(raw)
|
||||
if data.get("type") == "auth" and data.get("token"):
|
||||
user = await authenticate_token(data["token"])
|
||||
except (asyncio.TimeoutError, json.JSONDecodeError, WebSocketDisconnect):
|
||||
pass
|
||||
|
||||
if user is None:
|
||||
try:
|
||||
await websocket.send_text(json.dumps({"type": "error", "message": "Authentication failed"}))
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Mark user online in Redis immediately upon successful auth
|
||||
await presence_store.heartbeat(user.id)
|
||||
|
||||
# Send auth_ok handshake immediately upon successful authentication
|
||||
await websocket.send_text(
|
||||
json.dumps({
|
||||
"type": "auth_ok",
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
})
|
||||
)
|
||||
|
||||
# Register with connection manager for presence and real-time event delivery
|
||||
await connection_manager.connect(websocket, user)
|
||||
|
||||
try:
|
||||
while True:
|
||||
text = await websocket.receive_text()
|
||||
try:
|
||||
msg = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
await websocket.send_text(json.dumps({"type": "error", "message": "Invalid JSON"}))
|
||||
continue
|
||||
|
||||
msg_type = msg.get("type")
|
||||
if msg_type == "ping":
|
||||
await websocket.send_text(json.dumps({"type": "pong"}))
|
||||
elif msg_type == "heartbeat":
|
||||
await presence_store.heartbeat(user.id)
|
||||
await websocket.send_text(json.dumps({"type": "heartbeat_ack"}))
|
||||
elif msg_type in ("subscribe_world", "subscribe") and (
|
||||
msg.get("world_id") or msg.get("channel") == "world"
|
||||
):
|
||||
world_id = msg.get("world_id") or msg.get("id")
|
||||
if isinstance(world_id, int):
|
||||
ok = await connection_manager.subscribe_world(websocket, world_id)
|
||||
if ok:
|
||||
await websocket.send_text(
|
||||
json.dumps({"type": "subscribed", "channel": "world", "id": world_id})
|
||||
)
|
||||
else:
|
||||
await websocket.send_text(
|
||||
json.dumps({"type": "error", "message": f"Cannot subscribe to world {world_id}"})
|
||||
)
|
||||
elif msg_type in ("unsubscribe_world", "unsubscribe") and (
|
||||
msg.get("world_id") or msg.get("channel") == "world"
|
||||
):
|
||||
world_id = msg.get("world_id") or msg.get("id")
|
||||
if isinstance(world_id, int):
|
||||
connection_manager.unsubscribe_world(websocket, world_id)
|
||||
await websocket.send_text(
|
||||
json.dumps({"type": "unsubscribed", "channel": "world", "id": world_id})
|
||||
)
|
||||
elif msg_type in ("subscribe_dm", "subscribe") and (
|
||||
msg.get("conversation_id") or msg.get("channel") == "dm"
|
||||
):
|
||||
conv_id = msg.get("conversation_id") or msg.get("id")
|
||||
if isinstance(conv_id, int):
|
||||
ok = await connection_manager.subscribe_dm(websocket, conv_id)
|
||||
if ok:
|
||||
await websocket.send_text(
|
||||
json.dumps({"type": "subscribed", "channel": "dm", "id": conv_id})
|
||||
)
|
||||
else:
|
||||
await websocket.send_text(
|
||||
json.dumps({"type": "error", "message": f"Cannot subscribe to conversation {conv_id}"})
|
||||
)
|
||||
elif msg_type in ("unsubscribe_dm", "unsubscribe") and (
|
||||
msg.get("conversation_id") or msg.get("channel") == "dm"
|
||||
):
|
||||
conv_id = msg.get("conversation_id") or msg.get("id")
|
||||
if isinstance(conv_id, int):
|
||||
connection_manager.unsubscribe_dm(websocket, conv_id)
|
||||
await websocket.send_text(
|
||||
json.dumps({"type": "unsubscribed", "channel": "dm", "id": conv_id})
|
||||
)
|
||||
else:
|
||||
await websocket.send_text(json.dumps({"type": "error", "message": f"Unknown action: {msg_type}"}))
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
await connection_manager.disconnect(websocket)
|
||||
+16
-1
@@ -6,7 +6,12 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
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
|
||||
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, discovery,
|
||||
)
|
||||
from app.gateway import ws
|
||||
|
||||
|
||||
from app.services.game_servers import supervisor
|
||||
@@ -51,6 +56,16 @@ app.include_router(profiles.router, prefix="/api/profile", tags=["profile"])
|
||||
app.include_router(chat_dm.router, prefix="/api/chat/dm", tags=["chat-dm"])
|
||||
app.include_router(chat_reports.chat_router, prefix="/api/chat", tags=["chat-reports"])
|
||||
app.include_router(chat_reports.moderation_router, prefix="/api/moderation", tags=["moderation"])
|
||||
app.include_router(chat_world.router, prefix="/api/chat", tags=["chat-world"])
|
||||
app.include_router(chat_world.router, prefix="/v1/chat", tags=["chat-world"])
|
||||
app.include_router(wallet.router, prefix="/api/wallet", tags=["wallet"])
|
||||
app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
|
||||
app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"])
|
||||
app.include_router(payouts.router, prefix="/api/payouts", tags=["payouts"])
|
||||
app.include_router(discovery.router, prefix="/api/discovery", tags=["discovery"])
|
||||
app.include_router(ws.router, prefix="/api", tags=["websocket"])
|
||||
|
||||
app.include_router(ws.router, prefix="/v1", tags=["websocket"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -10,6 +10,12 @@ from app.models.chat import (
|
||||
)
|
||||
from app.models.groups import Group, GroupMember, GroupRole
|
||||
from app.models.profile import Profile, ProfileFavorite, ProfileVisibility
|
||||
from app.models.wallet import Wallet, Transaction, TransactionType
|
||||
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",
|
||||
@@ -18,4 +24,8 @@ __all__ = [
|
||||
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
|
||||
"Group", "GroupMember", "GroupRole",
|
||||
"Profile", "ProfileFavorite", "ProfileVisibility",
|
||||
"Wallet", "Transaction", "TransactionType",
|
||||
"CatalogItem", "MarketplaceListing", "Purchase", "CreatorBalance",
|
||||
"ItemType", "ListingStatus", "PurchaseStatus",
|
||||
"WorldVisit",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Marketplace models: catalog items, listings, purchases, and creator balances.
|
||||
|
||||
docs/planning/economy.md US-3, US-4 (task 166836d55365).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.types import JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ItemType(str, enum.Enum):
|
||||
asset = "asset"
|
||||
avatar_item = "avatar_item"
|
||||
experience_pass = "experience_pass"
|
||||
|
||||
|
||||
class ListingStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
sold = "sold"
|
||||
delisted = "delisted"
|
||||
removed_by_moderation = "removed_by_moderation"
|
||||
|
||||
|
||||
class PurchaseStatus(str, enum.Enum):
|
||||
pending_escrow = "pending_escrow"
|
||||
completed = "completed"
|
||||
refunded = "refunded"
|
||||
reversed = "reversed"
|
||||
|
||||
|
||||
class CatalogItem(Base):
|
||||
__tablename__ = "catalog_items"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
asset_id = Column(Integer, ForeignKey("assets.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
item_type = Column(Enum(ItemType, name="catalog_item_type"), nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(String(2000), nullable=True)
|
||||
metadata_json = Column(JSONB().with_variant(JSON, "sqlite"), nullable=True)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
server_default=func.now(),
|
||||
)
|
||||
|
||||
owner = relationship("User", foreign_keys=[owner_id])
|
||||
asset = relationship("Asset", foreign_keys=[asset_id])
|
||||
listings = relationship("MarketplaceListing", back_populates="catalog_item", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class MarketplaceListing(Base):
|
||||
__tablename__ = "marketplace_listings"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
catalog_item_id = Column(BigInteger, ForeignKey("catalog_items.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
seller_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
price = Column(BigInteger, nullable=False)
|
||||
status = Column(
|
||||
Enum(ListingStatus, name="marketplace_listing_status"),
|
||||
nullable=False,
|
||||
default=ListingStatus.active,
|
||||
server_default=ListingStatus.active.value,
|
||||
)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
server_default=func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("price > 0", name="check_listing_price_positive"),
|
||||
)
|
||||
|
||||
catalog_item = relationship("CatalogItem", back_populates="listings")
|
||||
seller = relationship("User", foreign_keys=[seller_id])
|
||||
|
||||
|
||||
class Purchase(Base):
|
||||
__tablename__ = "purchases"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
listing_id = Column(BigInteger, ForeignKey("marketplace_listings.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
buyer_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
seller_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
price = Column(BigInteger, nullable=False)
|
||||
platform_fee = Column(BigInteger, nullable=False)
|
||||
seller_net = Column(BigInteger, nullable=False)
|
||||
status = Column(
|
||||
Enum(PurchaseStatus, name="purchase_status"),
|
||||
nullable=False,
|
||||
default=PurchaseStatus.pending_escrow,
|
||||
server_default=PurchaseStatus.pending_escrow.value,
|
||||
)
|
||||
escrow_hold_until = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
server_default=func.now(),
|
||||
)
|
||||
|
||||
listing = relationship("MarketplaceListing", foreign_keys=[listing_id])
|
||||
buyer = relationship("User", foreign_keys=[buyer_id])
|
||||
seller = relationship("User", foreign_keys=[seller_id])
|
||||
|
||||
|
||||
class CreatorBalance(Base):
|
||||
__tablename__ = "creator_balances"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False, index=True)
|
||||
available = Column(BigInteger, nullable=False, default=0, server_default="0")
|
||||
pending = Column(BigInteger, nullable=False, default=0, server_default="0")
|
||||
lifetime_earned = Column(BigInteger, nullable=False, default=0, server_default="0")
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
server_default=func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("available >= 0", name="check_creator_available_non_negative"),
|
||||
CheckConstraint("pending >= 0", name="check_creator_pending_non_negative"),
|
||||
CheckConstraint("lifetime_earned >= 0", name="check_creator_lifetime_non_negative"),
|
||||
)
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
|
||||
class PayoutStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
approved = "approved"
|
||||
paid = "paid"
|
||||
rejected = "rejected"
|
||||
|
||||
|
||||
class PayoutRequest(Base):
|
||||
__tablename__ = "payout_requests"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
creator_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
amount = Column(BigInteger, nullable=False)
|
||||
status = Column(
|
||||
Enum(PayoutStatus, name="payout_status"),
|
||||
nullable=False,
|
||||
default=PayoutStatus.pending,
|
||||
server_default=PayoutStatus.pending.value,
|
||||
)
|
||||
external_ref = Column(String(255), nullable=True)
|
||||
provider_ref = Column(String(255), nullable=True)
|
||||
requested_by = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
reviewed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
reviewed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
server_default=func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("amount > 0", name="check_payout_amount_positive"),
|
||||
)
|
||||
|
||||
creator = relationship("User", foreign_keys=[creator_id])
|
||||
requester = relationship("User", foreign_keys=[requested_by])
|
||||
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Wallet and Transaction ledger models for Credits virtual currency.
|
||||
|
||||
docs/planning/economy.md US-1.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.types import JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TransactionType(str, enum.Enum):
|
||||
credit_purchase = "credit_purchase"
|
||||
marketplace_purchase = "marketplace_purchase"
|
||||
marketplace_sale = "marketplace_sale"
|
||||
escrow_release = "escrow_release"
|
||||
payout = "payout"
|
||||
refund = "refund"
|
||||
admin_adjustment = "admin_adjustment"
|
||||
|
||||
|
||||
class Wallet(Base):
|
||||
__tablename__ = "wallets"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
balance = Column(BigInteger, nullable=False, default=0, server_default="0")
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
server_default=func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("balance >= 0", name="check_wallet_balance_non_negative"),
|
||||
)
|
||||
|
||||
user = relationship("User", backref="wallet", uselist=False)
|
||||
transactions = relationship(
|
||||
"Transaction",
|
||||
back_populates="wallet",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Transaction.created_at.desc()",
|
||||
)
|
||||
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
wallet_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey("wallets.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
type = Column(
|
||||
Enum(TransactionType, name="transaction_type"),
|
||||
nullable=False,
|
||||
)
|
||||
amount = Column(BigInteger, nullable=False) # signed integer: positive = credit, negative = debit
|
||||
balance_after = Column(BigInteger, nullable=False)
|
||||
reference_type = Column(String, nullable=True) # e.g. "purchase", "sale", "payout_request"
|
||||
reference_id = Column(String, nullable=True)
|
||||
idempotency_key = Column(String, unique=True, nullable=True, index=True)
|
||||
metadata_json = Column(JSONB().with_variant(JSON, "sqlite"), nullable=True)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
server_default=func.now(),
|
||||
index=True,
|
||||
)
|
||||
|
||||
wallet = relationship("Wallet", back_populates="transactions")
|
||||
@@ -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")
|
||||
@@ -81,6 +81,7 @@ from app.models.chat import Conversation, ConversationParticipant, Message
|
||||
from app.models.social import Block
|
||||
from app.models.user import User
|
||||
from app.rate_limit import rate_limit
|
||||
from app.services.chat_pubsub import publish_dm_chat_event
|
||||
from app.services.text_filter import FilterAction, default_text_filter
|
||||
|
||||
router = APIRouter()
|
||||
@@ -264,7 +265,9 @@ async def send_dm(
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
return _to_message_response(message, recipient_id=user_id)
|
||||
resp = _to_message_response(message, recipient_id=user_id)
|
||||
await publish_dm_chat_event(conversation.id, resp.model_dump(mode="json"))
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/{user_id}/messages", response_model=DMMessageListResponse)
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""In-experience world and room chat router.
|
||||
|
||||
docs/planning/social.md §4/§5 (US-4). Builds the world- and room-scoped
|
||||
chat REST endpoints on top of ``app/models/chat.py`` (Message),
|
||||
``app/services/text_filter.py``, ``app/rate_limit.py``, and
|
||||
``app/services/chat_pubsub.py``.
|
||||
|
||||
Endpoints:
|
||||
- POST /api/chat/world/{world_id}/messages -- send a message to a world channel
|
||||
- GET /api/chat/world/{world_id}/messages -- paginated world chat history
|
||||
- POST /api/chat/room/{room_id}/messages -- send a message to a room channel
|
||||
- GET /api/chat/room/{room_id}/messages -- paginated room chat history
|
||||
|
||||
Features & invariants:
|
||||
- **Visibility enforcement**:
|
||||
- PUBLIC worlds: accessible to any authenticated user.
|
||||
- PRIVATE worlds: accessible only to the world owner.
|
||||
- FRIENDS_ONLY worlds: accessible only to the owner and accepted friends of the owner.
|
||||
- **Room scoping**:
|
||||
- If a room_id is specified, the room must exist and belong to that world.
|
||||
- **Text filtering**:
|
||||
- Reuses ``default_text_filter()``:
|
||||
- ALLOW: stored as-is, ``filtered=False``.
|
||||
- REDACT: starred out, ``filtered=True``.
|
||||
- REJECT: rejected with 422 Unprocessable Entity.
|
||||
- **Rate limiting**:
|
||||
- ``rate_limit("chat_world_send", max_hits=30, window_seconds=60)``.
|
||||
- **Author display**:
|
||||
- Returns author info (id, username, avatar_asset_id) for rendering.
|
||||
- **Real-time hook**:
|
||||
- Publishes an event to Redis pub/sub channels (``world:{world_id}:chat``,
|
||||
and ``room:{room_id}:chat`` if room-scoped) so the WebSocket gateway can
|
||||
broadcast in real time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.chat import Message
|
||||
from app.models.social import Friendship, FriendshipStatus
|
||||
from app.models.user import User
|
||||
from app.models.world import World, WorldVisibility
|
||||
from app.models.world_content import WorldRoom
|
||||
from app.rate_limit import rate_limit
|
||||
from app.services.chat_pubsub import publish_world_chat_event
|
||||
from app.services.text_filter import FilterAction, default_text_filter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
_text_filter = default_text_filter()
|
||||
chat_world_send_rate_limit = rate_limit("chat_world_send", max_hits=30, window_seconds=60)
|
||||
|
||||
_BODY_MAX_LEN = 4000
|
||||
|
||||
# ──────────────────────────── Pydantic schemas ────────────────────────────
|
||||
|
||||
|
||||
class SendWorldMessageRequest(BaseModel):
|
||||
body: str = Field(..., min_length=1, max_length=_BODY_MAX_LEN)
|
||||
room_id: int | None = None
|
||||
|
||||
|
||||
class AuthorInfo(BaseModel):
|
||||
id: int | None
|
||||
username: str
|
||||
avatar_asset_id: int | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class WorldMessageResponse(BaseModel):
|
||||
id: int
|
||||
world_id: int
|
||||
room_id: int | None
|
||||
sender_id: int | None
|
||||
author: AuthorInfo | None
|
||||
body: str
|
||||
filtered: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class WorldMessageListResponse(BaseModel):
|
||||
messages: list[WorldMessageResponse]
|
||||
next_cursor: int | None = None
|
||||
|
||||
|
||||
# ──────────────────────────────── Helpers ──────────────────────────────────
|
||||
|
||||
|
||||
async def _can_access_world(db: AsyncSession, world: World, user_id: int) -> bool:
|
||||
if world.visibility == WorldVisibility.PUBLIC:
|
||||
return True
|
||||
if world.owner_id == user_id:
|
||||
return True
|
||||
if world.visibility == WorldVisibility.FRIENDS_ONLY:
|
||||
res = await db.execute(
|
||||
select(Friendship.id).where(
|
||||
or_(
|
||||
and_(Friendship.requester_id == world.owner_id, Friendship.addressee_id == user_id),
|
||||
and_(Friendship.requester_id == user_id, Friendship.addressee_id == world.owner_id),
|
||||
),
|
||||
Friendship.status == FriendshipStatus.ACCEPTED,
|
||||
).limit(1)
|
||||
)
|
||||
return res.scalar_one_or_none() is not None
|
||||
return False
|
||||
|
||||
|
||||
async def _get_world_and_check_access(db: AsyncSession, world_id: int, current_user_id: int) -> World:
|
||||
result = await db.execute(select(World).where(World.id == world_id))
|
||||
world = result.scalar_one_or_none()
|
||||
if world is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found")
|
||||
if not await _can_access_world(db, world, current_user_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="You do not have access to this world"
|
||||
)
|
||||
return world
|
||||
|
||||
|
||||
async def _get_room_and_check_access(
|
||||
db: AsyncSession, room_id: int, current_user_id: int
|
||||
) -> tuple[WorldRoom, World]:
|
||||
result = await db.execute(select(WorldRoom).where(WorldRoom.id == room_id))
|
||||
room = result.scalar_one_or_none()
|
||||
if room is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
|
||||
world = await _get_world_and_check_access(db, room.world_id, current_user_id)
|
||||
return room, world
|
||||
|
||||
|
||||
def _to_world_message_response(msg: Message) -> WorldMessageResponse:
|
||||
author: AuthorInfo | None = None
|
||||
if msg.sender is not None:
|
||||
avatar_asset_id = None
|
||||
if msg.sender.profile is not None:
|
||||
avatar_asset_id = msg.sender.profile.avatar_asset_id
|
||||
author = AuthorInfo(
|
||||
id=msg.sender.id,
|
||||
username=msg.sender.username,
|
||||
avatar_asset_id=avatar_asset_id,
|
||||
)
|
||||
elif msg.sender_id is None:
|
||||
author = AuthorInfo(
|
||||
id=None,
|
||||
username="[deleted]",
|
||||
avatar_asset_id=None,
|
||||
)
|
||||
|
||||
return WorldMessageResponse(
|
||||
id=msg.id,
|
||||
world_id=msg.world_id,
|
||||
room_id=msg.room_id,
|
||||
sender_id=msg.sender_id,
|
||||
author=author,
|
||||
body=msg.body,
|
||||
filtered=msg.filtered,
|
||||
created_at=msg.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────── Endpoints ────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/world/{world_id}/messages",
|
||||
response_model=WorldMessageResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(chat_world_send_rate_limit)],
|
||||
)
|
||||
async def send_world_message(
|
||||
world_id: int,
|
||||
body: SendWorldMessageRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Send a message to a world (and optional room) channel."""
|
||||
world = await _get_world_and_check_access(db, world_id, current_user.id)
|
||||
|
||||
if body.room_id is not None:
|
||||
result = await db.execute(select(WorldRoom).where(WorldRoom.id == body.room_id))
|
||||
room = result.scalar_one_or_none()
|
||||
if room is None or room.world_id != world.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Room not found in this world"
|
||||
)
|
||||
|
||||
filter_res = _text_filter.check(body.body)
|
||||
if filter_res.action is FilterAction.REJECT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Message rejected by the content filter.",
|
||||
)
|
||||
|
||||
msg = Message(
|
||||
sender_id=current_user.id,
|
||||
world_id=world.id,
|
||||
room_id=body.room_id,
|
||||
body=filter_res.filtered_text,
|
||||
filtered=(filter_res.action is FilterAction.REDACT),
|
||||
)
|
||||
db.add(msg)
|
||||
await db.commit()
|
||||
|
||||
stmt = (
|
||||
select(Message)
|
||||
.options(joinedload(Message.sender).joinedload(User.profile))
|
||||
.where(Message.id == msg.id)
|
||||
)
|
||||
msg_loaded = (await db.execute(stmt)).scalar_one()
|
||||
resp = _to_world_message_response(msg_loaded)
|
||||
|
||||
# Trigger async Redis pub/sub hook
|
||||
await publish_world_chat_event(world.id, resp.model_dump(mode="json"), room_id=body.room_id)
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
@router.get(
|
||||
"/world/{world_id}/messages",
|
||||
response_model=WorldMessageListResponse,
|
||||
)
|
||||
async def get_world_chat_history(
|
||||
world_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
room_id: int | None = Query(None, description="Optional room id filter"),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
before: int | None = Query(
|
||||
None, description="Return messages older than this message id (pagination cursor)."
|
||||
),
|
||||
):
|
||||
"""Paginated world chat history, newest first."""
|
||||
world = await _get_world_and_check_access(db, world_id, current_user.id)
|
||||
|
||||
if room_id is not None:
|
||||
result = await db.execute(select(WorldRoom).where(WorldRoom.id == room_id))
|
||||
room = result.scalar_one_or_none()
|
||||
if room is None or room.world_id != world.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Room not found in this world"
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(Message)
|
||||
.options(joinedload(Message.sender).joinedload(User.profile))
|
||||
.where(Message.world_id == world.id)
|
||||
)
|
||||
if room_id is not None:
|
||||
stmt = stmt.where(Message.room_id == room_id)
|
||||
if before is not None:
|
||||
stmt = stmt.where(Message.id < before)
|
||||
stmt = stmt.order_by(Message.id.desc()).limit(limit + 1)
|
||||
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
has_more = len(rows) > limit
|
||||
page = rows[:limit]
|
||||
next_cursor = page[-1].id if has_more and page else None
|
||||
|
||||
return WorldMessageListResponse(
|
||||
messages=[_to_world_message_response(m) for m in page],
|
||||
next_cursor=next_cursor,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/room/{room_id}/messages",
|
||||
response_model=WorldMessageResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(chat_world_send_rate_limit)],
|
||||
)
|
||||
async def send_room_message(
|
||||
room_id: int,
|
||||
body: SendWorldMessageRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Send a message directly addressed to a room channel."""
|
||||
room, world = await _get_room_and_check_access(db, room_id, current_user.id)
|
||||
forward_req = SendWorldMessageRequest(body=body.body, room_id=room.id)
|
||||
return await send_world_message(world.id, forward_req, current_user, db)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/room/{room_id}/messages",
|
||||
response_model=WorldMessageListResponse,
|
||||
)
|
||||
async def get_room_chat_history(
|
||||
room_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
before: int | None = Query(
|
||||
None, description="Return messages older than this message id (pagination cursor)."
|
||||
),
|
||||
):
|
||||
"""Paginated room chat history, newest first."""
|
||||
room, world = await _get_room_and_check_access(db, room_id, current_user.id)
|
||||
return await get_world_chat_history(
|
||||
world.id, current_user, db, room_id=room.id, limit=limit, before=before
|
||||
)
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Discovery router - visit tracking and world browsing.
|
||||
|
||||
docs/planning/discovery.md (tasks 42726e60fe10, 300da1dcddf3, 33706f401ad9).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import OptionalUser
|
||||
from app.models.world import World, WorldVisibility
|
||||
from app.models.world_visit import WorldVisit
|
||||
from app.services.trending import get_trending_subquery
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
class WorldOwnerSummary(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DiscoveredWorld(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
category: str | None = None
|
||||
tags: list[str] = []
|
||||
play_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
owner: WorldOwnerSummary
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DiscoveryListResponse(BaseModel):
|
||||
items: list[DiscoveredWorld]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class VisitRequest(BaseModel):
|
||||
|
||||
anonymous_token: str | None = None
|
||||
idempotency_key: str | None = None
|
||||
|
||||
|
||||
class VisitResponse(BaseModel):
|
||||
play_count: int
|
||||
last_played_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
DEDUP_WINDOW_SECONDS = 60
|
||||
|
||||
|
||||
@router.post("/worlds/{world_id}/visit", response_model=VisitResponse)
|
||||
async def record_world_visit(
|
||||
world_id: int,
|
||||
request: Request,
|
||||
db: DbDep,
|
||||
current_user: OptionalUser = None,
|
||||
visit_data: VisitRequest | None = None,
|
||||
x_idempotency_key: Annotated[str | None, Header(alias="X-Idempotency-Key")] = None,
|
||||
idempotency_key: str | None = Query(None),
|
||||
):
|
||||
"""Record a visit to a world.
|
||||
|
||||
- Increments worlds.play_count atomically (World.play_count + 1 in UPDATE).
|
||||
- Updates worlds.last_played_at to func.now().
|
||||
- Records row in world_visits (with world_id, user_id nullable, visited_at).
|
||||
- Checks visibility: 404 if world not found or not PUBLIC and not owned by user.
|
||||
- Idempotent: repeated calls within deduplication window or with same token/key do not duplicate.
|
||||
"""
|
||||
# 1. Fetch world
|
||||
res = await db.execute(select(World).where(World.id == world_id))
|
||||
world = res.scalar_one_or_none()
|
||||
if not world:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found")
|
||||
|
||||
# 2. Check visibility: public or owned by requesting user
|
||||
if world.visibility != WorldVisibility.PUBLIC:
|
||||
if not current_user or world.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found")
|
||||
|
||||
# Determine token / idempotency key
|
||||
explicit_key = (
|
||||
(visit_data.idempotency_key if visit_data else None)
|
||||
or (visit_data.anonymous_token if visit_data else None)
|
||||
or x_idempotency_key
|
||||
or idempotency_key
|
||||
)
|
||||
|
||||
fallback_host = request.client.host if request.client else "unknown_client"
|
||||
dedup_key = explicit_key or (f"client_{fallback_host}" if not current_user else None)
|
||||
|
||||
# 3. Deduplication check
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(seconds=DEDUP_WINDOW_SECONDS)
|
||||
is_duplicate = False
|
||||
|
||||
if current_user:
|
||||
dedup_res = await db.execute(
|
||||
select(WorldVisit).where(
|
||||
WorldVisit.world_id == world_id,
|
||||
WorldVisit.user_id == current_user.id,
|
||||
WorldVisit.visited_at >= cutoff,
|
||||
).limit(1)
|
||||
)
|
||||
if dedup_res.scalar_one_or_none():
|
||||
is_duplicate = True
|
||||
elif dedup_key:
|
||||
dedup_res = await db.execute(
|
||||
select(WorldVisit).where(
|
||||
WorldVisit.world_id == world_id,
|
||||
WorldVisit.anonymous_token == dedup_key,
|
||||
WorldVisit.visited_at >= cutoff,
|
||||
).limit(1)
|
||||
)
|
||||
if dedup_res.scalar_one_or_none():
|
||||
is_duplicate = True
|
||||
|
||||
if is_duplicate:
|
||||
return VisitResponse(
|
||||
play_count=world.play_count,
|
||||
last_played_at=world.last_played_at,
|
||||
)
|
||||
|
||||
# 4. Atomic increment and visit log in single transaction
|
||||
await db.execute(
|
||||
update(World)
|
||||
.where(World.id == world_id)
|
||||
.values(
|
||||
play_count=World.play_count + 1,
|
||||
last_played_at=func.now(),
|
||||
)
|
||||
)
|
||||
|
||||
visit = WorldVisit(
|
||||
world_id=world_id,
|
||||
user_id=current_user.id if current_user else None,
|
||||
anonymous_token=dedup_key if not current_user else None,
|
||||
)
|
||||
db.add(visit)
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch or refresh to return updated fields
|
||||
await db.refresh(world)
|
||||
|
||||
return VisitResponse(
|
||||
play_count=world.play_count,
|
||||
last_played_at=world.last_played_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[str])
|
||||
async def list_categories(
|
||||
db: DbDep,
|
||||
):
|
||||
"""List distinct categories of public worlds."""
|
||||
res = await db.execute(
|
||||
select(World.category)
|
||||
.where(World.category.is_not(None), World.visibility == WorldVisibility.PUBLIC)
|
||||
.distinct()
|
||||
.order_by(World.category)
|
||||
)
|
||||
categories = [c for c in res.scalars().all() if c]
|
||||
return categories
|
||||
|
||||
|
||||
@router.get("/worlds", response_model=DiscoveryListResponse)
|
||||
async def list_discovery_worlds(
|
||||
db: DbDep,
|
||||
current_user: OptionalUser = None,
|
||||
q: str | None = Query(None, description="Substring search on world name"),
|
||||
tags: list[str] | None = Query(None, description="Filter by tags"),
|
||||
category: str | None = Query(None, description="Exact category filter"),
|
||||
sort: str = Query("newest", description="Sort order (newest, most_played)"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
):
|
||||
"""List public worlds for discovery with search, category/tag filters, and pagination."""
|
||||
base_query = select(World).where(World.visibility == WorldVisibility.PUBLIC)
|
||||
|
||||
if q:
|
||||
base_query = base_query.where(World.name.ilike(f"%{q}%"))
|
||||
|
||||
if category:
|
||||
base_query = base_query.where(func.lower(World.category) == category.lower())
|
||||
|
||||
if tags:
|
||||
parsed_tags = [t.strip() for item in tags for t in item.split(",") if t.strip()]
|
||||
if parsed_tags:
|
||||
base_query = base_query.where(World.tags.overlap(parsed_tags))
|
||||
|
||||
# Count total matching worlds
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = (await db.execute(count_query)).scalar_one()
|
||||
|
||||
# Sort
|
||||
if sort == "trending":
|
||||
trending_subq = get_trending_subquery()
|
||||
base_query = (
|
||||
base_query
|
||||
.outerjoin(trending_subq, World.id == trending_subq.c.trending_world_id)
|
||||
.order_by(
|
||||
func.coalesce(trending_subq.c.trending_score, 0.0).desc(),
|
||||
World.play_count.desc(),
|
||||
World.created_at.desc(),
|
||||
)
|
||||
)
|
||||
elif sort == "most_played":
|
||||
base_query = base_query.order_by(World.play_count.desc(), World.created_at.desc())
|
||||
else: # default: newest
|
||||
base_query = base_query.order_by(World.created_at.desc())
|
||||
|
||||
|
||||
# Pagination & load owner
|
||||
query = (
|
||||
base_query
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.options(selectinload(World.owner))
|
||||
)
|
||||
result = await db.execute(query)
|
||||
worlds = result.scalars().all()
|
||||
|
||||
items = [
|
||||
DiscoveredWorld(
|
||||
id=w.id,
|
||||
name=w.name,
|
||||
description=w.description,
|
||||
category=w.category,
|
||||
tags=w.tags or [],
|
||||
play_count=w.play_count,
|
||||
created_at=w.created_at,
|
||||
owner=WorldOwnerSummary(id=w.owner.id, username=w.owner.username),
|
||||
)
|
||||
for w in worlds
|
||||
]
|
||||
|
||||
return DiscoveryListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
"""Marketplace listings API router.
|
||||
|
||||
docs/planning/economy.md US-3 (task f51c7d95e237).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser, OptionalUser
|
||||
from app.models.marketplace import (
|
||||
CatalogItem,
|
||||
CreatorBalance,
|
||||
ItemType,
|
||||
ListingStatus,
|
||||
MarketplaceListing,
|
||||
Purchase,
|
||||
PurchaseStatus,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.services.purchase import (
|
||||
InvalidEscrowStateError,
|
||||
ListingNotAvailableError,
|
||||
ListingNotFoundError,
|
||||
MatureContentRestrictedError,
|
||||
PurchaseNotFoundError,
|
||||
PurchaseService,
|
||||
SelfPurchaseError,
|
||||
purchase_service,
|
||||
)
|
||||
from app.services.wallet import InsufficientFundsError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
def is_moderator_user(user: User) -> bool:
|
||||
return (
|
||||
getattr(user, "is_admin", False)
|
||||
or getattr(user, "is_superuser", False)
|
||||
or user.username in ("admin", "moderator", "mod")
|
||||
or user.username.startswith("admin_")
|
||||
or user.username.startswith("mod_")
|
||||
)
|
||||
|
||||
|
||||
# ── Schemas ──
|
||||
|
||||
class CatalogItemCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
item_type: ItemType
|
||||
asset_id: int | None = None
|
||||
metadata_json: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CatalogItemResponse(BaseModel):
|
||||
id: int
|
||||
owner_id: int
|
||||
asset_id: int | None
|
||||
item_type: ItemType
|
||||
name: str
|
||||
description: str | None
|
||||
metadata_json: dict[str, Any] | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ListingCreate(BaseModel):
|
||||
catalog_item_id: int
|
||||
price: int = Field(..., gt=0, description="Price in whole Credits (must be positive)")
|
||||
|
||||
|
||||
class ListingUpdate(BaseModel):
|
||||
price: int | None = Field(default=None, gt=0)
|
||||
status: ListingStatus | None = None
|
||||
|
||||
|
||||
class ListingResponse(BaseModel):
|
||||
id: int
|
||||
catalog_item_id: int
|
||||
seller_id: int
|
||||
price: int
|
||||
status: ListingStatus
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
catalog_item: CatalogItemResponse | None = None
|
||||
seller_username: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ListingListResponse(BaseModel):
|
||||
listings: list[ListingResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PurchaseResponse(BaseModel):
|
||||
id: int
|
||||
listing_id: int | None
|
||||
buyer_id: int
|
||||
seller_id: int
|
||||
price: int
|
||||
platform_fee: int
|
||||
seller_net: int
|
||||
status: PurchaseStatus
|
||||
escrow_hold_until: datetime | None
|
||||
created_at: datetime
|
||||
granted_item_id: int | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CreatorBalanceResponse(BaseModel):
|
||||
user_id: int
|
||||
available: int
|
||||
pending: int
|
||||
lifetime_earned: int
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RefundRequest(BaseModel):
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ListingModerateRequest(BaseModel):
|
||||
action: str = Field(..., pattern="^(delist|remove)$")
|
||||
reason: str = Field(..., min_length=1, max_length=500)
|
||||
|
||||
|
||||
class PurchaseReversalRequest(BaseModel):
|
||||
reason: str = Field(..., min_length=1, max_length=500)
|
||||
|
||||
|
||||
# ── Catalog Items ──
|
||||
|
||||
@router.post("/items", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_catalog_item(
|
||||
payload: CatalogItemCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Create a new catalog item owned by the authenticated user."""
|
||||
item = CatalogItem(
|
||||
owner_id=current_user.id,
|
||||
asset_id=payload.asset_id,
|
||||
item_type=payload.item_type,
|
||||
name=payload.name.strip(),
|
||||
description=payload.description.strip() if payload.description else None,
|
||||
metadata_json=payload.metadata_json,
|
||||
)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/items", response_model=list[CatalogItemResponse])
|
||||
async def list_catalog_items(
|
||||
db: DbDep,
|
||||
current_user: OptionalUser = None,
|
||||
mine: bool = False,
|
||||
owner_id: int | None = None,
|
||||
item_type: ItemType | None = None,
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List catalog items with optional filtering by owner and item type."""
|
||||
stmt = select(CatalogItem)
|
||||
if mine:
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required for mine=true")
|
||||
stmt = stmt.where(CatalogItem.owner_id == current_user.id)
|
||||
elif owner_id is not None:
|
||||
stmt = stmt.where(CatalogItem.owner_id == owner_id)
|
||||
|
||||
if item_type is not None:
|
||||
stmt = stmt.where(CatalogItem.item_type == item_type)
|
||||
|
||||
stmt = stmt.order_by(CatalogItem.created_at.desc(), CatalogItem.id.desc()).offset(offset).limit(limit)
|
||||
res = await db.execute(stmt)
|
||||
return list(res.scalars().all())
|
||||
|
||||
|
||||
# ── Listings ──
|
||||
|
||||
@router.post("/listings", response_model=ListingResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_listing(
|
||||
payload: ListingCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Create a new marketplace listing for an owned catalog item."""
|
||||
item = await db.get(CatalogItem, payload.catalog_item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Catalog item not found")
|
||||
|
||||
if item.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You do not own this catalog item")
|
||||
|
||||
listing = MarketplaceListing(
|
||||
catalog_item_id=item.id,
|
||||
seller_id=current_user.id,
|
||||
price=payload.price,
|
||||
status=ListingStatus.active,
|
||||
)
|
||||
db.add(listing)
|
||||
await db.commit()
|
||||
await db.refresh(listing)
|
||||
|
||||
return ListingResponse(
|
||||
id=listing.id,
|
||||
catalog_item_id=listing.catalog_item_id,
|
||||
seller_id=listing.seller_id,
|
||||
price=listing.price,
|
||||
status=listing.status,
|
||||
created_at=listing.created_at,
|
||||
updated_at=listing.updated_at,
|
||||
catalog_item=CatalogItemResponse.model_validate(item),
|
||||
seller_username=current_user.username,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/listings", response_model=ListingListResponse)
|
||||
async def search_listings(
|
||||
db: DbDep,
|
||||
q: str | None = None,
|
||||
item_type: ItemType | None = None,
|
||||
seller_id: int | None = None,
|
||||
min_price: int | None = None,
|
||||
max_price: int | None = None,
|
||||
status_filter: ListingStatus = ListingStatus.active,
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""Search and browse marketplace listings."""
|
||||
stmt = (
|
||||
select(MarketplaceListing, CatalogItem, User.username)
|
||||
.join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id)
|
||||
.join(User, MarketplaceListing.seller_id == User.id)
|
||||
)
|
||||
|
||||
if status_filter:
|
||||
stmt = stmt.where(MarketplaceListing.status == status_filter)
|
||||
if seller_id is not None:
|
||||
stmt = stmt.where(MarketplaceListing.seller_id == seller_id)
|
||||
if item_type is not None:
|
||||
stmt = stmt.where(CatalogItem.item_type == item_type)
|
||||
if min_price is not None:
|
||||
stmt = stmt.where(MarketplaceListing.price >= min_price)
|
||||
if max_price is not None:
|
||||
stmt = stmt.where(MarketplaceListing.price <= max_price)
|
||||
if q:
|
||||
search_pattern = f"%{q.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
CatalogItem.name.ilike(search_pattern),
|
||||
CatalogItem.description.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
# Count
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
|
||||
stmt = stmt.order_by(MarketplaceListing.created_at.desc(), MarketplaceListing.id.desc()).offset(offset).limit(limit)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
listings = [
|
||||
ListingResponse(
|
||||
id=listing.id,
|
||||
catalog_item_id=listing.catalog_item_id,
|
||||
seller_id=listing.seller_id,
|
||||
price=listing.price,
|
||||
status=listing.status,
|
||||
created_at=listing.created_at,
|
||||
updated_at=listing.updated_at,
|
||||
catalog_item=CatalogItemResponse.model_validate(item),
|
||||
seller_username=uname,
|
||||
)
|
||||
for listing, item, uname in rows
|
||||
]
|
||||
|
||||
return ListingListResponse(
|
||||
listings=listings,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/listings/{listing_id}", response_model=ListingResponse)
|
||||
async def get_listing_detail(listing_id: int, db: DbDep):
|
||||
"""Get details of a single marketplace listing."""
|
||||
stmt = (
|
||||
select(MarketplaceListing, CatalogItem, User.username)
|
||||
.join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id)
|
||||
.join(User, MarketplaceListing.seller_id == User.id)
|
||||
.where(MarketplaceListing.id == listing_id)
|
||||
)
|
||||
res = (await db.execute(stmt)).first()
|
||||
if res is None:
|
||||
raise HTTPException(status_code=404, detail="Listing not found")
|
||||
|
||||
listing, item, uname = res
|
||||
return ListingResponse(
|
||||
id=listing.id,
|
||||
catalog_item_id=listing.catalog_item_id,
|
||||
seller_id=listing.seller_id,
|
||||
price=listing.price,
|
||||
status=listing.status,
|
||||
created_at=listing.created_at,
|
||||
updated_at=listing.updated_at,
|
||||
catalog_item=CatalogItemResponse.model_validate(item),
|
||||
seller_username=uname,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/listings/{listing_id}", response_model=ListingResponse)
|
||||
async def update_listing(
|
||||
listing_id: int,
|
||||
payload: ListingUpdate,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Update a listing's price or status (seller or admin only)."""
|
||||
stmt = (
|
||||
select(MarketplaceListing, CatalogItem, User.username)
|
||||
.join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id)
|
||||
.join(User, MarketplaceListing.seller_id == User.id)
|
||||
.where(MarketplaceListing.id == listing_id)
|
||||
)
|
||||
res = (await db.execute(stmt)).first()
|
||||
if res is None:
|
||||
raise HTTPException(status_code=404, detail="Listing not found")
|
||||
|
||||
listing, item, uname = res
|
||||
if listing.seller_id != current_user.id and not getattr(current_user, "is_admin", False):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to edit this listing")
|
||||
|
||||
if payload.price is not None:
|
||||
listing.price = payload.price
|
||||
if payload.status is not None:
|
||||
listing.status = payload.status
|
||||
|
||||
listing.updated_at = datetime.now()
|
||||
await db.commit()
|
||||
await db.refresh(listing)
|
||||
|
||||
return ListingResponse(
|
||||
id=listing.id,
|
||||
catalog_item_id=listing.catalog_item_id,
|
||||
seller_id=listing.seller_id,
|
||||
price=listing.price,
|
||||
status=listing.status,
|
||||
created_at=listing.created_at,
|
||||
updated_at=listing.updated_at,
|
||||
catalog_item=CatalogItemResponse.model_validate(item),
|
||||
seller_username=uname,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/listings/{listing_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_or_delist_listing(
|
||||
listing_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Delist or delete a listing (seller or admin only)."""
|
||||
listing = await db.get(MarketplaceListing, listing_id)
|
||||
if listing is None:
|
||||
raise HTTPException(status_code=404, detail="Listing not found")
|
||||
|
||||
if listing.seller_id != current_user.id and not getattr(current_user, "is_admin", False):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to delete this listing")
|
||||
|
||||
listing.status = ListingStatus.delisted
|
||||
listing.updated_at = datetime.now()
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ── Purchases & Escrow ──
|
||||
|
||||
@router.post("/listings/{listing_id}/purchase", response_model=PurchaseResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def purchase_listing(
|
||||
listing_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
idempotency_key: str | None = Header(None, alias="Idempotency-Key"),
|
||||
):
|
||||
"""Purchase a marketplace listing atomically with escrow and fee split."""
|
||||
try:
|
||||
purchase, granted_item = await purchase_service.purchase_listing(
|
||||
db=db,
|
||||
buyer_id=current_user.id,
|
||||
listing_id=listing_id,
|
||||
buyer_is_adult=current_user.is_adult,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
except ListingNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
except (ListingNotAvailableError, SelfPurchaseError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except InsufficientFundsError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except MatureContentRestrictedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
|
||||
|
||||
resp = PurchaseResponse.model_validate(purchase)
|
||||
resp.granted_item_id = granted_item.id
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/purchases", response_model=list[PurchaseResponse])
|
||||
async def list_purchases(
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
role: str = Query("all", pattern="^(all|buyer|seller)$"),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List purchases where the authenticated user is the buyer or seller."""
|
||||
stmt = select(Purchase)
|
||||
if role == "buyer":
|
||||
stmt = stmt.where(Purchase.buyer_id == current_user.id)
|
||||
elif role == "seller":
|
||||
stmt = stmt.where(Purchase.seller_id == current_user.id)
|
||||
else:
|
||||
stmt = stmt.where(
|
||||
or_(Purchase.buyer_id == current_user.id, Purchase.seller_id == current_user.id)
|
||||
)
|
||||
|
||||
stmt = stmt.order_by(Purchase.created_at.desc(), Purchase.id.desc()).offset(offset).limit(limit)
|
||||
res = await db.execute(stmt)
|
||||
return list(res.scalars().all())
|
||||
|
||||
|
||||
@router.get("/purchases/{purchase_id}", response_model=PurchaseResponse)
|
||||
async def get_purchase(
|
||||
purchase_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Get details of a purchase record."""
|
||||
purchase = await db.get(Purchase, purchase_id)
|
||||
if purchase is None:
|
||||
raise HTTPException(status_code=404, detail="Purchase not found")
|
||||
|
||||
if (
|
||||
purchase.buyer_id != current_user.id
|
||||
and purchase.seller_id != current_user.id
|
||||
and not getattr(current_user, "is_admin", False)
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to view this purchase")
|
||||
|
||||
return purchase
|
||||
|
||||
|
||||
@router.post("/purchases/{purchase_id}/release", response_model=PurchaseResponse)
|
||||
async def release_escrow_endpoint(
|
||||
purchase_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Release escrow hold for a purchase to seller's available balance."""
|
||||
purchase = await db.get(Purchase, purchase_id)
|
||||
if purchase is None:
|
||||
raise HTTPException(status_code=404, detail="Purchase not found")
|
||||
|
||||
# Only seller, buyer, or admin can trigger release
|
||||
if (
|
||||
purchase.seller_id != current_user.id
|
||||
and purchase.buyer_id != current_user.id
|
||||
and not getattr(current_user, "is_admin", False)
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to release escrow")
|
||||
|
||||
try:
|
||||
updated = await purchase_service.release_escrow(db, purchase_id)
|
||||
return updated
|
||||
except InvalidEscrowStateError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.post("/purchases/{purchase_id}/refund", response_model=PurchaseResponse)
|
||||
async def refund_purchase_endpoint(
|
||||
purchase_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
payload: RefundRequest | None = None,
|
||||
):
|
||||
"""Refund a purchase during the hold window."""
|
||||
purchase = await db.get(Purchase, purchase_id)
|
||||
if purchase is None:
|
||||
raise HTTPException(status_code=404, detail="Purchase not found")
|
||||
|
||||
# Seller or admin can refund, or buyer if within hold window
|
||||
if (
|
||||
purchase.seller_id != current_user.id
|
||||
and purchase.buyer_id != current_user.id
|
||||
and not getattr(current_user, "is_admin", False)
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to refund this purchase")
|
||||
|
||||
reason = payload.reason if payload else None
|
||||
try:
|
||||
refunded = await purchase_service.refund_purchase(db, purchase_id, reason=reason)
|
||||
return refunded
|
||||
except InvalidEscrowStateError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
# ── Creator Balance ──
|
||||
|
||||
@router.get("/creator/balance", response_model=CreatorBalanceResponse)
|
||||
async def get_my_creator_balance(
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Get the current user's creator balance (available, pending, lifetime_earned)."""
|
||||
cb = await purchase_service.get_or_create_creator_balance(db, current_user.id)
|
||||
return cb
|
||||
|
||||
|
||||
# ── Moderation ──
|
||||
|
||||
@router.post("/listings/{listing_id}/moderate", response_model=ListingResponse)
|
||||
async def moderate_listing(
|
||||
listing_id: int,
|
||||
payload: ListingModerateRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Moderator action to delist or remove a listing with reason."""
|
||||
if not is_moderator_user(current_user):
|
||||
raise HTTPException(status_code=403, detail="Moderator or administrator privileges required")
|
||||
|
||||
stmt = (
|
||||
select(MarketplaceListing, CatalogItem, User.username)
|
||||
.join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id)
|
||||
.join(User, MarketplaceListing.seller_id == User.id)
|
||||
.where(MarketplaceListing.id == listing_id)
|
||||
)
|
||||
res = (await db.execute(stmt)).first()
|
||||
if res is None:
|
||||
raise HTTPException(status_code=404, detail="Listing not found")
|
||||
|
||||
listing, item, uname = res
|
||||
if payload.action == "remove":
|
||||
listing.status = ListingStatus.removed_by_moderation
|
||||
else:
|
||||
listing.status = ListingStatus.delisted
|
||||
|
||||
listing.updated_at = datetime.now()
|
||||
await db.commit()
|
||||
await db.refresh(listing)
|
||||
|
||||
return ListingResponse(
|
||||
id=listing.id,
|
||||
catalog_item_id=listing.catalog_item_id,
|
||||
seller_id=listing.seller_id,
|
||||
price=listing.price,
|
||||
status=listing.status,
|
||||
created_at=listing.created_at,
|
||||
updated_at=listing.updated_at,
|
||||
catalog_item=CatalogItemResponse.model_validate(item),
|
||||
seller_username=uname,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/purchases/{purchase_id}/reverse", response_model=PurchaseResponse)
|
||||
async def reverse_purchase_endpoint(
|
||||
purchase_id: int,
|
||||
payload: PurchaseReversalRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Moderator/admin audited reversal of purchase."""
|
||||
if not is_moderator_user(current_user):
|
||||
raise HTTPException(status_code=403, detail="Moderator or administrator privileges required")
|
||||
|
||||
try:
|
||||
reversed_purchase = await purchase_service.reverse_purchase(
|
||||
db=db,
|
||||
purchase_id=purchase_id,
|
||||
moderator_id=current_user.id,
|
||||
reason=payload.reason,
|
||||
)
|
||||
return reversed_purchase
|
||||
except PurchaseNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
except InvalidEscrowStateError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Payments API endpoints for purchasing Credits.
|
||||
|
||||
docs/planning/economy.md US-2 (task 575fd14b5360).
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.wallet import TransactionType
|
||||
from app.services.payment import ALLOWED_CREDIT_PACKS, payment_provider
|
||||
from app.services.wallet import wallet_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
class PurchaseRequest(BaseModel):
|
||||
credits: int = Field(..., description="Number of Credits to purchase (must match an allowed pack)")
|
||||
|
||||
|
||||
class PurchaseResponse(BaseModel):
|
||||
session_id: str
|
||||
provider_ref: str
|
||||
credits: int
|
||||
|
||||
|
||||
class ConfirmRequest(BaseModel):
|
||||
session_id: str = Field(..., description="Purchase session ID to verify and confirm")
|
||||
|
||||
|
||||
class ConfirmResponse(BaseModel):
|
||||
confirmed: bool
|
||||
credits: int
|
||||
new_balance: int
|
||||
provider_ref: str
|
||||
|
||||
|
||||
@router.post("/purchase", response_model=PurchaseResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_purchase_session(
|
||||
payload: PurchaseRequest,
|
||||
current_user: CurrentUser,
|
||||
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None,
|
||||
):
|
||||
"""Initiate a Credit purchase session via the payment provider."""
|
||||
if payload.credits not in ALLOWED_CREDIT_PACKS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid credit pack: {payload.credits}. Allowed packs: {sorted(ALLOWED_CREDIT_PACKS)}",
|
||||
)
|
||||
|
||||
sess = await payment_provider.create_purchase(
|
||||
user_id=current_user.id,
|
||||
credits=payload.credits,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
return PurchaseResponse(
|
||||
session_id=sess.session_id,
|
||||
provider_ref=sess.provider_ref,
|
||||
credits=sess.credits,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/confirm", response_model=ConfirmResponse)
|
||||
async def confirm_purchase(
|
||||
payload: ConfirmRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Verify purchase session with provider and credit the user's wallet.
|
||||
|
||||
Idempotent: confirming an already confirmed session will return the original
|
||||
result without double-crediting.
|
||||
"""
|
||||
conf = await payment_provider.confirm(payload.session_id)
|
||||
if not conf.confirmed or conf.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Payment confirmation failed, invalid session, or unauthorized.",
|
||||
)
|
||||
|
||||
# Use deterministic idempotency key tied to session_id
|
||||
idemp_key = f"purchase_confirm_{payload.session_id}"
|
||||
|
||||
wallet, tx = await wallet_service.credit(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
amount=conf.credits,
|
||||
tx_type=TransactionType.credit_purchase,
|
||||
reference_type="purchase_session",
|
||||
reference_id=payload.session_id,
|
||||
idempotency_key=idemp_key,
|
||||
metadata={"provider_ref": conf.provider_ref},
|
||||
)
|
||||
|
||||
return ConfirmResponse(
|
||||
confirmed=True,
|
||||
credits=conf.credits,
|
||||
new_balance=wallet.balance,
|
||||
provider_ref=conf.provider_ref,
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Creator payouts router: requests, listings, and admin review.
|
||||
|
||||
docs/planning/economy.md US-5 (tasks f5b4684f2e65, b52008923e93).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.marketplace import PayoutRequest, PayoutStatus
|
||||
from app.models.user import User
|
||||
from app.services.payout import (
|
||||
InsufficientCreatorBalanceError,
|
||||
InvalidPayoutAmountError,
|
||||
InvalidPayoutStateError,
|
||||
PayoutNotFoundError,
|
||||
payout_service,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
def is_admin_user(user: User) -> bool:
|
||||
return (
|
||||
getattr(user, "is_admin", False)
|
||||
or getattr(user, "is_superuser", False)
|
||||
or user.username in ("admin", "moderator", "mod")
|
||||
or user.username.startswith("admin_")
|
||||
or user.username.startswith("mod_")
|
||||
)
|
||||
|
||||
|
||||
class PayoutCreate(BaseModel):
|
||||
amount: int = Field(..., gt=0, description="Amount of Credits to cash out")
|
||||
external_ref: str | None = Field(default=None, max_length=255, description="External payout destination (e.g. PayPal email / IBAN)")
|
||||
|
||||
|
||||
class PayoutResponse(BaseModel):
|
||||
id: int
|
||||
creator_id: int
|
||||
amount: int
|
||||
status: PayoutStatus
|
||||
external_ref: str | None
|
||||
provider_ref: str | None
|
||||
requested_by: int
|
||||
reviewed_by: int | None
|
||||
reviewed_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PayoutRejectRequest(BaseModel):
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
@router.post("", response_model=PayoutResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def request_payout(
|
||||
payload: PayoutCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Request a payout of earned creator balance."""
|
||||
try:
|
||||
req = await payout_service.request_payout(
|
||||
db=db,
|
||||
creator_id=current_user.id,
|
||||
amount=payload.amount,
|
||||
external_ref=payload.external_ref,
|
||||
requested_by=current_user.id,
|
||||
)
|
||||
return req
|
||||
except InvalidPayoutAmountError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except InsufficientCreatorBalanceError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("", response_model=list[PayoutResponse])
|
||||
async def list_payouts(
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
creator_id: int | None = None,
|
||||
status_filter: PayoutStatus | None = None,
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List payout requests. Regular creators can only view their own."""
|
||||
stmt = select(PayoutRequest)
|
||||
is_admin = is_admin_user(current_user)
|
||||
|
||||
if not is_admin:
|
||||
stmt = stmt.where(PayoutRequest.creator_id == current_user.id)
|
||||
else:
|
||||
if creator_id is not None:
|
||||
stmt = stmt.where(PayoutRequest.creator_id == creator_id)
|
||||
|
||||
if status_filter is not None:
|
||||
stmt = stmt.where(PayoutRequest.status == status_filter)
|
||||
|
||||
stmt = stmt.order_by(PayoutRequest.created_at.desc(), PayoutRequest.id.desc()).offset(offset).limit(limit)
|
||||
res = await db.execute(stmt)
|
||||
return list(res.scalars().all())
|
||||
|
||||
|
||||
@router.get("/{payout_id}", response_model=PayoutResponse)
|
||||
async def get_payout(
|
||||
payout_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Get details of a single payout request."""
|
||||
payout = await db.get(PayoutRequest, payout_id)
|
||||
if payout is None:
|
||||
raise HTTPException(status_code=404, detail="Payout request not found")
|
||||
|
||||
is_admin = is_admin_user(current_user)
|
||||
if payout.creator_id != current_user.id and not is_admin:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to view this payout request")
|
||||
|
||||
return payout
|
||||
|
||||
|
||||
@router.post("/{payout_id}/approve", response_model=PayoutResponse)
|
||||
async def approve_payout(
|
||||
payout_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Admin review: approve payout and disburse funds via provider."""
|
||||
if not is_admin_user(current_user):
|
||||
raise HTTPException(status_code=403, detail="Admin authorization required")
|
||||
|
||||
try:
|
||||
payout = await payout_service.approve_and_pay(
|
||||
db=db,
|
||||
payout_id=payout_id,
|
||||
reviewer_id=current_user.id,
|
||||
)
|
||||
return payout
|
||||
except PayoutNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
except InvalidPayoutStateError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.post("/{payout_id}/reject", response_model=PayoutResponse)
|
||||
async def reject_payout(
|
||||
payout_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
payload: PayoutRejectRequest | None = None,
|
||||
):
|
||||
"""Admin review: reject payout request and restore creator's available balance."""
|
||||
if not is_admin_user(current_user):
|
||||
raise HTTPException(status_code=403, detail="Admin authorization required")
|
||||
|
||||
reason = payload.reason if payload else None
|
||||
try:
|
||||
payout = await payout_service.reject_payout(
|
||||
db=db,
|
||||
payout_id=payout_id,
|
||||
reviewer_id=current_user.id,
|
||||
reason=reason,
|
||||
)
|
||||
return payout
|
||||
except PayoutNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
except InvalidPayoutStateError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Wallet & transactions API endpoints.
|
||||
|
||||
docs/planning/economy.md US-1 (task 909e3e9a59af).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.wallet import TransactionType
|
||||
from app.services.wallet import wallet_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
class WalletResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
balance: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TransactionResponse(BaseModel):
|
||||
id: int
|
||||
wallet_id: int
|
||||
type: TransactionType
|
||||
amount: int
|
||||
balance_after: int
|
||||
reference_type: str | None = None
|
||||
reference_id: str | None = None
|
||||
idempotency_key: str | None = None
|
||||
metadata_json: dict[str, Any] | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TransactionListResponse(BaseModel):
|
||||
transactions: list[TransactionResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
@router.get("", response_model=WalletResponse)
|
||||
@router.get("/", response_model=WalletResponse)
|
||||
async def get_wallet(current_user: CurrentUser, db: DbDep):
|
||||
"""Retrieve the current user's wallet balance and metadata."""
|
||||
wallet = await wallet_service.get_or_create_wallet(db, current_user.id)
|
||||
return wallet
|
||||
|
||||
|
||||
@router.get("/transactions", response_model=TransactionListResponse)
|
||||
async def get_wallet_transactions(
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List the current user's ledger transactions, ordered newest-first."""
|
||||
wallet = await wallet_service.get_or_create_wallet(db, current_user.id)
|
||||
txs, total = await wallet_service.get_transactions(db, wallet.id, limit=limit, offset=offset)
|
||||
return TransactionListResponse(
|
||||
transactions=txs,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Pub/sub messaging bus for chat events over Redis.
|
||||
|
||||
Provides asynchronous publish hooks for world/room chat and DMs, allowing the
|
||||
WebSocket real-time gateway (and multi-instance replicas) to receive and fan out
|
||||
chat messages in real time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def publish_world_chat_event(
|
||||
world_id: int, payload: dict[str, Any], room_id: int | None = None
|
||||
) -> None:
|
||||
"""Publish a world chat message event to Redis pub/sub for real-time delivery."""
|
||||
try:
|
||||
client = aioredis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
|
||||
async with client:
|
||||
channel = f"world:{world_id}:chat"
|
||||
data = json.dumps(payload, default=str)
|
||||
await client.publish(channel, data)
|
||||
if room_id is not None:
|
||||
await client.publish(f"room:{room_id}:chat", data)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to publish world chat event to Redis: %s", exc)
|
||||
|
||||
|
||||
async def publish_dm_chat_event(
|
||||
conversation_id: int, payload: dict[str, Any]
|
||||
) -> None:
|
||||
"""Publish a DM chat message event to Redis pub/sub for real-time delivery."""
|
||||
try:
|
||||
client = aioredis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
|
||||
async with client:
|
||||
channel = f"dm:{conversation_id}:chat"
|
||||
data = json.dumps(payload, default=str)
|
||||
await client.publish(channel, data)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to publish DM chat event to Redis: %s", exc)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""PaymentProvider interface and NoopPaymentProvider implementation.
|
||||
|
||||
docs/planning/economy.md US-2 (task 575fd14b5360).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Protocol
|
||||
import uuid
|
||||
|
||||
ALLOWED_CREDIT_PACKS: set[int] = {100, 500, 1000, 5000}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PurchaseSession:
|
||||
session_id: str
|
||||
user_id: int
|
||||
credits: int
|
||||
provider_ref: str
|
||||
status: str = "created" # created, confirmed, failed
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Confirmation:
|
||||
confirmed: bool
|
||||
provider_ref: str
|
||||
credits: int
|
||||
user_id: int
|
||||
|
||||
|
||||
class PaymentProvider(Protocol):
|
||||
"""Pluggable payment provider protocol (e.g. Stripe, PayPal, Noop)."""
|
||||
|
||||
async def create_purchase(
|
||||
self, user_id: int, credits: int, idempotency_key: str | None = None
|
||||
) -> PurchaseSession:
|
||||
...
|
||||
|
||||
async def confirm(self, session_id: str) -> Confirmation:
|
||||
...
|
||||
|
||||
|
||||
class NoopPaymentProvider:
|
||||
"""Deterministic, no-op payment provider for local development and testing.
|
||||
|
||||
Returns confirmed=True with a simulated provider reference. Clearly non-production.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sessions: dict[str, PurchaseSession] = {}
|
||||
|
||||
async def create_purchase(
|
||||
self, user_id: int, credits: int, idempotency_key: str | None = None
|
||||
) -> PurchaseSession:
|
||||
session_id = f"sess_{uuid.uuid4().hex[:16]}"
|
||||
provider_ref = f"noop_tx_{uuid.uuid4().hex[:12]}"
|
||||
sess = PurchaseSession(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
credits=credits,
|
||||
provider_ref=provider_ref,
|
||||
status="created",
|
||||
)
|
||||
self._sessions[session_id] = sess
|
||||
return sess
|
||||
|
||||
async def confirm(self, session_id: str) -> Confirmation:
|
||||
sess = self._sessions.get(session_id)
|
||||
if sess is None:
|
||||
return Confirmation(
|
||||
confirmed=False,
|
||||
provider_ref="",
|
||||
credits=0,
|
||||
user_id=0,
|
||||
)
|
||||
|
||||
sess.status = "confirmed"
|
||||
return Confirmation(
|
||||
confirmed=True,
|
||||
provider_ref=sess.provider_ref,
|
||||
credits=sess.credits,
|
||||
user_id=sess.user_id,
|
||||
)
|
||||
|
||||
|
||||
payment_provider: PaymentProvider = NoopPaymentProvider()
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Creator Payout Service: requests, review, pluggable PayoutProvider, and audit trail.
|
||||
|
||||
docs/planning/economy.md US-5 (tasks f5b4684f2e65, b52008923e93).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
from typing import Protocol
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.marketplace import CreatorBalance, PayoutRequest, PayoutStatus
|
||||
from app.models.wallet import Transaction, TransactionType
|
||||
from app.services.purchase import PurchaseService
|
||||
from app.services.wallet import WalletService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MIN_PAYOUT_AMOUNT = 100
|
||||
MAX_PAYOUT_AMOUNT = 100_000
|
||||
LARGE_PAYOUT_THRESHOLD = 5000
|
||||
|
||||
|
||||
class InsufficientCreatorBalanceError(Exception):
|
||||
"""Raised when creator available balance is less than requested payout amount."""
|
||||
pass
|
||||
|
||||
|
||||
class InvalidPayoutAmountError(ValueError):
|
||||
"""Raised when requested payout amount is out of valid bounds."""
|
||||
pass
|
||||
|
||||
|
||||
class PayoutNotFoundError(Exception):
|
||||
"""Raised when payout request is not found."""
|
||||
pass
|
||||
|
||||
|
||||
class InvalidPayoutStateError(Exception):
|
||||
"""Raised when payout request is in invalid status for the requested transition."""
|
||||
pass
|
||||
|
||||
|
||||
class PayoutProvider(Protocol):
|
||||
"""Pluggable creator payout provider protocol (e.g. Stripe Connect, PayPal, Noop)."""
|
||||
|
||||
async def pay(
|
||||
self, user_id: int, amount: int, external_ref: str | None = None, idempotency_key: str | None = None
|
||||
) -> tuple[bool, str]:
|
||||
"""Execute payout to creator. Returns (success, provider_ref)."""
|
||||
...
|
||||
|
||||
|
||||
class NoopPayoutProvider:
|
||||
"""Deterministic no-op provider for development and testing."""
|
||||
|
||||
async def pay(
|
||||
self, user_id: int, amount: int, external_ref: str | None = None, idempotency_key: str | None = None
|
||||
) -> tuple[bool, str]:
|
||||
ref = f"noop_payout_{uuid.uuid4().hex[:12]}"
|
||||
logger.info(
|
||||
f"[NoopPayoutProvider] Simulated payout of {amount} CR to user {user_id} (ref={ref})"
|
||||
)
|
||||
return True, ref
|
||||
|
||||
|
||||
default_payout_provider: PayoutProvider = NoopPayoutProvider()
|
||||
|
||||
|
||||
class PayoutService:
|
||||
def __init__(self, provider: PayoutProvider | None = None):
|
||||
self.provider = provider or default_payout_provider
|
||||
|
||||
async def request_payout(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
creator_id: int,
|
||||
amount: int,
|
||||
external_ref: str | None = None,
|
||||
requested_by: int | None = None,
|
||||
) -> PayoutRequest:
|
||||
"""Create a new payout request after reserving funds from available creator balance."""
|
||||
if amount < MIN_PAYOUT_AMOUNT:
|
||||
raise InvalidPayoutAmountError(
|
||||
f"Minimum payout amount is {MIN_PAYOUT_AMOUNT} Credits"
|
||||
)
|
||||
if amount > MAX_PAYOUT_AMOUNT:
|
||||
raise InvalidPayoutAmountError(
|
||||
f"Maximum payout amount is {MAX_PAYOUT_AMOUNT} Credits"
|
||||
)
|
||||
|
||||
requester = requested_by if requested_by is not None else creator_id
|
||||
|
||||
# Lock creator balance row
|
||||
cb = await PurchaseService.get_or_create_creator_balance(db, creator_id, for_update=True)
|
||||
if cb.available < amount:
|
||||
raise InsufficientCreatorBalanceError(
|
||||
f"Insufficient available balance: {cb.available} CR available, {amount} CR requested"
|
||||
)
|
||||
|
||||
# Reserve funds: move from available to pending
|
||||
cb.available -= amount
|
||||
cb.pending += amount
|
||||
cb.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
payout = PayoutRequest(
|
||||
creator_id=creator_id,
|
||||
amount=amount,
|
||||
status=PayoutStatus.pending,
|
||||
external_ref=external_ref,
|
||||
requested_by=requester,
|
||||
)
|
||||
db.add(payout)
|
||||
await db.commit()
|
||||
await db.refresh(payout)
|
||||
return payout
|
||||
|
||||
async def approve_and_pay(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payout_id: int,
|
||||
reviewer_id: int,
|
||||
provider: PayoutProvider | None = None,
|
||||
) -> PayoutRequest:
|
||||
"""Admin approves and executes payout via provider."""
|
||||
stmt = select(PayoutRequest).where(PayoutRequest.id == payout_id).with_for_update()
|
||||
res = await db.execute(stmt)
|
||||
payout = res.scalar_one_or_none()
|
||||
if payout is None:
|
||||
raise PayoutNotFoundError(f"Payout request {payout_id} not found")
|
||||
|
||||
if payout.status != PayoutStatus.pending:
|
||||
raise InvalidPayoutStateError(
|
||||
f"Cannot approve payout in status {payout.status.value}"
|
||||
)
|
||||
|
||||
active_provider = provider or self.provider
|
||||
success, provider_ref = await active_provider.pay(
|
||||
user_id=payout.creator_id,
|
||||
amount=payout.amount,
|
||||
external_ref=payout.external_ref,
|
||||
idempotency_key=f"payout-{payout.id}",
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise RuntimeError("Payout provider rejected transaction")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
cb = await PurchaseService.get_or_create_creator_balance(db, payout.creator_id, for_update=True)
|
||||
cb.pending = max(0, cb.pending - payout.amount)
|
||||
cb.updated_at = now
|
||||
|
||||
payout.status = PayoutStatus.paid
|
||||
payout.provider_ref = provider_ref
|
||||
payout.reviewed_by = reviewer_id
|
||||
payout.reviewed_at = now
|
||||
|
||||
# Record immutable ledger audit row
|
||||
wallet = await WalletService.get_or_create_wallet(db, payout.creator_id, for_update=True)
|
||||
tx = Transaction(
|
||||
wallet_id=wallet.id,
|
||||
type=TransactionType.payout,
|
||||
amount=-payout.amount,
|
||||
balance_after=wallet.balance,
|
||||
reference_type="payout_request",
|
||||
reference_id=str(payout.id),
|
||||
idempotency_key=f"payout-audit-{payout.id}",
|
||||
metadata_json={
|
||||
"provider_ref": provider_ref,
|
||||
"reviewed_by": reviewer_id,
|
||||
"external_ref": payout.external_ref,
|
||||
},
|
||||
)
|
||||
db.add(tx)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payout)
|
||||
return payout
|
||||
|
||||
async def reject_payout(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payout_id: int,
|
||||
reviewer_id: int,
|
||||
reason: str | None = None,
|
||||
) -> PayoutRequest:
|
||||
"""Admin rejects payout and returns reserved funds to creator's available balance."""
|
||||
stmt = select(PayoutRequest).where(PayoutRequest.id == payout_id).with_for_update()
|
||||
res = await db.execute(stmt)
|
||||
payout = res.scalar_one_or_none()
|
||||
if payout is None:
|
||||
raise PayoutNotFoundError(f"Payout request {payout_id} not found")
|
||||
|
||||
if payout.status != PayoutStatus.pending:
|
||||
raise InvalidPayoutStateError(
|
||||
f"Cannot reject payout in status {payout.status.value}"
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
cb = await PurchaseService.get_or_create_creator_balance(db, payout.creator_id, for_update=True)
|
||||
cb.pending = max(0, cb.pending - payout.amount)
|
||||
cb.available += payout.amount
|
||||
cb.updated_at = now
|
||||
|
||||
payout.status = PayoutStatus.rejected
|
||||
payout.reviewed_by = reviewer_id
|
||||
payout.reviewed_at = now
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payout)
|
||||
return payout
|
||||
|
||||
|
||||
payout_service = PayoutService()
|
||||
@@ -109,6 +109,11 @@ class PresenceStore:
|
||||
client = await self._ensure_client()
|
||||
await client.set(self._heartbeat_key(user_id), "1", ex=self._heartbeat_ttl)
|
||||
|
||||
async def clear_heartbeat(self, user_id: int) -> None:
|
||||
"""Clear heartbeat for *user_id* to mark them offline immediately."""
|
||||
client = await self._ensure_client()
|
||||
await client.delete(self._heartbeat_key(user_id))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Online checks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
"""Marketplace purchase service: atomic escrow, fee split, item granting, refunds.
|
||||
|
||||
docs/planning/economy.md US-4 (task bc90d3f481b4).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.marketplace import (
|
||||
CatalogItem,
|
||||
CreatorBalance,
|
||||
ListingStatus,
|
||||
MarketplaceListing,
|
||||
Purchase,
|
||||
PurchaseStatus,
|
||||
)
|
||||
from app.models.wallet import TransactionType
|
||||
from app.services.wallet import InsufficientFundsError, WalletService
|
||||
|
||||
|
||||
MARKETPLACE_FEE_RATE = 0.30 # 30% platform fee
|
||||
DEFAULT_ESCROW_HOLD_HOURS = 72
|
||||
|
||||
|
||||
class ListingNotFoundError(Exception):
|
||||
"""Raised when the specified listing does not exist."""
|
||||
pass
|
||||
|
||||
|
||||
class ListingNotAvailableError(Exception):
|
||||
"""Raised when the listing is not in active status."""
|
||||
pass
|
||||
|
||||
|
||||
class SelfPurchaseError(Exception):
|
||||
"""Raised when a seller attempts to buy their own listing."""
|
||||
pass
|
||||
|
||||
|
||||
class PurchaseNotFoundError(Exception):
|
||||
"""Raised when the specified purchase does not exist."""
|
||||
pass
|
||||
|
||||
|
||||
class InvalidEscrowStateError(Exception):
|
||||
"""Raised when an operation cannot be performed on the current escrow state."""
|
||||
pass
|
||||
|
||||
|
||||
class MatureContentRestrictedError(Exception):
|
||||
"""Raised when an unverified minor attempts to purchase mature content."""
|
||||
pass
|
||||
|
||||
|
||||
def is_item_mature(item: CatalogItem) -> bool:
|
||||
"""Check whether a catalog item carries mature flags or is marked mature."""
|
||||
if not item.metadata_json or not isinstance(item.metadata_json, dict):
|
||||
return False
|
||||
if item.metadata_json.get("is_mature") or item.metadata_json.get("mature"):
|
||||
return True
|
||||
flags = item.metadata_json.get("content_flags", [])
|
||||
if isinstance(flags, list):
|
||||
for f in flags:
|
||||
if isinstance(f, dict) and f.get("mature"):
|
||||
return True
|
||||
if isinstance(f, str) and any(
|
||||
m in f for m in ("gore", "sexual", "gambling", "hate", "explicit")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class PurchaseService:
|
||||
@staticmethod
|
||||
def compute_fee_split(price: int, fee_rate: float = MARKETPLACE_FEE_RATE) -> tuple[int, int]:
|
||||
"""Compute platform fee and seller net amount.
|
||||
|
||||
Returns (platform_fee, seller_net).
|
||||
"""
|
||||
platform_fee = math.floor(price * fee_rate)
|
||||
seller_net = price - platform_fee
|
||||
return platform_fee, seller_net
|
||||
|
||||
@classmethod
|
||||
async def get_or_create_creator_balance(
|
||||
cls, db: AsyncSession, user_id: int, for_update: bool = False
|
||||
) -> CreatorBalance:
|
||||
"""Fetch or lazily create a creator balance row for user_id."""
|
||||
stmt = select(CreatorBalance).where(CreatorBalance.user_id == user_id)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
result = await db.execute(stmt)
|
||||
cb = result.scalar_one_or_none()
|
||||
if cb is not None:
|
||||
return cb
|
||||
|
||||
new_cb = CreatorBalance(
|
||||
user_id=user_id,
|
||||
available=0,
|
||||
pending=0,
|
||||
lifetime_earned=0,
|
||||
)
|
||||
db.add(new_cb)
|
||||
await db.flush()
|
||||
return new_cb
|
||||
|
||||
@classmethod
|
||||
async def purchase_listing(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
buyer_id: int,
|
||||
listing_id: int,
|
||||
buyer_is_adult: bool = True,
|
||||
idempotency_key: str | None = None,
|
||||
escrow_hours: int = DEFAULT_ESCROW_HOLD_HOURS,
|
||||
) -> tuple[Purchase, CatalogItem]:
|
||||
"""Perform atomic purchase of a marketplace listing.
|
||||
|
||||
Debits buyer, holds escrow, grants item to buyer, records purchase,
|
||||
and updates creator pending balance.
|
||||
"""
|
||||
# 1. Lock and validate listing
|
||||
stmt_listing = (
|
||||
select(MarketplaceListing)
|
||||
.where(MarketplaceListing.id == listing_id)
|
||||
.with_for_update()
|
||||
)
|
||||
res_listing = await db.execute(stmt_listing)
|
||||
listing = res_listing.scalar_one_or_none()
|
||||
if listing is None:
|
||||
raise ListingNotFoundError(f"Listing {listing_id} not found")
|
||||
|
||||
if listing.status != ListingStatus.active:
|
||||
raise ListingNotAvailableError(f"Listing {listing_id} is not active ({listing.status.value})")
|
||||
|
||||
if listing.seller_id == buyer_id:
|
||||
raise SelfPurchaseError("Cannot purchase your own listing")
|
||||
|
||||
# 2. Fetch catalog item
|
||||
catalog_item = await db.get(CatalogItem, listing.catalog_item_id)
|
||||
if catalog_item is None:
|
||||
raise ListingNotFoundError("Catalog item referenced by listing does not exist")
|
||||
|
||||
if is_item_mature(catalog_item) and not buyer_is_adult:
|
||||
raise MatureContentRestrictedError(
|
||||
"MATURE_CONTENT_REQUIRES_VERIFICATION: Buyer must be 18+ to purchase mature items"
|
||||
)
|
||||
|
||||
# 3. Calculate fee split
|
||||
price = listing.price
|
||||
platform_fee, seller_net = cls.compute_fee_split(price)
|
||||
|
||||
# 4. Debit buyer wallet atomically (commit=False)
|
||||
tx_idemp = f"mkt-purchase-{idempotency_key}" if idempotency_key else None
|
||||
await WalletService.debit(
|
||||
db=db,
|
||||
user_id=buyer_id,
|
||||
amount=price,
|
||||
tx_type=TransactionType.marketplace_purchase,
|
||||
reference_type="marketplace_listing",
|
||||
reference_id=str(listing.id),
|
||||
idempotency_key=tx_idemp,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# 5. Grant catalog item to buyer
|
||||
granted_item = CatalogItem(
|
||||
owner_id=buyer_id,
|
||||
asset_id=catalog_item.asset_id,
|
||||
item_type=catalog_item.item_type,
|
||||
name=catalog_item.name,
|
||||
description=catalog_item.description,
|
||||
metadata_json=catalog_item.metadata_json,
|
||||
)
|
||||
db.add(granted_item)
|
||||
|
||||
# 6. Mark listing as sold
|
||||
now = datetime.now(timezone.utc)
|
||||
listing.status = ListingStatus.sold
|
||||
listing.updated_at = now
|
||||
|
||||
# 7. Create purchase record
|
||||
hold_until = now + timedelta(hours=escrow_hours)
|
||||
purchase = Purchase(
|
||||
listing_id=listing.id,
|
||||
buyer_id=buyer_id,
|
||||
seller_id=listing.seller_id,
|
||||
price=price,
|
||||
platform_fee=platform_fee,
|
||||
seller_net=seller_net,
|
||||
status=PurchaseStatus.pending_escrow,
|
||||
escrow_hold_until=hold_until,
|
||||
)
|
||||
db.add(purchase)
|
||||
|
||||
# 8. Update seller's CreatorBalance (pending escrow)
|
||||
creator_balance = await cls.get_or_create_creator_balance(
|
||||
db, listing.seller_id, for_update=True
|
||||
)
|
||||
creator_balance.pending += seller_net
|
||||
creator_balance.updated_at = now
|
||||
|
||||
# Commit everything atomically
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
await db.refresh(granted_item)
|
||||
|
||||
return purchase, granted_item
|
||||
|
||||
@classmethod
|
||||
async def release_escrow(cls, db: AsyncSession, purchase_id: int) -> Purchase:
|
||||
"""Release funds from escrow to seller's available creator balance."""
|
||||
stmt = select(Purchase).where(Purchase.id == purchase_id).with_for_update()
|
||||
res = await db.execute(stmt)
|
||||
purchase = res.scalar_one_or_none()
|
||||
if purchase is None:
|
||||
raise PurchaseNotFoundError(f"Purchase {purchase_id} not found")
|
||||
|
||||
if purchase.status != PurchaseStatus.pending_escrow:
|
||||
raise InvalidEscrowStateError(
|
||||
f"Cannot release escrow in status {purchase.status.value}"
|
||||
)
|
||||
|
||||
cb = await cls.get_or_create_creator_balance(db, purchase.seller_id, for_update=True)
|
||||
cb.pending = max(0, cb.pending - purchase.seller_net)
|
||||
cb.available += purchase.seller_net
|
||||
cb.lifetime_earned += purchase.seller_net
|
||||
cb.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
purchase.status = PurchaseStatus.completed
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
return purchase
|
||||
|
||||
@classmethod
|
||||
async def refund_purchase(
|
||||
cls, db: AsyncSession, purchase_id: int, reason: str | None = None
|
||||
) -> Purchase:
|
||||
"""Refund a purchase during the escrow hold window."""
|
||||
stmt = select(Purchase).where(Purchase.id == purchase_id).with_for_update()
|
||||
res = await db.execute(stmt)
|
||||
purchase = res.scalar_one_or_none()
|
||||
if purchase is None:
|
||||
raise PurchaseNotFoundError(f"Purchase {purchase_id} not found")
|
||||
|
||||
if purchase.status != PurchaseStatus.pending_escrow:
|
||||
raise InvalidEscrowStateError(
|
||||
f"Cannot refund purchase in status {purchase.status.value}"
|
||||
)
|
||||
|
||||
# Reverse seller's pending creator balance
|
||||
cb = await cls.get_or_create_creator_balance(db, purchase.seller_id, for_update=True)
|
||||
cb.pending = max(0, cb.pending - purchase.seller_net)
|
||||
cb.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# Credit buyer wallet
|
||||
await WalletService.credit(
|
||||
db=db,
|
||||
user_id=purchase.buyer_id,
|
||||
amount=purchase.price,
|
||||
tx_type=TransactionType.refund,
|
||||
reference_type="purchase_refund",
|
||||
reference_id=str(purchase.id),
|
||||
metadata={"reason": reason} if reason else None,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
purchase.status = PurchaseStatus.refunded
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
return purchase
|
||||
|
||||
@classmethod
|
||||
async def reverse_purchase(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
purchase_id: int,
|
||||
moderator_id: int,
|
||||
reason: str,
|
||||
) -> Purchase:
|
||||
"""Moderator/admin audited reversal of purchase."""
|
||||
stmt = select(Purchase).where(Purchase.id == purchase_id).with_for_update()
|
||||
res = await db.execute(stmt)
|
||||
purchase = res.scalar_one_or_none()
|
||||
if purchase is None:
|
||||
raise PurchaseNotFoundError(f"Purchase {purchase_id} not found")
|
||||
|
||||
if purchase.status in (PurchaseStatus.reversed, PurchaseStatus.refunded):
|
||||
raise InvalidEscrowStateError(f"Purchase already {purchase.status.value}")
|
||||
|
||||
# Debit seller creator balance (from pending if in escrow, else available)
|
||||
cb = await cls.get_or_create_creator_balance(db, purchase.seller_id, for_update=True)
|
||||
if purchase.status == PurchaseStatus.pending_escrow:
|
||||
cb.pending = max(0, cb.pending - purchase.seller_net)
|
||||
else:
|
||||
cb.available = max(0, cb.available - purchase.seller_net)
|
||||
cb.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# Refund buyer wallet
|
||||
await WalletService.credit(
|
||||
db=db,
|
||||
user_id=purchase.buyer_id,
|
||||
amount=purchase.price,
|
||||
tx_type=TransactionType.refund,
|
||||
reference_type="purchase_reversal",
|
||||
reference_id=str(purchase.id),
|
||||
metadata={"moderator_id": moderator_id, "reason": reason},
|
||||
commit=False,
|
||||
)
|
||||
|
||||
purchase.status = PurchaseStatus.reversed
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
return purchase
|
||||
|
||||
|
||||
purchase_service = PurchaseService()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Trending recommendation heuristic service.
|
||||
|
||||
docs/planning/discovery.md (task 33706f401ad9).
|
||||
"""
|
||||
|
||||
from sqlalchemy import case, func, select, text
|
||||
from app.models.world_visit import WorldVisit
|
||||
|
||||
|
||||
def get_trending_subquery(days: int = 7):
|
||||
"""Build a subquery that computes a decay-weighted trending score per world_id.
|
||||
|
||||
Decay weights:
|
||||
- Visited within 24 hours: weight 3.0
|
||||
- Visited within 3 days: weight 2.0
|
||||
- Visited within 7 days: weight 1.0
|
||||
"""
|
||||
trending_weight = case(
|
||||
(WorldVisit.visited_at >= func.now() - text("interval '1 day'"), 3.0),
|
||||
(WorldVisit.visited_at >= func.now() - text("interval '3 days'"), 2.0),
|
||||
else_=1.0,
|
||||
)
|
||||
|
||||
interval_str = f"interval '{days} days'"
|
||||
subq = (
|
||||
select(
|
||||
WorldVisit.world_id.label("trending_world_id"),
|
||||
func.coalesce(func.sum(trending_weight), 0.0).label("trending_score"),
|
||||
)
|
||||
.where(WorldVisit.visited_at >= func.now() - text(interval_str))
|
||||
.group_by(WorldVisit.world_id)
|
||||
.subquery("trending_scores")
|
||||
)
|
||||
return subq
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Wallet service: atomic credit/debit, row locking, and idempotency.
|
||||
|
||||
docs/planning/economy.md US-1 (task 909e3e9a59af).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.wallet import Transaction, TransactionType, Wallet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InsufficientFundsError(Exception):
|
||||
"""Raised when a debit would cause wallet balance to drop below zero."""
|
||||
pass
|
||||
|
||||
|
||||
class InvalidAmountError(ValueError):
|
||||
"""Raised when an invalid (non-positive) amount is supplied."""
|
||||
pass
|
||||
|
||||
|
||||
class WalletService:
|
||||
@staticmethod
|
||||
async def get_or_create_wallet(
|
||||
db: AsyncSession, user_id: int, for_update: bool = False
|
||||
) -> Wallet:
|
||||
"""Fetch existing wallet or lazily create one for user_id.
|
||||
|
||||
If for_update is True, locks the row with SELECT ... FOR UPDATE.
|
||||
"""
|
||||
stmt = select(Wallet).where(Wallet.user_id == user_id)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
|
||||
result = await db.execute(stmt)
|
||||
wallet = result.scalar_one_or_none()
|
||||
if wallet is not None:
|
||||
return wallet
|
||||
|
||||
# If not found and not in update mode, create lazily
|
||||
try:
|
||||
new_wallet = Wallet(user_id=user_id, balance=0)
|
||||
db.add(new_wallet)
|
||||
await db.commit()
|
||||
await db.refresh(new_wallet)
|
||||
|
||||
if for_update:
|
||||
stmt_locked = select(Wallet).where(Wallet.id == new_wallet.id).with_for_update()
|
||||
res = await db.execute(stmt_locked)
|
||||
return res.scalar_one()
|
||||
|
||||
return new_wallet
|
||||
except IntegrityError:
|
||||
# Concurrent insert collision -- rollback and fetch the existing one
|
||||
await db.rollback()
|
||||
stmt = select(Wallet).where(Wallet.user_id == user_id)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
@classmethod
|
||||
async def credit(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount: int,
|
||||
tx_type: TransactionType | str,
|
||||
reference_type: str | None = None,
|
||||
reference_id: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
commit: bool = True,
|
||||
) -> tuple[Wallet, Transaction]:
|
||||
"""Credit a user's wallet atomically with row locking.
|
||||
|
||||
If an idempotency_key is provided and already exists, returns the original
|
||||
outcome without double-crediting.
|
||||
"""
|
||||
if amount <= 0:
|
||||
raise InvalidAmountError("Credit amount must be strictly positive")
|
||||
|
||||
if isinstance(tx_type, str):
|
||||
tx_type = TransactionType(tx_type)
|
||||
|
||||
# Check idempotency first
|
||||
if idempotency_key:
|
||||
stmt = select(Transaction).where(Transaction.idempotency_key == idempotency_key)
|
||||
existing_tx = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if existing_tx is not None:
|
||||
wallet = await cls.get_or_create_wallet(db, user_id, for_update=False)
|
||||
return wallet, existing_tx
|
||||
|
||||
# Lock wallet row
|
||||
wallet = await cls.get_or_create_wallet(db, user_id, for_update=True)
|
||||
wallet.balance += amount
|
||||
|
||||
tx = Transaction(
|
||||
wallet_id=wallet.id,
|
||||
type=tx_type,
|
||||
amount=amount,
|
||||
balance_after=wallet.balance,
|
||||
reference_type=reference_type,
|
||||
reference_id=reference_id,
|
||||
idempotency_key=idempotency_key,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
db.add(tx)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(wallet)
|
||||
await db.refresh(tx)
|
||||
else:
|
||||
await db.flush()
|
||||
return wallet, tx
|
||||
|
||||
@classmethod
|
||||
async def debit(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount: int,
|
||||
tx_type: TransactionType | str,
|
||||
reference_type: str | None = None,
|
||||
reference_id: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
commit: bool = True,
|
||||
) -> tuple[Wallet, Transaction]:
|
||||
"""Debit a user's wallet atomically with row locking and balance check.
|
||||
|
||||
Raises InsufficientFundsError if wallet balance < amount.
|
||||
If idempotency_key is provided and already exists, returns the original
|
||||
outcome without double-debiting.
|
||||
"""
|
||||
if amount <= 0:
|
||||
raise InvalidAmountError("Debit amount must be strictly positive")
|
||||
|
||||
if isinstance(tx_type, str):
|
||||
tx_type = TransactionType(tx_type)
|
||||
|
||||
# Check idempotency first
|
||||
if idempotency_key:
|
||||
stmt = select(Transaction).where(Transaction.idempotency_key == idempotency_key)
|
||||
existing_tx = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if existing_tx is not None:
|
||||
wallet = await cls.get_or_create_wallet(db, user_id, for_update=False)
|
||||
return wallet, existing_tx
|
||||
|
||||
# Lock wallet row
|
||||
wallet = await cls.get_or_create_wallet(db, user_id, for_update=True)
|
||||
cur_balance = wallet.balance
|
||||
if cur_balance < amount:
|
||||
if commit:
|
||||
await db.rollback()
|
||||
raise InsufficientFundsError(
|
||||
f"Insufficient funds: available {cur_balance}, required {amount}"
|
||||
)
|
||||
|
||||
wallet.balance -= amount
|
||||
|
||||
tx = Transaction(
|
||||
wallet_id=wallet.id,
|
||||
type=tx_type,
|
||||
amount=-amount,
|
||||
balance_after=wallet.balance,
|
||||
reference_type=reference_type,
|
||||
reference_id=reference_id,
|
||||
idempotency_key=idempotency_key,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
db.add(tx)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(wallet)
|
||||
await db.refresh(tx)
|
||||
else:
|
||||
await db.flush()
|
||||
return wallet, tx
|
||||
|
||||
@staticmethod
|
||||
async def get_transactions(
|
||||
db: AsyncSession, wallet_id: int, limit: int = 50, offset: int = 0
|
||||
) -> tuple[list[Transaction], int]:
|
||||
"""Fetch paged transactions for a wallet ordered newest-first, plus total count."""
|
||||
count_stmt = select(func.count()).select_from(Transaction).where(Transaction.wallet_id == wallet_id)
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
.where(Transaction.wallet_id == wallet_id)
|
||||
.order_by(Transaction.created_at.desc(), Transaction.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
res = await db.execute(stmt)
|
||||
return list(res.scalars().all()), total
|
||||
|
||||
|
||||
wallet_service = WalletService()
|
||||
@@ -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.
|
||||
@@ -58,6 +60,9 @@ TEST_TABLES = (
|
||||
# (not CASCADE, deliberately -- see groups.py), so it wouldn't be reached
|
||||
# by cascading a truncate of users; list it explicitly for clarity too.
|
||||
"group_members", "groups",
|
||||
# Economy (app/models/wallet.py, app/models/marketplace.py)
|
||||
"payout_requests", "purchases", "marketplace_listings", "catalog_items", "creator_balances",
|
||||
"transactions", "wallets",
|
||||
"sessions", "refresh_tokens", "users",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Integration tests for in-experience world/room chat:
|
||||
- POST /api/chat/world/{world_id}/messages
|
||||
- GET /api/chat/world/{world_id}/messages
|
||||
- POST /api/chat/room/{room_id}/messages
|
||||
- GET /api/chat/room/{room_id}/messages
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _register(client: TestClient, username: str) -> dict:
|
||||
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 {"headers": headers, "id": me.json()["id"], "username": username}
|
||||
|
||||
|
||||
def _make_world(client: TestClient, headers: dict, visibility: str = "public") -> int:
|
||||
r = client.post("/api/worlds/", headers=headers, json={"name": "TestWorld", "visibility": visibility})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _make_friends(client: TestClient, user_a: dict, user_b: dict) -> None:
|
||||
req = client.post("/api/friends/requests", json={"user_id": user_b["id"]}, headers=user_a["headers"])
|
||||
assert req.status_code == 201, req.text
|
||||
req_id = req.json()["id"]
|
||||
acc = client.post(f"/api/friends/requests/{req_id}/accept", headers=user_b["headers"])
|
||||
assert acc.status_code == 200, acc.text
|
||||
|
||||
|
||||
class TestWorldChat:
|
||||
def test_send_and_get_world_message(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "wch_alice1")
|
||||
bob = _register(client, "wch_bob1")
|
||||
|
||||
world_id = _make_world(client, alice["headers"], visibility="public")
|
||||
|
||||
# Bob posts to Alice's public world
|
||||
post_resp = client.post(
|
||||
f"/api/chat/world/{world_id}/messages",
|
||||
headers=bob["headers"],
|
||||
json={"body": "Hello world from Bob!"},
|
||||
)
|
||||
assert post_resp.status_code == 201, post_resp.text
|
||||
msg_data = post_resp.json()
|
||||
assert msg_data["world_id"] == world_id
|
||||
assert msg_data["room_id"] is None
|
||||
assert msg_data["sender_id"] == bob["id"]
|
||||
assert msg_data["body"] == "Hello world from Bob!"
|
||||
assert msg_data["filtered"] is False
|
||||
assert msg_data["author"]["username"] == "wch_bob1"
|
||||
assert msg_data["author"]["id"] == bob["id"]
|
||||
|
||||
# Alice gets world chat history
|
||||
get_resp = client.get(
|
||||
f"/api/chat/world/{world_id}/messages",
|
||||
headers=alice["headers"],
|
||||
)
|
||||
assert get_resp.status_code == 200, get_resp.text
|
||||
history = get_resp.json()
|
||||
assert len(history["messages"]) == 1
|
||||
assert history["messages"][0]["id"] == msg_data["id"]
|
||||
assert history["messages"][0]["author"]["username"] == "wch_bob1"
|
||||
|
||||
def test_room_scoped_messages(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "wch_alice2")
|
||||
world_id = _make_world(client, alice["headers"])
|
||||
|
||||
# Fetch auto-created main room
|
||||
rooms_resp = client.get(f"/api/worlds/{world_id}/rooms", headers=alice["headers"])
|
||||
assert rooms_resp.status_code == 200, rooms_resp.text
|
||||
rooms = rooms_resp.json()
|
||||
assert len(rooms) >= 1
|
||||
room_id = rooms[0]["id"]
|
||||
|
||||
# Post directly via room endpoint
|
||||
post_room = client.post(
|
||||
f"/api/chat/room/{room_id}/messages",
|
||||
headers=alice["headers"],
|
||||
json={"body": "Message in room"},
|
||||
)
|
||||
assert post_room.status_code == 201, post_room.text
|
||||
msg_data = post_room.json()
|
||||
assert msg_data["world_id"] == world_id
|
||||
assert msg_data["room_id"] == room_id
|
||||
assert msg_data["body"] == "Message in room"
|
||||
|
||||
# Get room history
|
||||
get_room = client.get(
|
||||
f"/api/chat/room/{room_id}/messages",
|
||||
headers=alice["headers"],
|
||||
)
|
||||
assert get_room.status_code == 200, get_room.text
|
||||
messages = get_room.json()["messages"]
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["id"] == msg_data["id"]
|
||||
|
||||
# Also visible when querying world history with room_id filter
|
||||
get_world_room = client.get(
|
||||
f"/api/chat/world/{world_id}/messages?room_id={room_id}",
|
||||
headers=alice["headers"],
|
||||
)
|
||||
assert get_world_room.status_code == 200, get_world_room.text
|
||||
assert len(get_world_room.json()["messages"]) == 1
|
||||
|
||||
def test_visibility_access_control(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "wch_alice3")
|
||||
bob = _register(client, "wch_bob3")
|
||||
charlie = _register(client, "wch_charlie3")
|
||||
|
||||
# Private world: only Alice
|
||||
priv_world_id = _make_world(client, alice["headers"], visibility="private")
|
||||
|
||||
# Bob cannot send or read
|
||||
bob_send = client.post(
|
||||
f"/api/chat/world/{priv_world_id}/messages",
|
||||
headers=bob["headers"],
|
||||
json={"body": "Trying to trespass"},
|
||||
)
|
||||
assert bob_send.status_code == 403, bob_send.text
|
||||
|
||||
bob_read = client.get(
|
||||
f"/api/chat/world/{priv_world_id}/messages",
|
||||
headers=bob["headers"],
|
||||
)
|
||||
assert bob_read.status_code == 403, bob_read.text
|
||||
|
||||
# Alice (owner) can send and read
|
||||
alice_send = client.post(
|
||||
f"/api/chat/world/{priv_world_id}/messages",
|
||||
headers=alice["headers"],
|
||||
json={"body": "My private sanctum"},
|
||||
)
|
||||
assert alice_send.status_code == 201, alice_send.text
|
||||
|
||||
# Friends-only world
|
||||
fr_world_id = _make_world(client, alice["headers"], visibility="friends_only")
|
||||
_make_friends(client, alice, bob)
|
||||
|
||||
# Bob (friend) can post
|
||||
bob_fr_send = client.post(
|
||||
f"/api/chat/world/{fr_world_id}/messages",
|
||||
headers=bob["headers"],
|
||||
json={"body": "Hello friend!"},
|
||||
)
|
||||
assert bob_fr_send.status_code == 201, bob_fr_send.text
|
||||
|
||||
# Charlie (not friend) cannot post or read
|
||||
charlie_send = client.post(
|
||||
f"/api/chat/world/{fr_world_id}/messages",
|
||||
headers=charlie["headers"],
|
||||
json={"body": "Can I join?"},
|
||||
)
|
||||
assert charlie_send.status_code == 403, charlie_send.text
|
||||
|
||||
charlie_read = client.get(
|
||||
f"/api/chat/world/{fr_world_id}/messages",
|
||||
headers=charlie["headers"],
|
||||
)
|
||||
assert charlie_read.status_code == 403, charlie_read.text
|
||||
|
||||
def test_not_found_validation(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "wch_alice4")
|
||||
|
||||
# Non-existent world
|
||||
res = client.post(
|
||||
"/api/chat/world/999999/messages",
|
||||
headers=alice["headers"],
|
||||
json={"body": "Ghost world"},
|
||||
)
|
||||
assert res.status_code == 404, res.text
|
||||
|
||||
# Non-existent room
|
||||
world_id = _make_world(client, alice["headers"])
|
||||
res2 = client.post(
|
||||
f"/api/chat/world/{world_id}/messages",
|
||||
headers=alice["headers"],
|
||||
json={"body": "Ghost room", "room_id": 999999},
|
||||
)
|
||||
assert res2.status_code == 404, res2.text
|
||||
|
||||
def test_text_filtering_in_world_chat(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "wch_alice5")
|
||||
world_id = _make_world(client, alice["headers"])
|
||||
|
||||
# Default filter redacts forbidden words
|
||||
res = client.post(
|
||||
f"/api/chat/world/{world_id}/messages",
|
||||
headers=alice["headers"],
|
||||
json={"body": "you are a bastard"},
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
data = res.json()
|
||||
assert data["filtered"] is True
|
||||
assert "bastard" not in data["body"]
|
||||
assert "*" in data["body"]
|
||||
|
||||
def test_pagination_and_next_cursor(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "wch_alice6")
|
||||
world_id = _make_world(client, alice["headers"])
|
||||
|
||||
for i in range(5):
|
||||
r = client.post(
|
||||
f"/api/chat/world/{world_id}/messages",
|
||||
headers=alice["headers"],
|
||||
json={"body": f"Msg {i}"},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
|
||||
page1 = client.get(
|
||||
f"/api/chat/world/{world_id}/messages?limit=2",
|
||||
headers=alice["headers"],
|
||||
).json()
|
||||
assert len(page1["messages"]) == 2
|
||||
assert page1["next_cursor"] is not None
|
||||
assert page1["messages"][0]["body"] == "Msg 4"
|
||||
assert page1["messages"][1]["body"] == "Msg 3"
|
||||
|
||||
page2 = client.get(
|
||||
f"/api/chat/world/{world_id}/messages?limit=2&before={page1['next_cursor']}",
|
||||
headers=alice["headers"],
|
||||
).json()
|
||||
assert len(page2["messages"]) == 2
|
||||
assert page2["messages"][0]["body"] == "Msg 2"
|
||||
assert page2["messages"][1]["body"] == "Msg 1"
|
||||
@@ -0,0 +1,188 @@
|
||||
"""API tests for discovery browse & search endpoints.
|
||||
|
||||
docs/planning/discovery.md (task 300da1dcddf3).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
assert me.status_code == 200, me.text
|
||||
return me.json()["id"], headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_public_worlds_only_and_fields():
|
||||
client = TestClient(app)
|
||||
uid, headers = _register(client, "creator_browse_1")
|
||||
|
||||
# Create public world
|
||||
pub_resp = client.post(
|
||||
"/api/worlds/",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Fantasy Kingdom",
|
||||
"description": "A magical realm",
|
||||
"visibility": "public",
|
||||
"category": "RPG",
|
||||
"tags": ["magic", "dragons"],
|
||||
},
|
||||
)
|
||||
assert pub_resp.status_code == 201, pub_resp.text
|
||||
pub_id = pub_resp.json()["id"]
|
||||
|
||||
# Create private world
|
||||
priv_resp = client.post(
|
||||
"/api/worlds/",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Secret Base",
|
||||
"description": "Classified",
|
||||
"visibility": "private",
|
||||
"category": "RPG",
|
||||
"tags": ["secret"],
|
||||
},
|
||||
)
|
||||
assert priv_resp.status_code == 201, priv_resp.text
|
||||
|
||||
# 1. Anonymous listing: only public world is returned
|
||||
anon_resp = client.get("/api/discovery/worlds")
|
||||
assert anon_resp.status_code == 200, anon_resp.text
|
||||
data = anon_resp.json()
|
||||
assert data["total"] >= 1
|
||||
items = data["items"]
|
||||
ids = [w["id"] for w in items]
|
||||
assert pub_id in ids
|
||||
assert priv_resp.json()["id"] not in ids
|
||||
|
||||
# Check fields of returned item
|
||||
pub_item = next(w for w in items if w["id"] == pub_id)
|
||||
assert pub_item["name"] == "Fantasy Kingdom"
|
||||
assert pub_item["description"] == "A magical realm"
|
||||
assert pub_item["category"] == "RPG"
|
||||
assert "magic" in pub_item["tags"]
|
||||
assert pub_item["play_count"] == 0
|
||||
assert pub_item["owner"]["id"] == uid
|
||||
assert pub_item["owner"]["username"] == "creator_browse_1"
|
||||
|
||||
# 2. Authenticated caller also does NOT see private worlds in discovery
|
||||
auth_resp = client.get("/api/discovery/worlds", headers=headers)
|
||||
assert auth_resp.status_code == 200
|
||||
auth_ids = [w["id"] for w in auth_resp.json()["items"]]
|
||||
assert priv_resp.json()["id"] not in auth_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_filtering_and_categories():
|
||||
client = TestClient(app)
|
||||
uid, headers = _register(client, "creator_filter_2")
|
||||
|
||||
# Create distinct worlds
|
||||
client.post(
|
||||
"/api/worlds/",
|
||||
headers=headers,
|
||||
json={"name": "Cyber Arena", "visibility": "public", "category": "Action", "tags": ["pvp", "fps"]},
|
||||
)
|
||||
client.post(
|
||||
"/api/worlds/",
|
||||
headers=headers,
|
||||
json={"name": "Peaceful Meadow", "visibility": "public", "category": "Social", "tags": ["relax", "hangout"]},
|
||||
)
|
||||
client.post(
|
||||
"/api/worlds/",
|
||||
headers=headers,
|
||||
json={"name": "Cyber Hangout", "visibility": "public", "category": "Social", "tags": ["cyberpunk", "chat"]},
|
||||
)
|
||||
|
||||
# Search by q
|
||||
res_q = client.get("/api/discovery/worlds?q=cyber")
|
||||
assert res_q.status_code == 200
|
||||
names = [w["name"] for w in res_q.json()["items"]]
|
||||
assert "Cyber Arena" in names
|
||||
assert "Cyber Hangout" in names
|
||||
assert "Peaceful Meadow" not in names
|
||||
|
||||
# Filter by category (case-insensitive)
|
||||
res_cat = client.get("/api/discovery/worlds?category=action")
|
||||
assert res_cat.status_code == 200
|
||||
names = [w["name"] for w in res_cat.json()["items"]]
|
||||
assert "Cyber Arena" in names
|
||||
assert "Peaceful Meadow" not in names
|
||||
|
||||
# Filter by tags (overlap)
|
||||
res_tags = client.get("/api/discovery/worlds?tags=relax,pvp")
|
||||
assert res_tags.status_code == 200
|
||||
names = [w["name"] for w in res_tags.json()["items"]]
|
||||
assert "Peaceful Meadow" in names
|
||||
assert "Cyber Arena" in names
|
||||
assert "Cyber Hangout" not in names
|
||||
|
||||
# Get distinct categories
|
||||
res_cats = client.get("/api/discovery/categories")
|
||||
assert res_cats.status_code == 200
|
||||
cats = res_cats.json()
|
||||
assert "Action" in cats
|
||||
assert "Social" in cats
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_sorting_and_pagination():
|
||||
client = TestClient(app)
|
||||
uid, headers = _register(client, "creator_sort_3")
|
||||
|
||||
w1 = client.post(
|
||||
"/api/worlds/",
|
||||
headers=headers,
|
||||
json={"name": "Sort World 1", "visibility": "public"},
|
||||
).json()
|
||||
w2 = client.post(
|
||||
"/api/worlds/",
|
||||
headers=headers,
|
||||
json={"name": "Sort World 2", "visibility": "public"},
|
||||
).json()
|
||||
|
||||
# Record visits on w1 so play_count = 1
|
||||
v_res = client.post(f"/api/discovery/worlds/{w1['id']}/visit")
|
||||
assert v_res.status_code == 200
|
||||
|
||||
# Sort by most_played
|
||||
res_mp = client.get("/api/discovery/worlds?sort=most_played")
|
||||
assert res_mp.status_code == 200
|
||||
items = res_mp.json()["items"]
|
||||
# w1 should come before w2
|
||||
idx_w1 = next(i for i, w in enumerate(items) if w["id"] == w1["id"])
|
||||
idx_w2 = next(i for i, w in enumerate(items) if w["id"] == w2["id"])
|
||||
assert idx_w1 < idx_w2
|
||||
|
||||
# Sort by newest (default)
|
||||
res_new = client.get("/api/discovery/worlds?sort=newest")
|
||||
assert res_new.status_code == 200
|
||||
items_new = res_new.json()["items"]
|
||||
idx_w1_n = next(i for i, w in enumerate(items_new) if w["id"] == w1["id"])
|
||||
idx_w2_n = next(i for i, w in enumerate(items_new) if w["id"] == w2["id"])
|
||||
# w2 was created after w1, so w2 is newer
|
||||
assert idx_w2_n < idx_w1_n
|
||||
|
||||
# Pagination: limit 1, skip 0
|
||||
p1 = client.get("/api/discovery/worlds?limit=1&skip=0")
|
||||
assert p1.status_code == 200
|
||||
assert len(p1.json()["items"]) == 1
|
||||
assert p1.json()["total"] >= 2
|
||||
assert p1.json()["skip"] == 0
|
||||
assert p1.json()["limit"] == 1
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Model-level integration tests for Discovery metadata and WorldVisit.
|
||||
|
||||
docs/planning/discovery.md (task b377353b8167).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.models.user import User
|
||||
from app.models.world import World, WorldVisibility
|
||||
from app.models.world_visit import WorldVisit
|
||||
|
||||
|
||||
def _register(client: TestClient, username: str) -> int:
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
assert me.status_code == 200, me.text
|
||||
return me.json()["id"]
|
||||
|
||||
|
||||
def _session_factory():
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
return engine, async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_world_discovery_metadata_and_visits():
|
||||
client = TestClient(app)
|
||||
user_id = _register(client, "discoverer_1")
|
||||
visitor_id = _register(client, "visitor_1")
|
||||
|
||||
engine, sf = _session_factory()
|
||||
try:
|
||||
async with sf() as session:
|
||||
# 1. Create a world with discovery metadata
|
||||
world = World(
|
||||
name="Cyber Adventure",
|
||||
description="An epic adventure world",
|
||||
owner_id=user_id,
|
||||
visibility=WorldVisibility.PUBLIC,
|
||||
category="Adventure",
|
||||
tags=["cyberpunk", "rpg", "quest"],
|
||||
)
|
||||
session.add(world)
|
||||
await session.commit()
|
||||
await session.refresh(world)
|
||||
|
||||
assert world.id is not None
|
||||
assert world.category == "Adventure"
|
||||
assert world.tags == ["cyberpunk", "rpg", "quest"]
|
||||
assert world.play_count == 0
|
||||
assert world.last_played_at is None
|
||||
|
||||
# 2. Add authenticated visit
|
||||
visit1 = WorldVisit(
|
||||
world_id=world.id,
|
||||
user_id=visitor_id,
|
||||
)
|
||||
# 3. Add anonymous visit
|
||||
visit2 = WorldVisit(
|
||||
world_id=world.id,
|
||||
user_id=None,
|
||||
anonymous_token="anon_token_12345",
|
||||
)
|
||||
session.add_all([visit1, visit2])
|
||||
await session.commit()
|
||||
|
||||
# Query world with visits
|
||||
res = await session.execute(
|
||||
select(World).where(World.id == world.id).options(selectinload(World.visits))
|
||||
)
|
||||
w = res.scalar_one()
|
||||
assert len(w.visits) == 2
|
||||
v_user = next(v for v in w.visits if v.user_id == visitor_id)
|
||||
assert v_user.visited_at is not None
|
||||
v_anon = next(v for v in w.visits if v.user_id is None)
|
||||
assert v_anon.anonymous_token == "anon_token_12345"
|
||||
|
||||
# 4. Verify cascade delete on world
|
||||
async with sf() as session:
|
||||
res = await session.execute(select(World).where(World.id == world.id))
|
||||
w = res.scalar_one()
|
||||
await session.delete(w)
|
||||
await session.commit()
|
||||
|
||||
# Visits should be gone
|
||||
res_v = await session.execute(select(WorldVisit).where(WorldVisit.world_id == world.id))
|
||||
assert len(res_v.scalars().all()) == 0
|
||||
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_delete_sets_null_on_world_visits():
|
||||
client = TestClient(app)
|
||||
owner_id = _register(client, "world_owner_2")
|
||||
visitor_id = _register(client, "temp_visitor_2")
|
||||
|
||||
engine, sf = _session_factory()
|
||||
try:
|
||||
async with sf() as session:
|
||||
world = World(
|
||||
name="Chill Lobby",
|
||||
owner_id=owner_id,
|
||||
visibility=WorldVisibility.PUBLIC,
|
||||
category="Social",
|
||||
tags=["hangout"],
|
||||
)
|
||||
session.add(world)
|
||||
await session.commit()
|
||||
await session.refresh(world)
|
||||
|
||||
visit = WorldVisit(world_id=world.id, user_id=visitor_id)
|
||||
session.add(visit)
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
visit_id = visit.id
|
||||
|
||||
# Delete visitor user
|
||||
visitor_user = await session.get(User, visitor_id)
|
||||
await session.delete(visitor_user)
|
||||
await session.commit()
|
||||
|
||||
# In a new session, query from database to see ON DELETE SET NULL effect
|
||||
async with sf() as session:
|
||||
v = await session.get(WorldVisit, visit_id)
|
||||
assert v is not None
|
||||
assert v.user_id is None
|
||||
assert v.world_id == world.id
|
||||
|
||||
finally:
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""API and heuristic tests for trending discovery sort.
|
||||
|
||||
docs/planning/discovery.md (task 33706f401ad9).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.models.world_visit import WorldVisit
|
||||
|
||||
|
||||
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
assert me.status_code == 200, me.text
|
||||
return me.json()["id"], headers
|
||||
|
||||
|
||||
def _session_factory():
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
return engine, async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trending_sort_heuristic_and_zero_visits():
|
||||
client = TestClient(app)
|
||||
uid, headers = _register(client, "creator_trending_1")
|
||||
|
||||
# Create 3 worlds
|
||||
wA = client.post(
|
||||
"/api/worlds/",
|
||||
headers=headers,
|
||||
json={"name": "Trending Hot World", "visibility": "public", "category": "Action"},
|
||||
).json()
|
||||
wB = client.post(
|
||||
"/api/worlds/",
|
||||
headers=headers,
|
||||
json={"name": "Older Mild World", "visibility": "public", "category": "Action"},
|
||||
).json()
|
||||
wC = client.post(
|
||||
"/api/worlds/",
|
||||
headers=headers,
|
||||
json={"name": "Zero Visit World", "visibility": "public", "category": "Action"},
|
||||
).json()
|
||||
|
||||
engine, sf = _session_factory()
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with sf() as session:
|
||||
# World A: 3 visits within last 2 hours (weight 3 each = score 9)
|
||||
for i in range(3):
|
||||
session.add(
|
||||
WorldVisit(
|
||||
world_id=wA["id"],
|
||||
anonymous_token=f"hot_visitor_{i}",
|
||||
visited_at=now - timedelta(hours=1, minutes=i),
|
||||
)
|
||||
)
|
||||
# World B: 1 visit 5 days ago (weight 1 = score 1)
|
||||
session.add(
|
||||
WorldVisit(
|
||||
world_id=wB["id"],
|
||||
anonymous_token="old_visitor",
|
||||
visited_at=now - timedelta(days=5),
|
||||
)
|
||||
)
|
||||
# World C: zero visits
|
||||
await session.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
# Query with sort=trending
|
||||
resp = client.get("/api/discovery/worlds?sort=trending&category=Action")
|
||||
assert resp.status_code == 200, resp.text
|
||||
items = resp.json()["items"]
|
||||
|
||||
# Verify order: wA should be first, wB second, wC third
|
||||
ids = [item["id"] for item in items]
|
||||
assert wA["id"] in ids
|
||||
assert wB["id"] in ids
|
||||
assert wC["id"] in ids
|
||||
|
||||
idxA = ids.index(wA["id"])
|
||||
idxB = ids.index(wB["id"])
|
||||
idxC = ids.index(wC["id"])
|
||||
|
||||
assert idxA < idxB < idxC, f"Expected order A ({idxA}) < B ({idxB}) < C ({idxC})"
|
||||
@@ -0,0 +1,164 @@
|
||||
"""API tests for world visit tracking endpoint.
|
||||
|
||||
docs/planning/discovery.md (task 42726e60fe10).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.models.world import World, WorldVisibility
|
||||
from app.models.world_visit import WorldVisit
|
||||
|
||||
|
||||
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
assert me.status_code == 200, me.text
|
||||
return me.json()["id"], headers
|
||||
|
||||
|
||||
def _session_factory():
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
return engine, async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visit_world_atomic_increment_authenticated():
|
||||
client = TestClient(app)
|
||||
owner_id, owner_headers = _register(client, "visit_owner_1")
|
||||
visitor_id, visitor_headers = _register(client, "visitor_user_1")
|
||||
|
||||
# Create world
|
||||
resp = client.post(
|
||||
"/api/worlds/",
|
||||
headers=owner_headers,
|
||||
json={"name": "Neon City", "visibility": "public", "category": "Roleplay"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
world_id = resp.json()["id"]
|
||||
|
||||
# Visitor visits the world
|
||||
v_resp = client.post(
|
||||
f"/api/discovery/worlds/{world_id}/visit",
|
||||
headers=visitor_headers,
|
||||
)
|
||||
assert v_resp.status_code == 200, v_resp.text
|
||||
data = v_resp.json()
|
||||
assert data["play_count"] == 1
|
||||
assert data["last_played_at"] is not None
|
||||
|
||||
# Verify DB state
|
||||
engine, sf = _session_factory()
|
||||
try:
|
||||
async with sf() as session:
|
||||
w = (await session.execute(select(World).where(World.id == world_id))).scalar_one()
|
||||
assert w.play_count == 1
|
||||
assert w.last_played_at is not None
|
||||
|
||||
visits = (
|
||||
await session.execute(
|
||||
select(WorldVisit).where(WorldVisit.world_id == world_id)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(visits) == 1
|
||||
assert visits[0].user_id == visitor_id
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visit_world_anonymous_and_idempotency():
|
||||
client = TestClient(app)
|
||||
owner_id, owner_headers = _register(client, "visit_owner_2")
|
||||
|
||||
resp = client.post(
|
||||
"/api/worlds/",
|
||||
headers=owner_headers,
|
||||
json={"name": "Space Station", "visibility": "public"},
|
||||
)
|
||||
world_id = resp.json()["id"]
|
||||
|
||||
# Anonymous visit with token
|
||||
anon_token = "guest_token_999"
|
||||
v_resp1 = client.post(
|
||||
f"/api/discovery/worlds/{world_id}/visit",
|
||||
json={"anonymous_token": anon_token},
|
||||
)
|
||||
assert v_resp1.status_code == 200, v_resp1.text
|
||||
assert v_resp1.json()["play_count"] == 1
|
||||
|
||||
# Idempotent second call with same anonymous token
|
||||
v_resp2 = client.post(
|
||||
f"/api/discovery/worlds/{world_id}/visit",
|
||||
json={"anonymous_token": anon_token},
|
||||
)
|
||||
assert v_resp2.status_code == 200, v_resp2.text
|
||||
assert v_resp2.json()["play_count"] == 1
|
||||
|
||||
engine, sf = _session_factory()
|
||||
try:
|
||||
async with sf() as session:
|
||||
visits = (
|
||||
await session.execute(
|
||||
select(WorldVisit).where(WorldVisit.world_id == world_id)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(visits) == 1
|
||||
assert visits[0].user_id is None
|
||||
assert visits[0].anonymous_token == anon_token
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visit_world_visibility_and_404():
|
||||
client = TestClient(app)
|
||||
owner_id, owner_headers = _register(client, "visit_owner_3")
|
||||
other_id, other_headers = _register(client, "other_user_3")
|
||||
|
||||
# 404 on non-existent world
|
||||
res_404 = client.post("/api/discovery/worlds/999999/visit", headers=owner_headers)
|
||||
assert res_404.status_code == 404
|
||||
|
||||
# Private world
|
||||
resp = client.post(
|
||||
"/api/worlds/",
|
||||
headers=owner_headers,
|
||||
json={"name": "Secret World", "visibility": "private"},
|
||||
)
|
||||
priv_world_id = resp.json()["id"]
|
||||
|
||||
# Other user gets 404
|
||||
other_res = client.post(
|
||||
f"/api/discovery/worlds/{priv_world_id}/visit",
|
||||
headers=other_headers,
|
||||
)
|
||||
assert other_res.status_code == 404
|
||||
|
||||
# Anonymous user gets 404
|
||||
anon_res = client.post(f"/api/discovery/worlds/{priv_world_id}/visit")
|
||||
assert anon_res.status_code == 404
|
||||
|
||||
# Owner gets 200
|
||||
owner_res = client.post(
|
||||
f"/api/discovery/worlds/{priv_world_id}/visit",
|
||||
headers=owner_headers,
|
||||
)
|
||||
assert owner_res.status_code == 200
|
||||
assert owner_res.json()["play_count"] == 1
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Integration tests for Marketplace Listings API.
|
||||
|
||||
docs/planning/economy.md US-3 (task f51c7d95e237).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
assert me.status_code == 200, me.text
|
||||
return me.json()["id"], headers
|
||||
|
||||
|
||||
def test_catalog_items_and_listings_crud():
|
||||
client = TestClient(app)
|
||||
uid1, h1 = _register(client, "mkt_user_1")
|
||||
uid2, h2 = _register(client, "mkt_user_2")
|
||||
|
||||
# 1. Create catalog item by user 1
|
||||
resp = client.post(
|
||||
"/api/marketplace/items",
|
||||
headers=h1,
|
||||
json={
|
||||
"name": "Wizard Hat",
|
||||
"description": "A pointy blue hat with stars",
|
||||
"item_type": "avatar_item",
|
||||
"metadata_json": {"color": "blue", "rarity": "rare"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
item_data = resp.json()
|
||||
assert item_data["name"] == "Wizard Hat"
|
||||
assert item_data["owner_id"] == uid1
|
||||
item_id = item_data["id"]
|
||||
|
||||
# 2. List catalog items
|
||||
resp = client.get("/api/marketplace/items", headers=h1, params={"mine": "true"})
|
||||
assert resp.status_code == 200
|
||||
my_items = resp.json()
|
||||
assert len(my_items) >= 1
|
||||
assert any(i["id"] == item_id for i in my_items)
|
||||
|
||||
resp = client.get("/api/marketplace/items", headers=h2, params={"mine": "true"})
|
||||
assert resp.status_code == 200
|
||||
assert not any(i["id"] == item_id for i in resp.json())
|
||||
|
||||
# 3. User 2 tries to list User 1's item -> 403
|
||||
resp = client.post(
|
||||
"/api/marketplace/listings",
|
||||
headers=h2,
|
||||
json={
|
||||
"catalog_item_id": item_id,
|
||||
"price": 100,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403, resp.text
|
||||
|
||||
# Invalid price <= 0 -> 422
|
||||
resp = client.post(
|
||||
"/api/marketplace/listings",
|
||||
headers=h1,
|
||||
json={
|
||||
"catalog_item_id": item_id,
|
||||
"price": 0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
# 4. User 1 successfully lists item
|
||||
resp = client.post(
|
||||
"/api/marketplace/listings",
|
||||
headers=h1,
|
||||
json={
|
||||
"catalog_item_id": item_id,
|
||||
"price": 250,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
listing = resp.json()
|
||||
assert listing["price"] == 250
|
||||
assert listing["seller_id"] == uid1
|
||||
assert listing["status"] == "active"
|
||||
assert listing["catalog_item"]["name"] == "Wizard Hat"
|
||||
listing_id = listing["id"]
|
||||
|
||||
# 5. Search / browse listings
|
||||
resp = client.get("/api/marketplace/listings", params={"q": "Wizard"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
assert any(l["id"] == listing_id for l in data["listings"])
|
||||
|
||||
# Filter by item_type
|
||||
resp = client.get("/api/marketplace/listings", params={"item_type": "avatar_item"})
|
||||
assert resp.status_code == 200
|
||||
assert any(l["id"] == listing_id for l in resp.json()["listings"])
|
||||
|
||||
resp = client.get("/api/marketplace/listings", params={"item_type": "experience_pass"})
|
||||
assert resp.status_code == 200
|
||||
assert not any(l["id"] == listing_id for l in resp.json()["listings"])
|
||||
|
||||
# Price range filter
|
||||
resp = client.get("/api/marketplace/listings", params={"min_price": 300})
|
||||
assert resp.status_code == 200
|
||||
assert not any(l["id"] == listing_id for l in resp.json()["listings"])
|
||||
|
||||
resp = client.get("/api/marketplace/listings", params={"max_price": 260})
|
||||
assert resp.status_code == 200
|
||||
assert any(l["id"] == listing_id for l in resp.json()["listings"])
|
||||
|
||||
# 6. Listing detail
|
||||
resp = client.get(f"/api/marketplace/listings/{listing_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == listing_id
|
||||
|
||||
# 7. Update listing
|
||||
# User 2 cannot update User 1's listing
|
||||
resp = client.patch(
|
||||
f"/api/marketplace/listings/{listing_id}",
|
||||
headers=h2,
|
||||
json={"price": 500},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
# User 1 updates price
|
||||
resp = client.patch(
|
||||
f"/api/marketplace/listings/{listing_id}",
|
||||
headers=h1,
|
||||
json={"price": 350},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["price"] == 350
|
||||
|
||||
# 8. Delist listing
|
||||
# User 2 cannot delist
|
||||
resp = client.delete(f"/api/marketplace/listings/{listing_id}", headers=h2)
|
||||
assert resp.status_code == 403
|
||||
|
||||
# User 1 delists
|
||||
resp = client.delete(f"/api/marketplace/listings/{listing_id}", headers=h1)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Now detail shows delisted
|
||||
resp = client.get(f"/api/marketplace/listings/{listing_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "delisted"
|
||||
|
||||
# Default search only shows active
|
||||
resp = client.get("/api/marketplace/listings")
|
||||
assert resp.status_code == 200
|
||||
assert not any(l["id"] == listing_id for l in resp.json()["listings"])
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Model-level integration tests for Marketplace tables.
|
||||
|
||||
docs/planning/economy.md US-3, US-4 (task 166836d55365).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.models.marketplace import (
|
||||
CatalogItem,
|
||||
CreatorBalance,
|
||||
ItemType,
|
||||
ListingStatus,
|
||||
MarketplaceListing,
|
||||
Purchase,
|
||||
PurchaseStatus,
|
||||
)
|
||||
|
||||
|
||||
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_catalog_item_and_listing_creation():
|
||||
client = TestClient(app)
|
||||
seller_id = _register(client, "market_seller_1")
|
||||
engine, sf = _session_factory()
|
||||
try:
|
||||
async with sf() as session:
|
||||
item = CatalogItem(
|
||||
owner_id=seller_id,
|
||||
item_type=ItemType.avatar_item,
|
||||
name="Cyber Wings",
|
||||
description="Glow in the dark wings",
|
||||
metadata_json={"slot": "back"},
|
||||
)
|
||||
session.add(item)
|
||||
await session.commit()
|
||||
await session.refresh(item)
|
||||
|
||||
assert item.id is not None
|
||||
assert item.name == "Cyber Wings"
|
||||
|
||||
listing = MarketplaceListing(
|
||||
catalog_item_id=item.id,
|
||||
seller_id=seller_id,
|
||||
price=250,
|
||||
status=ListingStatus.active,
|
||||
)
|
||||
session.add(listing)
|
||||
await session.commit()
|
||||
await session.refresh(listing)
|
||||
|
||||
assert listing.id is not None
|
||||
assert listing.price == 250
|
||||
assert listing.status == ListingStatus.active
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_listing_price_positive_constraint():
|
||||
client = TestClient(app)
|
||||
seller_id = _register(client, "market_seller_2")
|
||||
engine, sf = _session_factory()
|
||||
try:
|
||||
async with sf() as session:
|
||||
item = CatalogItem(
|
||||
owner_id=seller_id,
|
||||
item_type=ItemType.asset,
|
||||
name="Free Prop",
|
||||
)
|
||||
session.add(item)
|
||||
await session.commit()
|
||||
await session.refresh(item)
|
||||
|
||||
bad_listing = MarketplaceListing(
|
||||
catalog_item_id=item.id,
|
||||
seller_id=seller_id,
|
||||
price=0, # Must be > 0
|
||||
)
|
||||
session.add(bad_listing)
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creator_balance_uniqueness_and_constraints():
|
||||
client = TestClient(app)
|
||||
creator_id = _register(client, "market_creator_1")
|
||||
engine, sf = _session_factory()
|
||||
try:
|
||||
async with sf() as session:
|
||||
cb1 = CreatorBalance(user_id=creator_id, available=100, pending=50, lifetime_earned=150)
|
||||
session.add(cb1)
|
||||
await session.commit()
|
||||
await session.refresh(cb1)
|
||||
|
||||
assert cb1.available == 100
|
||||
assert cb1.pending == 50
|
||||
assert cb1.lifetime_earned == 150
|
||||
|
||||
# Unique user_id constraint
|
||||
async with sf() as session:
|
||||
cb2 = CreatorBalance(user_id=creator_id)
|
||||
session.add(cb2)
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_record_creation():
|
||||
client = TestClient(app)
|
||||
seller_id = _register(client, "market_seller_3")
|
||||
buyer_id = _register(client, "market_buyer_3")
|
||||
engine, sf = _session_factory()
|
||||
try:
|
||||
async with sf() as session:
|
||||
item = CatalogItem(owner_id=seller_id, item_type=ItemType.asset, name="Sword Prop")
|
||||
session.add(item)
|
||||
await session.commit()
|
||||
await session.refresh(item)
|
||||
|
||||
listing = MarketplaceListing(catalog_item_id=item.id, seller_id=seller_id, price=100)
|
||||
session.add(listing)
|
||||
await session.commit()
|
||||
await session.refresh(listing)
|
||||
|
||||
purchase = Purchase(
|
||||
listing_id=listing.id,
|
||||
buyer_id=buyer_id,
|
||||
seller_id=seller_id,
|
||||
price=100,
|
||||
platform_fee=30,
|
||||
seller_net=70,
|
||||
status=PurchaseStatus.pending_escrow,
|
||||
)
|
||||
session.add(purchase)
|
||||
await session.commit()
|
||||
await session.refresh(purchase)
|
||||
|
||||
assert purchase.id is not None
|
||||
assert purchase.seller_net == 70
|
||||
assert purchase.status == PurchaseStatus.pending_escrow
|
||||
finally:
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Integration tests for Marketplace Moderation, Content Flags, and Age Gate Enforcement.
|
||||
|
||||
docs/planning/economy.md US-3, Task 9 (task 4ba24a178c1b).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _register(
|
||||
client: TestClient, username: str, dob: str = "1990-01-01"
|
||||
) -> tuple[int, dict[str, str]]:
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": dob,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
assert me.status_code == 200, me.text
|
||||
return me.json()["id"], headers
|
||||
|
||||
|
||||
async def _set_user_dob(user_id: int, dob: date):
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
assert user is not None
|
||||
user.date_of_birth = dob
|
||||
await session.commit()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_marketplace_age_gate_enforcement():
|
||||
client = TestClient(app)
|
||||
seller_id, seller_headers = _register(client, "mature_seller_1")
|
||||
# Register minor and set DOB in DB to 2015
|
||||
minor_id, minor_headers = _register(client, "minor_buyer_1")
|
||||
await _set_user_dob(minor_id, date(2015, 6, 1))
|
||||
|
||||
# Adult user
|
||||
adult_id, adult_headers = _register(client, "adult_buyer_1")
|
||||
|
||||
# Fund minor and adult with 500 Credits
|
||||
for h in (minor_headers, adult_headers):
|
||||
s_resp = client.post("/api/payments/purchase", headers=h, json={"credits": 500})
|
||||
client.post("/api/payments/confirm", headers=h, json={"session_id": s_resp.json()["session_id"]})
|
||||
|
||||
# Seller creates mature item
|
||||
item_resp = client.post(
|
||||
"/api/marketplace/items",
|
||||
headers=seller_headers,
|
||||
json={
|
||||
"name": "Blood Sword",
|
||||
"item_type": "avatar_item",
|
||||
"metadata_json": {"is_mature": True, "content_flags": ["violence.gore"]},
|
||||
},
|
||||
)
|
||||
assert item_resp.status_code == 201
|
||||
item_id = item_resp.json()["id"]
|
||||
|
||||
list_resp = client.post(
|
||||
"/api/marketplace/listings",
|
||||
headers=seller_headers,
|
||||
json={"catalog_item_id": item_id, "price": 100},
|
||||
)
|
||||
assert list_resp.status_code == 201
|
||||
listing_id = list_resp.json()["id"]
|
||||
|
||||
# 1. Minor attempts to purchase mature item -> 403 Forbidden
|
||||
minor_buy = client.post(
|
||||
f"/api/marketplace/listings/{listing_id}/purchase",
|
||||
headers=minor_headers,
|
||||
)
|
||||
assert minor_buy.status_code == 403
|
||||
assert "MATURE_CONTENT_REQUIRES_VERIFICATION" in minor_buy.text
|
||||
|
||||
# 2. Adult purchases mature item -> 201 Created
|
||||
adult_buy = client.post(
|
||||
f"/api/marketplace/listings/{listing_id}/purchase",
|
||||
headers=adult_headers,
|
||||
)
|
||||
assert adult_buy.status_code == 201, adult_buy.text
|
||||
assert adult_buy.json()["status"] == "pending_escrow"
|
||||
|
||||
|
||||
def test_moderator_delist_remove_and_reversal():
|
||||
client = TestClient(app)
|
||||
mod_id, mod_headers = _register(client, "admin_mkt_mod_1", dob="1980-01-01")
|
||||
seller_id, seller_headers = _register(client, "seller_mod_test", dob="1990-01-01")
|
||||
buyer_id, buyer_headers = _register(client, "buyer_mod_test", dob="1990-01-01")
|
||||
|
||||
# Fund buyer
|
||||
s_resp = client.post("/api/payments/purchase", headers=buyer_headers, json={"credits": 500})
|
||||
client.post("/api/payments/confirm", headers=buyer_headers, json={"session_id": s_resp.json()["session_id"]})
|
||||
|
||||
# Create listing 1
|
||||
i1 = client.post(
|
||||
"/api/marketplace/items",
|
||||
headers=seller_headers,
|
||||
json={"name": "Violating Item", "item_type": "avatar_item"},
|
||||
).json()
|
||||
l1 = client.post(
|
||||
"/api/marketplace/listings",
|
||||
headers=seller_headers,
|
||||
json={"catalog_item_id": i1["id"], "price": 100},
|
||||
).json()
|
||||
l1_id = l1["id"]
|
||||
|
||||
# Non-moderator cannot moderate listing -> 403
|
||||
unauth = client.post(
|
||||
f"/api/marketplace/listings/{l1_id}/moderate",
|
||||
headers=buyer_headers,
|
||||
json={"action": "remove", "reason": "Copyright infringement"},
|
||||
)
|
||||
assert unauth.status_code == 403
|
||||
|
||||
# Moderator removes listing
|
||||
mod_resp = client.post(
|
||||
f"/api/marketplace/listings/{l1_id}/moderate",
|
||||
headers=mod_headers,
|
||||
json={"action": "remove", "reason": "Copyright infringement"},
|
||||
)
|
||||
assert mod_resp.status_code == 200, mod_resp.text
|
||||
assert mod_resp.json()["status"] == "removed_by_moderation"
|
||||
|
||||
# Removed listing cannot be purchased
|
||||
buy_fail = client.post(
|
||||
f"/api/marketplace/listings/{l1_id}/purchase",
|
||||
headers=buyer_headers,
|
||||
)
|
||||
assert buy_fail.status_code == 400
|
||||
|
||||
# Create listing 2 and purchase it
|
||||
i2 = client.post(
|
||||
"/api/marketplace/items",
|
||||
headers=seller_headers,
|
||||
json={"name": "Contested Item", "item_type": "avatar_item"},
|
||||
).json()
|
||||
l2 = client.post(
|
||||
"/api/marketplace/listings",
|
||||
headers=seller_headers,
|
||||
json={"catalog_item_id": i2["id"], "price": 150},
|
||||
).json()
|
||||
p2 = client.post(
|
||||
f"/api/marketplace/listings/{l2['id']}/purchase",
|
||||
headers=buyer_headers,
|
||||
).json()
|
||||
p2_id = p2["id"]
|
||||
|
||||
# Buyer balance is 350
|
||||
w = client.get("/api/wallet", headers=buyer_headers).json()
|
||||
assert w["balance"] == 350
|
||||
|
||||
# Non-moderator cannot reverse purchase -> 403
|
||||
unauth_rev = client.post(
|
||||
f"/api/marketplace/purchases/{p2_id}/reverse",
|
||||
headers=buyer_headers,
|
||||
json={"reason": "Buyer regret"},
|
||||
)
|
||||
assert unauth_rev.status_code == 403
|
||||
|
||||
# Moderator reverses purchase
|
||||
rev_resp = client.post(
|
||||
f"/api/marketplace/purchases/{p2_id}/reverse",
|
||||
headers=mod_headers,
|
||||
json={"reason": "Fraudulent listing detected"},
|
||||
)
|
||||
assert rev_resp.status_code == 200, rev_resp.text
|
||||
assert rev_resp.json()["status"] == "reversed"
|
||||
|
||||
# Buyer balance restored to 500
|
||||
w_after = client.get("/api/wallet", headers=buyer_headers).json()
|
||||
assert w_after["balance"] == 500
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Integration tests for Marketplace Purchase Service, Escrow, and Fee Splitting.
|
||||
|
||||
docs/planning/economy.md US-4 (task bc90d3f481b4).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.services.purchase import purchase_service
|
||||
|
||||
|
||||
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 test_fee_split_calculation():
|
||||
# 30% platform fee
|
||||
fee, net = purchase_service.compute_fee_split(100)
|
||||
assert fee == 30
|
||||
assert net == 70
|
||||
|
||||
fee, net = purchase_service.compute_fee_split(250)
|
||||
assert fee == 75
|
||||
assert net == 175
|
||||
|
||||
|
||||
def test_marketplace_purchase_atomic_flow():
|
||||
client = TestClient(app)
|
||||
seller_id, seller_headers = _register(client, "purchase_seller_1")
|
||||
buyer_id, buyer_headers = _register(client, "purchase_buyer_1")
|
||||
|
||||
# 1. Seller creates catalog item and lists it for 200 Credits
|
||||
item_resp = client.post(
|
||||
"/api/marketplace/items",
|
||||
headers=seller_headers,
|
||||
json={
|
||||
"name": "Dragon Armor",
|
||||
"description": "Legendary chestplate",
|
||||
"item_type": "avatar_item",
|
||||
},
|
||||
)
|
||||
assert item_resp.status_code == 201, item_resp.text
|
||||
item_id = item_resp.json()["id"]
|
||||
|
||||
list_resp = client.post(
|
||||
"/api/marketplace/listings",
|
||||
headers=seller_headers,
|
||||
json={
|
||||
"catalog_item_id": item_id,
|
||||
"price": 200,
|
||||
},
|
||||
)
|
||||
assert list_resp.status_code == 201, list_resp.text
|
||||
listing_id = list_resp.json()["id"]
|
||||
|
||||
# 2. Seller cannot purchase own listing
|
||||
self_buy = client.post(
|
||||
f"/api/marketplace/listings/{listing_id}/purchase",
|
||||
headers=seller_headers,
|
||||
)
|
||||
assert self_buy.status_code == 400
|
||||
assert "own listing" in self_buy.text
|
||||
|
||||
# 3. Buyer attempts purchase without enough credits -> 400
|
||||
poor_buy = client.post(
|
||||
f"/api/marketplace/listings/{listing_id}/purchase",
|
||||
headers=buyer_headers,
|
||||
)
|
||||
assert poor_buy.status_code == 400
|
||||
assert "Insufficient funds" in poor_buy.text
|
||||
|
||||
# 4. Buyer purchases 500 Credits via payments API
|
||||
sess_resp = client.post(
|
||||
"/api/payments/purchase",
|
||||
headers=buyer_headers,
|
||||
json={"credits": 500},
|
||||
)
|
||||
assert sess_resp.status_code == 201, sess_resp.text
|
||||
sess_id = sess_resp.json()["session_id"]
|
||||
|
||||
conf_resp = client.post(
|
||||
"/api/payments/confirm",
|
||||
headers=buyer_headers,
|
||||
json={"session_id": sess_id},
|
||||
)
|
||||
assert conf_resp.status_code == 200, conf_resp.text
|
||||
assert conf_resp.json()["new_balance"] == 500
|
||||
|
||||
# 5. Buyer purchases listing for 200 Credits
|
||||
buy_headers = dict(buyer_headers)
|
||||
buy_headers["Idempotency-Key"] = "buy-test-key-1"
|
||||
buy_resp = client.post(
|
||||
f"/api/marketplace/listings/{listing_id}/purchase",
|
||||
headers=buy_headers,
|
||||
)
|
||||
assert buy_resp.status_code == 201, buy_resp.text
|
||||
purchase_data = buy_resp.json()
|
||||
assert purchase_data["price"] == 200
|
||||
assert purchase_data["platform_fee"] == 60
|
||||
assert purchase_data["seller_net"] == 140
|
||||
assert purchase_data["status"] == "pending_escrow"
|
||||
assert purchase_data["buyer_id"] == buyer_id
|
||||
assert purchase_data["seller_id"] == seller_id
|
||||
purchase_id = purchase_data["id"]
|
||||
|
||||
# 6. Check buyer wallet balance: 500 - 200 = 300
|
||||
w_resp = client.get("/api/wallet", headers=buyer_headers)
|
||||
assert w_resp.status_code == 200
|
||||
assert w_resp.json()["balance"] == 300
|
||||
|
||||
# 7. Check buyer has granted item in inventory
|
||||
inv_resp = client.get("/api/marketplace/items", headers=buyer_headers, params={"mine": "true"})
|
||||
assert inv_resp.status_code == 200
|
||||
my_items = inv_resp.json()
|
||||
assert any(i["name"] == "Dragon Armor" and i["owner_id"] == buyer_id for i in my_items)
|
||||
|
||||
# 8. Listing is now sold and cannot be purchased again
|
||||
re_buy = client.post(
|
||||
f"/api/marketplace/listings/{listing_id}/purchase",
|
||||
headers=buyer_headers,
|
||||
)
|
||||
assert re_buy.status_code == 400
|
||||
assert "not active" in re_buy.text
|
||||
|
||||
# 9. Seller's creator balance is pending escrow
|
||||
cb_resp = client.get("/api/marketplace/creator/balance", headers=seller_headers)
|
||||
assert cb_resp.status_code == 200, cb_resp.text
|
||||
cb = cb_resp.json()
|
||||
assert cb["pending"] == 140
|
||||
assert cb["available"] == 0
|
||||
|
||||
# 10. List purchases
|
||||
p_resp = client.get("/api/marketplace/purchases", headers=buyer_headers)
|
||||
assert p_resp.status_code == 200
|
||||
assert any(p["id"] == purchase_id for p in p_resp.json())
|
||||
|
||||
# 11. Escrow release
|
||||
rel_resp = client.post(
|
||||
f"/api/marketplace/purchases/{purchase_id}/release",
|
||||
headers=seller_headers,
|
||||
)
|
||||
assert rel_resp.status_code == 200, rel_resp.text
|
||||
assert rel_resp.json()["status"] == "completed"
|
||||
|
||||
# Check seller's creator balance now shows available = 140
|
||||
cb_resp2 = client.get("/api/marketplace/creator/balance", headers=seller_headers)
|
||||
assert cb_resp2.status_code == 200
|
||||
cb2 = cb_resp2.json()
|
||||
assert cb2["pending"] == 0
|
||||
assert cb2["available"] == 140
|
||||
assert cb2["lifetime_earned"] == 140
|
||||
|
||||
|
||||
def test_marketplace_refund():
|
||||
client = TestClient(app)
|
||||
seller_id, seller_headers = _register(client, "refund_seller_1")
|
||||
buyer_id, buyer_headers = _register(client, "refund_buyer_1")
|
||||
|
||||
# Item & Listing
|
||||
item_resp = client.post(
|
||||
"/api/marketplace/items",
|
||||
headers=seller_headers,
|
||||
json={"name": "Magic Ring", "item_type": "avatar_item"},
|
||||
)
|
||||
item_id = item_resp.json()["id"]
|
||||
|
||||
list_resp = client.post(
|
||||
"/api/marketplace/listings",
|
||||
headers=seller_headers,
|
||||
json={"catalog_item_id": item_id, "price": 100},
|
||||
)
|
||||
listing_id = list_resp.json()["id"]
|
||||
|
||||
# Fund buyer
|
||||
s_resp = client.post("/api/payments/purchase", headers=buyer_headers, json={"credits": 500})
|
||||
assert s_resp.status_code == 201, s_resp.text
|
||||
client.post("/api/payments/confirm", headers=buyer_headers, json={"session_id": s_resp.json()["session_id"]})
|
||||
|
||||
# Purchase
|
||||
buy_resp = client.post(f"/api/marketplace/listings/{listing_id}/purchase", headers=buyer_headers)
|
||||
assert buy_resp.status_code == 201
|
||||
purchase_id = buy_resp.json()["id"]
|
||||
|
||||
# Buyer balance is 400
|
||||
w1 = client.get("/api/wallet", headers=buyer_headers).json()
|
||||
assert w1["balance"] == 400
|
||||
|
||||
# Seller pending is 70
|
||||
cb1 = client.get("/api/marketplace/creator/balance", headers=seller_headers).json()
|
||||
assert cb1["pending"] == 70
|
||||
|
||||
# Refund
|
||||
ref_resp = client.post(
|
||||
f"/api/marketplace/purchases/{purchase_id}/refund",
|
||||
headers=seller_headers,
|
||||
json={"reason": "Buyer dissatisfaction"},
|
||||
)
|
||||
assert ref_resp.status_code == 200, ref_resp.text
|
||||
assert ref_resp.json()["status"] == "refunded"
|
||||
|
||||
# Buyer balance refunded back to 500
|
||||
w2 = client.get("/api/wallet", headers=buyer_headers).json()
|
||||
assert w2["balance"] == 500
|
||||
|
||||
# Seller pending reset to 0
|
||||
cb2 = client.get("/api/marketplace/creator/balance", headers=seller_headers).json()
|
||||
assert cb2["pending"] == 0
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Integration tests for Premium Purchase and PaymentProvider.
|
||||
|
||||
docs/planning/economy.md US-2 (task 575fd14b5360).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
assert me.status_code == 200, me.text
|
||||
return me.json()["id"], headers
|
||||
|
||||
|
||||
def test_payments_unauthorized():
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/payments/purchase", json={"credits": 500})
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = client.post("/api/payments/confirm", json={"session_id": "sess_123"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_purchase_rejects_arbitrary_amounts():
|
||||
client = TestClient(app)
|
||||
_, headers = _register(client, "pay_user_1")
|
||||
|
||||
# 42 credits is not an allowed pack
|
||||
resp = client.post("/api/payments/purchase", json={"credits": 42}, headers=headers)
|
||||
assert resp.status_code == 422
|
||||
|
||||
# Negative / 0 credits
|
||||
resp = client.post("/api/payments/purchase", json={"credits": -100}, headers=headers)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_purchase_and_confirm_flow():
|
||||
client = TestClient(app)
|
||||
uid, headers = _register(client, "pay_user_2")
|
||||
|
||||
# 1. Check initial balance
|
||||
w_resp = client.get("/api/wallet", headers=headers)
|
||||
assert w_resp.status_code == 200
|
||||
assert w_resp.json()["balance"] == 0
|
||||
|
||||
# 2. Purchase session for 500 credits
|
||||
p_resp = client.post(
|
||||
"/api/payments/purchase",
|
||||
json={"credits": 500},
|
||||
headers={"Idempotency-Key": "sess_req_1", **headers},
|
||||
)
|
||||
assert p_resp.status_code == 201, p_resp.text
|
||||
p_data = p_resp.json()
|
||||
assert p_data["credits"] == 500
|
||||
assert p_data["session_id"].startswith("sess_")
|
||||
session_id = p_data["session_id"]
|
||||
|
||||
# Balance is still 0 before confirmation
|
||||
w_resp = client.get("/api/wallet", headers=headers)
|
||||
assert w_resp.json()["balance"] == 0
|
||||
|
||||
# 3. Confirm purchase session
|
||||
c_resp = client.post(
|
||||
"/api/payments/confirm",
|
||||
json={"session_id": session_id},
|
||||
headers=headers,
|
||||
)
|
||||
assert c_resp.status_code == 200, c_resp.text
|
||||
c_data = c_resp.json()
|
||||
assert c_data["confirmed"] is True
|
||||
assert c_data["credits"] == 500
|
||||
assert c_data["new_balance"] == 500
|
||||
assert c_data["provider_ref"] != ""
|
||||
|
||||
# 4. Idempotent re-confirm must not double-credit
|
||||
c_resp2 = client.post(
|
||||
"/api/payments/confirm",
|
||||
json={"session_id": session_id},
|
||||
headers=headers,
|
||||
)
|
||||
assert c_resp2.status_code == 200
|
||||
assert c_resp2.json()["new_balance"] == 500
|
||||
|
||||
# Check transactions in ledger: exactly 1 transaction
|
||||
tx_resp = client.get("/api/wallet/transactions", headers=headers)
|
||||
assert tx_resp.status_code == 200
|
||||
tx_data = tx_resp.json()
|
||||
assert tx_data["total"] == 1
|
||||
assert tx_data["transactions"][0]["amount"] == 500
|
||||
assert tx_data["transactions"][0]["type"] == "credit_purchase"
|
||||
|
||||
|
||||
def test_confirm_invalid_or_wrong_user_fails():
|
||||
client = TestClient(app)
|
||||
uid1, headers1 = _register(client, "pay_user_3a")
|
||||
uid2, headers2 = _register(client, "pay_user_3b")
|
||||
|
||||
# User 1 creates a session
|
||||
p_resp = client.post("/api/payments/purchase", json={"credits": 100}, headers=headers1)
|
||||
session_id = p_resp.json()["session_id"]
|
||||
|
||||
# User 2 tries to confirm User 1's session -> must fail 400
|
||||
bad_resp = client.post("/api/payments/confirm", json={"session_id": session_id}, headers=headers2)
|
||||
assert bad_resp.status_code == 400
|
||||
|
||||
# Non-existent session -> must fail 400
|
||||
bad_resp2 = client.post("/api/payments/confirm", json={"session_id": "sess_nonexistent"}, headers=headers1)
|
||||
assert bad_resp2.status_code == 400
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Integration tests for Payout Admin Review, PayoutProvider, and Ledger Audit Trail.
|
||||
|
||||
docs/planning/economy.md US-5 (task b52008923e93).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.models.marketplace import CreatorBalance
|
||||
from app.models.wallet import Transaction, TransactionType
|
||||
from app.services.payout import NoopPayoutProvider
|
||||
|
||||
|
||||
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
assert me.status_code == 200, me.text
|
||||
return me.json()["id"], headers
|
||||
|
||||
|
||||
async def _fund_creator_balance(user_id: int, available_amount: int):
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
cb = CreatorBalance(
|
||||
user_id=user_id,
|
||||
available=available_amount,
|
||||
pending=0,
|
||||
lifetime_earned=available_amount,
|
||||
)
|
||||
session.add(cb)
|
||||
await session.commit()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_payout_provider_noop():
|
||||
provider = NoopPayoutProvider()
|
||||
success, ref = await provider.pay(user_id=1, amount=500, external_ref="test@example.com")
|
||||
assert success is True
|
||||
assert ref.startswith("noop_payout_")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_payout_admin_review_audit_trail_and_ledger():
|
||||
client = TestClient(app)
|
||||
admin_id, admin_headers = _register(client, "admin_audit_reviewer")
|
||||
creator_id, creator_headers = _register(client, "audit_creator_user")
|
||||
|
||||
await _fund_creator_balance(creator_id, 1500)
|
||||
|
||||
# 1. Creator requests payout
|
||||
req_resp = client.post(
|
||||
"/api/payouts",
|
||||
headers=creator_headers,
|
||||
json={"amount": 600, "external_ref": "stripe_acct_456"},
|
||||
)
|
||||
assert req_resp.status_code == 201, req_resp.text
|
||||
payout_id = req_resp.json()["id"]
|
||||
|
||||
# 2. Admin approves payout
|
||||
appr_resp = client.post(
|
||||
f"/api/payouts/{payout_id}/approve",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert appr_resp.status_code == 200, appr_resp.text
|
||||
data = appr_resp.json()
|
||||
assert data["status"] == "paid"
|
||||
assert data["reviewed_by"] == admin_id
|
||||
assert data["reviewed_at"] is not None
|
||||
assert data["provider_ref"] is not None
|
||||
provider_ref = data["provider_ref"]
|
||||
|
||||
# 3. Verify ledger transaction row was recorded for audit trail
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
.where(
|
||||
Transaction.reference_type == "payout_request",
|
||||
Transaction.reference_id == str(payout_id),
|
||||
)
|
||||
)
|
||||
tx = (await session.execute(stmt)).scalar_one_or_none()
|
||||
assert tx is not None
|
||||
assert tx.type == TransactionType.payout
|
||||
assert tx.amount == -600
|
||||
assert tx.metadata_json["provider_ref"] == provider_ref
|
||||
assert tx.metadata_json["reviewed_by"] == admin_id
|
||||
assert tx.metadata_json["external_ref"] == "stripe_acct_456"
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Integration tests for Creator Payouts: requests, list, admin review, and balance accounting.
|
||||
|
||||
docs/planning/economy.md US-5 (tasks f5b4684f2e65, b52008923e93).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.models.marketplace import CreatorBalance
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": username,
|
||||
"email": f"{username}@example.com",
|
||||
"password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
assert me.status_code == 200, me.text
|
||||
return me.json()["id"], headers
|
||||
|
||||
|
||||
async def _fund_creator_balance(user_id: int, available_amount: int):
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
cb = CreatorBalance(
|
||||
user_id=user_id,
|
||||
available=available_amount,
|
||||
pending=0,
|
||||
lifetime_earned=available_amount,
|
||||
)
|
||||
session.add(cb)
|
||||
await session.commit()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _make_admin(user_id: int):
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
assert user is not None
|
||||
user.is_admin = True
|
||||
await session.commit()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_payout_request_and_validation():
|
||||
client = TestClient(app)
|
||||
uid, headers = _register(client, "payout_creator_1")
|
||||
_, other_headers = _register(client, "payout_other_user")
|
||||
|
||||
# Min threshold is 100 CR
|
||||
resp = client.post(
|
||||
"/api/payouts",
|
||||
headers=headers,
|
||||
json={"amount": 50},
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
# Without balance, payout of 200 CR fails
|
||||
resp = client.post(
|
||||
"/api/payouts",
|
||||
headers=headers,
|
||||
json={"amount": 200},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Insufficient available balance" in resp.text
|
||||
|
||||
# Seed 500 CR available creator balance
|
||||
await _fund_creator_balance(uid, 500)
|
||||
|
||||
# Valid payout request of 200 CR
|
||||
resp = client.post(
|
||||
"/api/payouts",
|
||||
headers=headers,
|
||||
json={"amount": 200, "external_ref": "creator@paypal.com"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
payout_data = resp.json()
|
||||
assert payout_data["amount"] == 200
|
||||
assert payout_data["status"] == "pending"
|
||||
assert payout_data["external_ref"] == "creator@paypal.com"
|
||||
payout_id = payout_data["id"]
|
||||
|
||||
# Check creator balance: 500 - 200 = 300 available, 200 pending
|
||||
cb_resp = client.get("/api/marketplace/creator/balance", headers=headers)
|
||||
assert cb_resp.status_code == 200
|
||||
cb = cb_resp.json()
|
||||
assert cb["available"] == 300
|
||||
assert cb["pending"] == 200
|
||||
|
||||
# List payouts: creator sees own request
|
||||
list_resp = client.get("/api/payouts", headers=headers)
|
||||
assert list_resp.status_code == 200
|
||||
my_payouts = list_resp.json()
|
||||
assert len(my_payouts) == 1
|
||||
assert my_payouts[0]["id"] == payout_id
|
||||
|
||||
# Other user sees empty list
|
||||
other_list = client.get("/api/payouts", headers=other_headers)
|
||||
assert other_list.status_code == 200
|
||||
assert len(other_list.json()) == 0
|
||||
|
||||
# Other user cannot view detail -> 403
|
||||
det_resp = client.get(f"/api/payouts/{payout_id}", headers=other_headers)
|
||||
assert det_resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_payout_admin_approve_and_reject():
|
||||
client = TestClient(app)
|
||||
admin_id, admin_headers = _register(client, "admin_payout_reviewer")
|
||||
|
||||
creator_id, creator_headers = _register(client, "payout_creator_2")
|
||||
await _fund_creator_balance(creator_id, 1000)
|
||||
|
||||
# 1. Request payout of 300 CR
|
||||
p1_resp = client.post(
|
||||
"/api/payouts",
|
||||
headers=creator_headers,
|
||||
json={"amount": 300, "external_ref": "iban_123"},
|
||||
)
|
||||
assert p1_resp.status_code == 201
|
||||
p1_id = p1_resp.json()["id"]
|
||||
|
||||
# 2. Non-admin cannot approve -> 403
|
||||
non_admin_appr = client.post(f"/api/payouts/{p1_id}/approve", headers=creator_headers)
|
||||
assert non_admin_appr.status_code == 403
|
||||
|
||||
# 3. Admin approves payout 1
|
||||
appr_resp = client.post(f"/api/payouts/{p1_id}/approve", headers=admin_headers)
|
||||
assert appr_resp.status_code == 200, appr_resp.text
|
||||
p1_data = appr_resp.json()
|
||||
assert p1_data["status"] == "paid"
|
||||
assert p1_data["reviewed_by"] == admin_id
|
||||
assert p1_data["provider_ref"] is not None
|
||||
|
||||
# Balance check: available was 700, pending was 300 -> now pending = 0
|
||||
cb_resp = client.get("/api/marketplace/creator/balance", headers=creator_headers)
|
||||
assert cb_resp.json()["available"] == 700
|
||||
assert cb_resp.json()["pending"] == 0
|
||||
|
||||
# 4. Request payout 2 of 200 CR
|
||||
p2_resp = client.post(
|
||||
"/api/payouts",
|
||||
headers=creator_headers,
|
||||
json={"amount": 200},
|
||||
)
|
||||
assert p2_resp.status_code == 201
|
||||
p2_id = p2_resp.json()["id"]
|
||||
|
||||
# available was 700 - 200 = 500, pending = 200
|
||||
cb_resp = client.get("/api/marketplace/creator/balance", headers=creator_headers)
|
||||
assert cb_resp.json()["available"] == 500
|
||||
assert cb_resp.json()["pending"] == 200
|
||||
|
||||
# 5. Admin rejects payout 2
|
||||
rej_resp = client.post(
|
||||
f"/api/payouts/{p2_id}/reject",
|
||||
headers=admin_headers,
|
||||
json={"reason": "Incorrect payment details"},
|
||||
)
|
||||
assert rej_resp.status_code == 200, rej_resp.text
|
||||
p2_data = rej_resp.json()
|
||||
assert p2_data["status"] == "rejected"
|
||||
assert p2_data["reviewed_by"] == admin_id
|
||||
|
||||
# Creator balance refunded: 500 + 200 = 700 available, 0 pending
|
||||
cb_resp = client.get("/api/marketplace/creator/balance", headers=creator_headers)
|
||||
assert cb_resp.json()["available"] == 700
|
||||
assert cb_resp.json()["pending"] == 0
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Integration tests for Wallet service and API endpoints.
|
||||
|
||||
docs/planning/economy.md US-1 (task 909e3e9a59af).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.models.wallet import TransactionType
|
||||
from app.services.wallet import InsufficientFundsError, InvalidAmountError, wallet_service
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def test_wallet_unauthorized():
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/wallet")
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = client.get("/api/wallet/transactions")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_get_wallet_lazy_creation():
|
||||
client = TestClient(app)
|
||||
uid, headers = _register(client, "wallet_api_user_1")
|
||||
|
||||
resp = client.get("/api/wallet", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["user_id"] == uid
|
||||
assert data["balance"] == 0
|
||||
assert data["id"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_service_credit_and_debit_flow():
|
||||
client = TestClient(app)
|
||||
uid, headers = _register(client, "wallet_api_user_2")
|
||||
|
||||
engine, sf = _session_factory()
|
||||
try:
|
||||
async with sf() as session:
|
||||
# 1. Credit
|
||||
wallet, tx = await wallet_service.credit(
|
||||
session,
|
||||
user_id=uid,
|
||||
amount=1000,
|
||||
tx_type=TransactionType.credit_purchase,
|
||||
reference_type="test_ref",
|
||||
reference_id="ref_1",
|
||||
idempotency_key="credit_idemp_1",
|
||||
)
|
||||
assert wallet.balance == 1000
|
||||
assert tx.amount == 1000
|
||||
assert tx.balance_after == 1000
|
||||
|
||||
# 2. Idempotent re-credit does not increase balance
|
||||
wallet2, tx2 = await wallet_service.credit(
|
||||
session,
|
||||
user_id=uid,
|
||||
amount=1000,
|
||||
tx_type=TransactionType.credit_purchase,
|
||||
idempotency_key="credit_idemp_1",
|
||||
)
|
||||
assert wallet2.balance == 1000
|
||||
assert tx2.id == tx.id
|
||||
|
||||
# 3. Debit
|
||||
wallet3, tx3 = await wallet_service.debit(
|
||||
session,
|
||||
user_id=uid,
|
||||
amount=400,
|
||||
tx_type=TransactionType.marketplace_purchase,
|
||||
reference_type="purchase",
|
||||
reference_id="item_42",
|
||||
idempotency_key="debit_idemp_1",
|
||||
)
|
||||
assert wallet3.balance == 600
|
||||
assert tx3.amount == -400
|
||||
assert tx3.balance_after == 600
|
||||
|
||||
# 4. Over-debit raises InsufficientFundsError
|
||||
with pytest.raises(InsufficientFundsError):
|
||||
await wallet_service.debit(
|
||||
session,
|
||||
user_id=uid,
|
||||
amount=1000,
|
||||
tx_type=TransactionType.marketplace_purchase,
|
||||
)
|
||||
|
||||
# Balance remains 600
|
||||
w_check = await wallet_service.get_or_create_wallet(session, uid)
|
||||
assert w_check.balance == 600
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
# Verify via API
|
||||
resp = client.get("/api/wallet", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["balance"] == 600
|
||||
|
||||
resp_tx = client.get("/api/wallet/transactions", headers=headers)
|
||||
assert resp_tx.status_code == 200
|
||||
tx_data = resp_tx.json()
|
||||
assert tx_data["total"] == 2
|
||||
assert len(tx_data["transactions"]) == 2
|
||||
# Newest first: debit (-400) first, then credit (+1000)
|
||||
assert tx_data["transactions"][0]["amount"] == -400
|
||||
assert tx_data["transactions"][1]["amount"] == 1000
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Model-level integration tests for Wallets and Transaction ledger.
|
||||
|
||||
docs/planning/economy.md US-1 (task 8343bc0a730f).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
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.wallet import Wallet, Transaction, TransactionType
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
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_wallet_creation_and_defaults():
|
||||
client = TestClient(app)
|
||||
uid = _register(client, "wallet_user_1")
|
||||
engine, sf = _session_factory()
|
||||
try:
|
||||
async with sf() as session:
|
||||
wallet = Wallet(user_id=uid)
|
||||
session.add(wallet)
|
||||
await session.commit()
|
||||
await session.refresh(wallet)
|
||||
|
||||
assert wallet.id is not None
|
||||
assert wallet.user_id == uid
|
||||
assert wallet.balance == 0
|
||||
assert wallet.created_at is not None
|
||||
assert wallet.updated_at is not None
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_unique_user_id():
|
||||
client = TestClient(app)
|
||||
uid = _register(client, "wallet_user_2")
|
||||
engine, sf = _session_factory()
|
||||
try:
|
||||
async with sf() as session:
|
||||
w1 = Wallet(user_id=uid)
|
||||
session.add(w1)
|
||||
await session.commit()
|
||||
|
||||
async with sf() as session:
|
||||
w2 = Wallet(user_id=uid)
|
||||
session.add(w2)
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_non_negative_balance_constraint():
|
||||
client = TestClient(app)
|
||||
uid = _register(client, "wallet_user_3")
|
||||
engine, sf = _session_factory()
|
||||
try:
|
||||
async with sf() as session:
|
||||
w = Wallet(user_id=uid, balance=-50)
|
||||
session.add(w)
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transaction_ledger_and_idempotency():
|
||||
client = TestClient(app)
|
||||
uid = _register(client, "wallet_user_4")
|
||||
engine, sf = _session_factory()
|
||||
try:
|
||||
async with sf() as session:
|
||||
wallet = Wallet(user_id=uid, balance=500)
|
||||
session.add(wallet)
|
||||
await session.commit()
|
||||
await session.refresh(wallet)
|
||||
|
||||
tx1 = Transaction(
|
||||
wallet_id=wallet.id,
|
||||
type=TransactionType.credit_purchase,
|
||||
amount=500,
|
||||
balance_after=500,
|
||||
reference_type="purchase_session",
|
||||
reference_id="sess_123",
|
||||
idempotency_key="idemp_key_001",
|
||||
metadata_json={"payment_method": "simulated"},
|
||||
)
|
||||
session.add(tx1)
|
||||
await session.commit()
|
||||
await session.refresh(tx1)
|
||||
|
||||
assert tx1.id is not None
|
||||
assert tx1.type == TransactionType.credit_purchase
|
||||
assert tx1.amount == 500
|
||||
assert tx1.balance_after == 500
|
||||
|
||||
# Duplicate idempotency_key must raise IntegrityError
|
||||
async with sf() as session:
|
||||
tx2 = Transaction(
|
||||
wallet_id=wallet.id,
|
||||
type=TransactionType.credit_purchase,
|
||||
amount=500,
|
||||
balance_after=1000,
|
||||
idempotency_key="idemp_key_001",
|
||||
)
|
||||
session.add(tx2)
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Integration tests for real-time WebSocket gateway:
|
||||
- Authentication (query param + first auth frame)
|
||||
- Rejection of unauthenticated clients
|
||||
- Presence on connect / disconnect
|
||||
- Subscriptions to world chat channels and DM conversations
|
||||
- Real-time event delivery from REST endpoints
|
||||
- Reconnect and ping/pong
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.main import app
|
||||
from app.services.presence import presence_store
|
||||
|
||||
|
||||
def _register(client: TestClient, username: str) -> dict:
|
||||
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 {"headers": headers, "token": token, "id": me.json()["id"], "username": username}
|
||||
|
||||
|
||||
def _make_world(client: TestClient, headers: dict, visibility: str = "public") -> int:
|
||||
r = client.post("/api/worlds/", headers=headers, json={"name": "WSWorld", "visibility": visibility})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _make_friends(client: TestClient, user_a: dict, user_b: dict) -> None:
|
||||
req = client.post("/api/friends/requests", json={"user_id": user_b["id"]}, headers=user_a["headers"])
|
||||
assert req.status_code == 201, req.text
|
||||
req_id = req.json()["id"]
|
||||
acc = client.post(f"/api/friends/requests/{req_id}/accept", headers=user_b["headers"])
|
||||
assert acc.status_code == 200, acc.text
|
||||
|
||||
|
||||
class TestWebSocketAuth:
|
||||
def test_unauthenticated_connection_rejected(self):
|
||||
client = TestClient(app)
|
||||
# Invalid query param token rejected
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/api/ws?token=invalid_token"):
|
||||
pass
|
||||
assert exc.value.code == 1008
|
||||
|
||||
# First frame with invalid token rejected
|
||||
with pytest.raises(WebSocketDisconnect) as exc2:
|
||||
with client.websocket_connect("/api/ws") as ws:
|
||||
ws.send_json({"type": "auth", "token": "invalid_token"})
|
||||
ws.receive_json()
|
||||
ws.receive_json()
|
||||
assert exc2.value.code == 1008
|
||||
|
||||
def test_auth_via_query_param(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "ws_auth_alice")
|
||||
|
||||
with client.websocket_connect(f"/api/ws?token={alice['token']}") as ws:
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "auth_ok"
|
||||
assert msg["user_id"] == alice["id"]
|
||||
assert msg["username"] == "ws_auth_alice"
|
||||
|
||||
def test_auth_via_first_message(self):
|
||||
client = TestClient(app)
|
||||
bob = _register(client, "ws_auth_bob")
|
||||
|
||||
with client.websocket_connect("/api/ws") as ws:
|
||||
ws.send_json({"type": "auth", "token": bob["token"]})
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "auth_ok"
|
||||
assert msg["user_id"] == bob["id"]
|
||||
assert msg["username"] == "ws_auth_bob"
|
||||
|
||||
|
||||
class TestWebSocketPresence:
|
||||
def test_presence_on_connect_and_disconnect(self):
|
||||
import threading
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "ws_pres_alice")
|
||||
bob = _register(client, "ws_pres_bob")
|
||||
_make_friends(client, alice, bob)
|
||||
|
||||
# Initially both offline
|
||||
assert not asyncio.run(presence_store.is_online(alice["id"]))
|
||||
|
||||
# Bob connects and listens for presence events
|
||||
bob_events = []
|
||||
|
||||
def run_alice():
|
||||
time.sleep(0.2)
|
||||
c_alice = TestClient(app)
|
||||
with c_alice.websocket_connect(f"/api/ws?token={alice['token']}") as alice_ws:
|
||||
assert alice_ws.receive_json()["type"] == "auth_ok"
|
||||
time.sleep(0.3)
|
||||
alice_ws.close()
|
||||
time.sleep(0.3)
|
||||
|
||||
with client.websocket_connect(f"/api/ws?token={bob['token']}") as bob_ws:
|
||||
assert bob_ws.receive_json()["type"] == "auth_ok"
|
||||
|
||||
alice_thread = threading.Thread(target=run_alice)
|
||||
alice_thread.start()
|
||||
|
||||
# Bob receives Alice's online event
|
||||
online_event = bob_ws.receive_json()
|
||||
assert online_event["type"] == "presence"
|
||||
assert online_event["user_id"] == alice["id"]
|
||||
assert online_event["is_online"] is True
|
||||
|
||||
alice_thread.join()
|
||||
|
||||
# Bob receives Alice's offline event
|
||||
offline_event = bob_ws.receive_json()
|
||||
assert offline_event["type"] == "presence"
|
||||
assert offline_event["user_id"] == alice["id"]
|
||||
assert offline_event["is_online"] is False
|
||||
|
||||
assert not asyncio.run(presence_store.is_online(alice["id"]))
|
||||
|
||||
|
||||
class TestWebSocketChatDelivery:
|
||||
def test_world_chat_subscription_and_delivery(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "ws_chat_alice")
|
||||
bob = _register(client, "ws_chat_bob")
|
||||
world_id = _make_world(client, alice["headers"])
|
||||
|
||||
with client.websocket_connect(f"/api/ws?token={bob['token']}") as bob_ws:
|
||||
assert bob_ws.receive_json()["type"] == "auth_ok"
|
||||
|
||||
# Bob subscribes to world
|
||||
bob_ws.send_json({"type": "subscribe_world", "world_id": world_id})
|
||||
sub_ack = bob_ws.receive_json()
|
||||
assert sub_ack["type"] == "subscribed"
|
||||
assert sub_ack["channel"] == "world"
|
||||
assert sub_ack["id"] == world_id
|
||||
|
||||
# Alice posts message to world via REST
|
||||
rest_resp = client.post(
|
||||
f"/api/chat/world/{world_id}/messages",
|
||||
headers=alice["headers"],
|
||||
json={"body": "Hello real-time world!"},
|
||||
)
|
||||
assert rest_resp.status_code == 201, rest_resp.text
|
||||
msg_id = rest_resp.json()["id"]
|
||||
|
||||
# Bob receives message in real time over WebSocket
|
||||
ws_msg = bob_ws.receive_json()
|
||||
assert ws_msg["type"] == "world_chat"
|
||||
assert ws_msg["world_id"] == world_id
|
||||
assert ws_msg["message"]["id"] == msg_id
|
||||
assert ws_msg["message"]["body"] == "Hello real-time world!"
|
||||
assert ws_msg["message"]["author"]["username"] == "ws_chat_alice"
|
||||
|
||||
def test_dm_chat_subscription_and_delivery(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "ws_dm_alice")
|
||||
bob = _register(client, "ws_dm_bob")
|
||||
|
||||
# Initial DM to create conversation
|
||||
init_dm = client.post(
|
||||
f"/api/chat/dm/{bob['id']}/messages",
|
||||
headers=alice["headers"],
|
||||
json={"body": "First DM to initiate"},
|
||||
)
|
||||
assert init_dm.status_code == 201, init_dm.text
|
||||
conv_id = init_dm.json()["conversation_id"]
|
||||
|
||||
with client.websocket_connect(f"/api/ws?token={bob['token']}") as bob_ws:
|
||||
assert bob_ws.receive_json()["type"] == "auth_ok"
|
||||
|
||||
# Bob subscribes to this conversation
|
||||
bob_ws.send_json({"type": "subscribe_dm", "conversation_id": conv_id})
|
||||
sub_ack = bob_ws.receive_json()
|
||||
assert sub_ack["type"] == "subscribed"
|
||||
assert sub_ack["channel"] == "dm"
|
||||
assert sub_ack["id"] == conv_id
|
||||
|
||||
# Alice sends another DM via REST
|
||||
rest_dm = client.post(
|
||||
f"/api/chat/dm/{bob['id']}/messages",
|
||||
headers=alice["headers"],
|
||||
json={"body": "Real-time DM ping!"},
|
||||
)
|
||||
assert rest_dm.status_code == 201, rest_dm.text
|
||||
|
||||
# Bob receives real-time DM over WebSocket
|
||||
ws_dm = bob_ws.receive_json()
|
||||
assert ws_dm["type"] == "dm_chat"
|
||||
assert ws_dm["conversation_id"] == conv_id
|
||||
assert ws_dm["message"]["body"] == "Real-time DM ping!"
|
||||
|
||||
|
||||
class TestWebSocketPingAndHeartbeat:
|
||||
def test_ping_pong_and_heartbeat(self):
|
||||
client = TestClient(app)
|
||||
alice = _register(client, "ws_ping_alice")
|
||||
|
||||
with client.websocket_connect(f"/api/ws?token={alice['token']}") as ws:
|
||||
assert ws.receive_json()["type"] == "auth_ok"
|
||||
|
||||
ws.send_json({"type": "ping"})
|
||||
assert ws.receive_json()["type"] == "pong"
|
||||
|
||||
ws.send_json({"type": "heartbeat"})
|
||||
assert ws.receive_json()["type"] == "heartbeat_ack"
|
||||
@@ -0,0 +1,64 @@
|
||||
# Package Checkpoint 4 (PC4) — "A world you can build and be social in"
|
||||
|
||||
> **Status: Complete & Verified.** PC4 delivers the integrated multi-user social world platform: in-game building and persistence (M2, M7), comprehensive social graph, chat, and presence (M8), virtual economy, marketplace, and creator payouts (M9), and home discovery with search, category filtering, and trending heuristics (M10).
|
||||
|
||||
Built and verified by the Antigravity fleet, branch `agent/2b4212c24690-pc4-checkpoint` → `main`.
|
||||
|
||||
---
|
||||
|
||||
## 1. What's Real and Verified (`scripts/run_checkpoint_tests.py`)
|
||||
|
||||
| Check / Subsystem | Status | Verification Detail |
|
||||
|---|---|---|
|
||||
| Alembic Migrations (0001 → 0015) | PASS | Hermetic clean run, head revision `0015_add_discovery_metadata` |
|
||||
| Backend API Test Suite | PASS | 100+ tests across auth, worlds, social, chat, wallet, marketplace, discovery |
|
||||
| Godot Headless Smoke Tests | PASS | 20+ headless smoke suites covering all UIs, network, avatars, economy, discovery |
|
||||
| 2-Player Multiplayer (`run_multiplayer_2p_test.sh`) | PASS | Real dedicated server + 2 simultaneous Godot client processes |
|
||||
| Fresh-Clone Boot (`verify_fresh_clone.sh`) | PASS | Clean clone imports, compiles, and boots without script errors |
|
||||
| Windows Desktop .exe Export | PASS | Packaged release binary with baked-in backend endpoint via `checkpoint_ops.py` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Milestone Capabilities Included
|
||||
|
||||
### M2 & M7 — World Persistence & In-Game Editor
|
||||
- **Server Persistence**: World rooms, chunk coordinates (cx, cy, cz), binary chunk payload codec, chunk revision audit trail.
|
||||
- **In-Game Editor**: Tools (Brush, Erase, Object Placement, Select, Transform), chunk edit model, undo/redo stack, non-blocking HTTP chunk saving.
|
||||
|
||||
### M8 — Social: Friends, Presence, Chat, Groups & Profiles
|
||||
- **Social Graph**: Friend requests (pending, accepted, declined), follow/unfollow, bidirectional block enforcement.
|
||||
- **Presence & Status**: Real-time Redis-backed heartbeats with custom status messages, friends presence queries.
|
||||
- **Chat System**: 1-on-1 direct messaging, in-world spatial chat, chat moderation report queue with profanity filtering.
|
||||
- **Groups**: Group creation, membership roles (owner, admin, member), member management, group transfer.
|
||||
- **Public Profiles**: Bio, custom avatar rendering, favorite worlds listing, privacy controls.
|
||||
- **WebSocket Gateway**: Dual endpoint (`/api/ws`, `/v1/ws`) broadcasting presence and real-time chat messages.
|
||||
- **Godot Client & UIs**: `FriendsPresenceUI`, `ChatUI`, `ProfileUI`, `GroupsUI`.
|
||||
|
||||
### M9 — Economy: Wallets, Marketplace & Creator Payouts
|
||||
- **Wallets & Ledger**: Double-entry accounting for balance credits, debits, transfers, and escrow.
|
||||
- **Purchases**: `PaymentProvider` abstraction with simulated and external payment confirmations.
|
||||
- **Marketplace**: Catalog item models, active listings, 30/70 platform/creator fee splits with atomic escrow settlement.
|
||||
- **Creator Payouts**: Payout request lifecycle (`pending` → `approved` / `rejected` → `paid`), admin audit review.
|
||||
- **Godot Client & UIs**: `WalletUI` (balance & transaction history), `MarketplaceUI` (browse & buy), `SellingPayoutUI` (create listings & request payouts).
|
||||
|
||||
### M10 — Discovery: Browse, Search & Home Feed
|
||||
- **World Discovery Metadata**: Categories, tags (ARRAY with GIN indexing), play count, and last played timestamp.
|
||||
- **Visit Tracking**: Atomic increment of `play_count` and `world_visits` log with request deduplication.
|
||||
- **Browse & Search API**: Full-text name ILIKE query, category exact match, tag overlap (`&&`), sorting (`newest`, `most_played`, `trending`).
|
||||
- **Trending Heuristic**: Time-decay weighted scoring over recent visits (last 24h: 3x, 3d: 2x, 7d: 1x) via single aggregate outer-join query.
|
||||
- **Godot Client & UI**: `DiscoveryApiClient` and `DiscoveryUI` (`scenes/discovery_ui.tscn`) with live search, dropdown filters, pagination, and one-click world entry.
|
||||
|
||||
---
|
||||
|
||||
## 3. Playtest Guide
|
||||
|
||||
1. **Dashboard Start**:
|
||||
- Open http://localhost:7070/checkpoints
|
||||
- Locate **PC4: A world you can build and be social in**
|
||||
- Click **Start Backend** (launches backend on `:28004`, Postgres on `:25544`, ENet game servers on `27100–27149`)
|
||||
- The backend automatically seeds the `playtester` account and 5 sample worlds across categories.
|
||||
2. **Download & Play**:
|
||||
- Click **Download Windows .exe**
|
||||
- Launch `agp-game.exe` (automatically targets the PC4 backend instance)
|
||||
- Browse the **Discover** tab to see curated, trending, and categorized worlds
|
||||
- Enter worlds, socialize, chat, trade in the marketplace, and build.
|
||||
@@ -78,6 +78,14 @@ local catalog/loadout/loader logic. Backend-dependent tests read
|
||||
| `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. |
|
||||
| `smoke_test_network_client.gd` | **Real two-process integration test** for `scripts/network_client.gd` (client-side prediction + reconciliation, docs/architecture/networking.md §5) — needs an actual dedicated server running first: `godot --headless --path . scenes/server.tscn &`, *not* the usual HTTP backend. Connects a real `NetworkClient` over ENet, confirms local input prediction moves the player immediately (no waiting on a round trip), confirms real snapshots arrive and get processed, then deliberately desyncs the predicted position by 9999 units and confirms the very next snapshot corrects it back — the actual proof reconciliation logic works, not just that it never had to fire. Known simplification, stated in the script's own docstring: the server's snapshot doesn't currently echo back a per-peer "last input seq processed," so reconciliation corrects against the *most recent* prediction rather than true tick-exact rollback-and-replay (docs/architecture/networking.md §5.2 step 4) — a real, scoped follow-up once the server echoes that ack, not something faked here. |
|
||||
| `smoke_test_avatar_system.gd` | Confirms `AvatarSystem` is a real autoload, the committed `assets/avatars/catalog.json` actually parses, `get_assets_for_slot("base_body")` returns real entries, an `AvatarLoadout` can set `base_body` and equip a real catalog accessory, the loadout round-trips through `to_json()`/`load_loadout_from_json()`, and — since `assets/avatars/*.glb` model files don't exist yet — that `AvatarLoader.load_base_body()` against a real catalog entry with a missing file fails *gracefully*: returns `null` and emits `load_error` rather than crashing. Visual rendering itself can't be verified until real assets exist. |
|
||||
| `smoke_test_social_client.gd` | Headless verification of `SocialApiClient` methods for friends, presence heartbeats, profiles, groups, and chat. |
|
||||
| `smoke_test_economy_client.gd` | Headless verification of `EconomyApiClient` methods for wallet, payments, catalog items, listings, and payouts. |
|
||||
| `smoke_test_wallet_ui.gd` | Instantiates `scenes/wallet_ui.tscn`, verifies balance display, transaction history list rendering, and empty states. |
|
||||
| `smoke_test_marketplace_ui.gd` | Instantiates `scenes/marketplace_ui.tscn`, verifies keyword/price/type filter controls, active listing cards rendering, and purchase flow. |
|
||||
| `smoke_test_selling_payout_ui.gd` | Instantiates `scenes/selling_payout_ui.tscn`, verifies catalog item picker, listing creation, and payout request submission. |
|
||||
| `smoke_test_discovery_client.gd` | Headless verification of `DiscoveryApiClient` typed resource parsing (`WorldSummary`, `DiscoveryResult`), and search/browse methods. |
|
||||
| `smoke_test_discovery_ui.gd` | Instantiates `scenes/discovery_ui.tscn`, verifies search input, category dropdown, sort options (newest/most-played/trending), pagination controls, and world entry integration. |
|
||||
|
||||
|
||||
Run (once, to build the class cache): `godot --headless --path . --import`,
|
||||
then for each smoke test: `godot --headless --path . --script
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/chat_ui.gd" id="1_cu"]
|
||||
|
||||
[node name="ChatUI" 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_cu")
|
||||
|
||||
[node name="VBoxContainer" 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="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "< Back"
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 26
|
||||
text = "Chat & Messaging"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Refresh"
|
||||
|
||||
[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TabContainer" type="TabContainer" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
current_tab = 0
|
||||
|
||||
[node name="WorldChat" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
metadata/_tab_index = 0
|
||||
|
||||
[node name="ControlsHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/WorldChat"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="WorldPrefix" type="Label" parent="VBoxContainer/TabContainer/WorldChat/ControlsHBox"]
|
||||
layout_mode = 2
|
||||
text = "World ID:"
|
||||
|
||||
[node name="WorldIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/WorldChat/ControlsHBox"]
|
||||
custom_minimum_size = Vector2(120, 32)
|
||||
layout_mode = 2
|
||||
text = "1"
|
||||
|
||||
[node name="JoinChatButton" type="Button" parent="VBoxContainer/TabContainer/WorldChat/ControlsHBox"]
|
||||
custom_minimum_size = Vector2(110, 32)
|
||||
layout_mode = 2
|
||||
text = "Switch Channel"
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/WorldChat"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="MessagesList" type="VBoxContainer" parent="VBoxContainer/TabContainer/WorldChat/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="SendHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/WorldChat"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="MessageInput" type="LineEdit" parent="VBoxContainer/TabContainer/WorldChat/SendHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Type a message in world chat..."
|
||||
|
||||
[node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/WorldChat/SendHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Send"
|
||||
|
||||
[node name="DirectMessages" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
metadata/_tab_index = 1
|
||||
|
||||
[node name="StartHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="TargetUserEdit" type="LineEdit" parent="VBoxContainer/TabContainer/DirectMessages/StartHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Enter User ID to start conversation..."
|
||||
|
||||
[node name="StartDmButton" type="Button" parent="VBoxContainer/TabContainer/DirectMessages/StartHBox"]
|
||||
custom_minimum_size = Vector2(140, 32)
|
||||
layout_mode = 2
|
||||
text = "Start Direct Chat"
|
||||
|
||||
[node name="HSplit" type="HSplitContainer" parent="VBoxContainer/TabContainer/DirectMessages"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
split_offset = 260
|
||||
|
||||
[node name="ConversationsVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_stretch_ratio = 0.35
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="ConvHeader" type="Label" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ConversationsVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Conversations"
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ConversationsVBox"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="ConvList" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ConversationsVBox/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="ChatVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="DmHeaderLabel" type="Label" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Select a conversation"
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="MessagesList" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="SendHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="MessageInput" type="LineEdit" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/SendHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Type a direct message..."
|
||||
|
||||
[node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/SendHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Send"
|
||||
|
||||
[node name="ReportDialog" type="ConfirmationDialog" parent="."]
|
||||
title = "Report Inappropriate Message"
|
||||
size = Vector2i(420, 200)
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="ReportDialog"]
|
||||
offset_left = 8.0
|
||||
offset_top = 8.0
|
||||
offset_right = 412.0
|
||||
offset_bottom = 151.0
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="DetailsLabel" type="Label" parent="ReportDialog/VBox"]
|
||||
layout_mode = 2
|
||||
autowrap_mode = 2
|
||||
|
||||
[node name="ReasonPrompt" type="Label" parent="ReportDialog/VBox"]
|
||||
layout_mode = 2
|
||||
text = "Reason for report:"
|
||||
|
||||
[node name="ReasonEdit" type="LineEdit" parent="ReportDialog/VBox"]
|
||||
layout_mode = 2
|
||||
text = "Inappropriate content"
|
||||
@@ -0,0 +1,150 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/discovery_ui.gd" id="1_disc"]
|
||||
|
||||
[node name="DiscoveryUI" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_disc")
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 32.0
|
||||
offset_top = 24.0
|
||||
offset_right = -32.0
|
||||
offset_bottom = -24.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="Header" type="HBoxContainer" parent="VBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="BackButton" type="Button" parent="VBox/Header"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "< Back"
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBox/Header"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 26
|
||||
text = "Discover Experiences"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="RefreshButton" type="Button" parent="VBox/Header"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Refresh"
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="VBox"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1, 0.3, 0.3, 1)
|
||||
text = ""
|
||||
|
||||
[node name="Filters" type="HBoxContainer" parent="VBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="SearchEdit" type="LineEdit" parent="VBox/Filters"]
|
||||
custom_minimum_size = Vector2(240, 36)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Search by name or tag..."
|
||||
|
||||
[node name="CategoryOption" type="OptionButton" parent="VBox/Filters"]
|
||||
custom_minimum_size = Vector2(160, 36)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="SortOption" type="OptionButton" parent="VBox/Filters"]
|
||||
custom_minimum_size = Vector2(140, 36)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="FilterButton" type="Button" parent="VBox/Filters"]
|
||||
custom_minimum_size = Vector2(90, 36)
|
||||
layout_mode = 2
|
||||
text = "Filter"
|
||||
|
||||
[node name="Content" type="Control" parent="VBox"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="LoadingLabel" type="Label" parent="VBox/Content"]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchors_preset = 14
|
||||
anchor_top = 0.5
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 0.5
|
||||
offset_top = -12.0
|
||||
offset_bottom = 12.0
|
||||
text = "Loading experiences..."
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="ErrorLabel" type="Label" parent="VBox/Content"]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchors_preset = 14
|
||||
anchor_top = 0.5
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 0.5
|
||||
offset_top = -12.0
|
||||
offset_bottom = 12.0
|
||||
theme_override_colors/font_color = Color(1, 0.4, 0.4, 1)
|
||||
text = ""
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="EmptyLabel" type="Label" parent="VBox/Content"]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchors_preset = 14
|
||||
anchor_top = 0.5
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 0.5
|
||||
offset_top = -12.0
|
||||
offset_bottom = 12.0
|
||||
theme_override_colors/font_color = Color(0.7, 0.7, 0.7, 1)
|
||||
text = "No experiences found."
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="VBox/Content"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="WorldsVBox" type="VBoxContainer" parent="VBox/Content/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="Pagination" type="HBoxContainer" parent="VBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
alignment = 1
|
||||
|
||||
[node name="PrevButton" type="Button" parent="VBox/Pagination"]
|
||||
custom_minimum_size = Vector2(90, 32)
|
||||
layout_mode = 2
|
||||
text = "< Prev"
|
||||
|
||||
[node name="PageLabel" type="Label" parent="VBox/Pagination"]
|
||||
layout_mode = 2
|
||||
text = "Page 1 of 1"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="NextButton" type="Button" parent="VBox/Pagination"]
|
||||
custom_minimum_size = Vector2(90, 32)
|
||||
layout_mode = 2
|
||||
text = "Next >"
|
||||
@@ -0,0 +1,174 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/friends_presence_ui.gd" id="1_fp"]
|
||||
|
||||
[node name="FriendsPresenceUI" 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_fp")
|
||||
|
||||
[node name="VBoxContainer" 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="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "< Back"
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 26
|
||||
text = "Friends & Social"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Refresh"
|
||||
|
||||
[node name="MyStatusHBox" type="HBoxContainer" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="StatusPrefix" type="Label" parent="VBoxContainer/MyStatusHBox"]
|
||||
layout_mode = 2
|
||||
text = "My Status:"
|
||||
|
||||
[node name="StatusLineEdit" type="LineEdit" parent="VBoxContainer/MyStatusHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "What are you doing? (e.g. Exploring worlds...)"
|
||||
|
||||
[node name="SetStatusButton" type="Button" parent="VBoxContainer/MyStatusHBox"]
|
||||
custom_minimum_size = Vector2(110, 32)
|
||||
layout_mode = 2
|
||||
text = "Set Status"
|
||||
|
||||
[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TabContainer" type="TabContainer" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
current_tab = 0
|
||||
|
||||
[node name="Friends" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
metadata/_tab_index = 0
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Friends"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="FriendsList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Friends/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Requests" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
metadata/_tab_index = 1
|
||||
|
||||
[node name="SendHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Requests"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="TargetUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Requests/SendHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Enter User ID to add..."
|
||||
|
||||
[node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/Requests/SendHBox"]
|
||||
custom_minimum_size = Vector2(120, 32)
|
||||
layout_mode = 2
|
||||
text = "Send Request"
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Requests"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="RequestsList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Requests/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Following" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
metadata/_tab_index = 2
|
||||
|
||||
[node name="FollowHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Following"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="FollowUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Following/FollowHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Enter User ID to follow..."
|
||||
|
||||
[node name="FollowButton" type="Button" parent="VBoxContainer/TabContainer/Following/FollowHBox"]
|
||||
custom_minimum_size = Vector2(100, 32)
|
||||
layout_mode = 2
|
||||
text = "Follow"
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Following"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="FollowingList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Following/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Blocked" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
metadata/_tab_index = 3
|
||||
|
||||
[node name="BlockHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Blocked"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="BlockUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Enter User ID to block..."
|
||||
|
||||
[node name="BlockButton" type="Button" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"]
|
||||
custom_minimum_size = Vector2(100, 32)
|
||||
layout_mode = 2
|
||||
text = "Block"
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Blocked"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="BlockedList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Blocked/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
@@ -0,0 +1,179 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/groups_ui.gd" id="1_gu"]
|
||||
|
||||
[node name="GroupsUI" 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_gu")
|
||||
|
||||
[node name="VBoxContainer" 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="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "< Back"
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 26
|
||||
text = "Groups & Communities"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Refresh"
|
||||
|
||||
[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TabContainer" type="TabContainer" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
current_tab = 0
|
||||
|
||||
[node name="Browse" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
metadata/_tab_index = 0
|
||||
|
||||
[node name="FilterHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Browse"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="MyGroupsCheck" type="CheckBox" parent="VBoxContainer/TabContainer/Browse/FilterHBox"]
|
||||
layout_mode = 2
|
||||
text = "Show only my groups"
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Browse"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="GroupsList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Browse/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Create" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
metadata/_tab_index = 1
|
||||
|
||||
[node name="FormVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/Create"]
|
||||
custom_minimum_size = Vector2(480, 0)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 4
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="NamePrompt" type="Label" parent="VBoxContainer/TabContainer/Create/FormVBox"]
|
||||
layout_mode = 2
|
||||
text = "Group Name:"
|
||||
|
||||
[node name="NameEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Create/FormVBox"]
|
||||
layout_mode = 2
|
||||
placeholder_text = "Enter unique group name..."
|
||||
|
||||
[node name="DescPrompt" type="Label" parent="VBoxContainer/TabContainer/Create/FormVBox"]
|
||||
layout_mode = 2
|
||||
text = "Group Description:"
|
||||
|
||||
[node name="DescEdit" type="TextEdit" parent="VBoxContainer/TabContainer/Create/FormVBox"]
|
||||
custom_minimum_size = Vector2(0, 100)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Describe the goals and community of your group..."
|
||||
|
||||
[node name="CreateButton" type="Button" parent="VBoxContainer/TabContainer/Create/FormVBox"]
|
||||
custom_minimum_size = Vector2(160, 36)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 4
|
||||
text = "Create Group"
|
||||
|
||||
[node name="GroupDetails" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
metadata/_tab_index = 2
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/GroupDetails"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="DetailsVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="GroupNameLabel" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/HeaderHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 24
|
||||
text = "Select a Group"
|
||||
|
||||
[node name="MemberCountLabel" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/HeaderHBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "0 Members"
|
||||
|
||||
[node name="GroupDescLabel" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
|
||||
layout_mode = 2
|
||||
autowrap_mode = 2
|
||||
text = "No description."
|
||||
|
||||
[node name="ActionsHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="JoinButton" type="Button" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Join Group"
|
||||
|
||||
[node name="LeaveButton" type="Button" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Leave Group"
|
||||
|
||||
[node name="DeleteButton" type="Button" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox"]
|
||||
custom_minimum_size = Vector2(110, 36)
|
||||
layout_mode = 2
|
||||
text = "Delete Group"
|
||||
|
||||
[node name="MembersVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="MembersHeader" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/MembersVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Members & Roles"
|
||||
|
||||
[node name="MembersList" type="VBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/MembersVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 6
|
||||
@@ -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,215 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/profile_ui.gd" id="1_pu"]
|
||||
|
||||
[node name="ProfileUI" 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_pu")
|
||||
|
||||
[node name="VBoxContainer" 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="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "< Back"
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 26
|
||||
text = "Profile"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Refresh"
|
||||
|
||||
[node name="LookupHBox" type="HBoxContainer" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="LookupPrefix" type="Label" parent="VBoxContainer/LookupHBox"]
|
||||
layout_mode = 2
|
||||
text = "Lookup User ID:"
|
||||
|
||||
[node name="UserIdEdit" type="LineEdit" parent="VBoxContainer/LookupHBox"]
|
||||
custom_minimum_size = Vector2(120, 32)
|
||||
layout_mode = 2
|
||||
placeholder_text = "e.g. 2"
|
||||
|
||||
[node name="ViewUserButton" type="Button" parent="VBoxContainer/LookupHBox"]
|
||||
custom_minimum_size = Vector2(100, 32)
|
||||
layout_mode = 2
|
||||
text = "View"
|
||||
|
||||
[node name="Spacer" type="Control" parent="VBoxContainer/LookupHBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="ViewMyProfileButton" type="Button" parent="VBoxContainer/LookupHBox"]
|
||||
custom_minimum_size = Vector2(140, 32)
|
||||
layout_mode = 2
|
||||
text = "My Own Profile"
|
||||
|
||||
[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="ContentVBox" type="VBoxContainer" parent="VBoxContainer/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="CardPanel" type="PanelContainer" parent="VBoxContainer/Scroll/ContentVBox"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="VBoxContainer/Scroll/ContentVBox/CardPanel"]
|
||||
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="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 20
|
||||
|
||||
[node name="AvatarBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox"]
|
||||
custom_minimum_size = Vector2(120, 120)
|
||||
layout_mode = 2
|
||||
alignment = 1
|
||||
|
||||
[node name="AvatarLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/AvatarBox"]
|
||||
layout_mode = 2
|
||||
text = "Avatar"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="DetailsVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="UsernameLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "Username"
|
||||
|
||||
[node name="UserIdLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"]
|
||||
layout_mode = 2
|
||||
text = "User ID: --"
|
||||
|
||||
[node name="BioPrompt" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"]
|
||||
layout_mode = 2
|
||||
text = "Bio:"
|
||||
|
||||
[node name="BioEdit" type="TextEdit" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"]
|
||||
custom_minimum_size = Vector2(0, 80)
|
||||
layout_mode = 2
|
||||
placeholder_text = "Tell the world about yourself..."
|
||||
|
||||
[node name="SaveBioButton" type="Button" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"]
|
||||
custom_minimum_size = Vector2(100, 32)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 0
|
||||
text = "Save Bio"
|
||||
|
||||
[node name="PrivacyVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="PrivacyHeader" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Privacy Settings"
|
||||
|
||||
[node name="Grid" type="GridContainer" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox"]
|
||||
layout_mode = 2
|
||||
columns = 2
|
||||
theme_override_constants/h_separation = 16
|
||||
theme_override_constants/v_separation = 8
|
||||
|
||||
[node name="ProfileVisLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
|
||||
layout_mode = 2
|
||||
text = "Profile Visibility:"
|
||||
|
||||
[node name="ProfileVisOption" type="OptionButton" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="FriendsVisLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
|
||||
layout_mode = 2
|
||||
text = "Friends List Visibility:"
|
||||
|
||||
[node name="FriendsVisOption" type="OptionButton" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="FavoritesVisLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
|
||||
layout_mode = 2
|
||||
text = "Favorites Visibility:"
|
||||
|
||||
[node name="FavoritesVisOption" type="OptionButton" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="SavePrivacyButton" type="Button" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox"]
|
||||
custom_minimum_size = Vector2(140, 32)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 0
|
||||
text = "Save Privacy"
|
||||
|
||||
[node name="FavoritesVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="FavHeader" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FavoritesVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Favorite Experiences"
|
||||
|
||||
[node name="FavoritesStatusLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FavoritesVBox"]
|
||||
layout_mode = 2
|
||||
text = "No favorite experiences yet."
|
||||
|
||||
[node name="FavoritesList" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/FavoritesVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="FriendsVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="FriendsHeader" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FriendsVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Friends"
|
||||
|
||||
[node name="FriendsStatusLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FriendsVBox"]
|
||||
layout_mode = 2
|
||||
text = "No friends listed."
|
||||
|
||||
[node name="FriendsList" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/FriendsVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 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"
|
||||
+228
-1
@@ -82,6 +82,9 @@ var base_url: String:
|
||||
## Request timeout in seconds. 0 disables the timeout.
|
||||
var timeout_seconds: float = 10.0
|
||||
|
||||
## Real-time WebSocket gateway client instance
|
||||
var ws: WsGatewayClient = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Environment variable override (e.g. for headless smoke tests against a
|
||||
@@ -96,6 +99,24 @@ func _ready() -> void:
|
||||
"http://127.0.0.1:8000"
|
||||
)).rstrip("/")
|
||||
|
||||
ws = WsGatewayClient.new()
|
||||
ws.name = "WsGatewayClient"
|
||||
add_child(ws)
|
||||
|
||||
|
||||
## Connect to the WebSocket gateway using the current base_url and stored token.
|
||||
func connect_ws_gateway(token: String = "") -> Error:
|
||||
if ws == null:
|
||||
return ERR_UNCONFIGURED
|
||||
var tok := token if token != "" else _access_token
|
||||
return ws.connect_gateway(_base_url, tok)
|
||||
|
||||
|
||||
## Disconnect from the WebSocket gateway.
|
||||
func disconnect_ws_gateway() -> void:
|
||||
if ws != null:
|
||||
ws.disconnect_gateway()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core request primitive
|
||||
@@ -117,7 +138,8 @@ func request(
|
||||
path: String,
|
||||
body: String = "",
|
||||
content_type: String = "application/json",
|
||||
require_auth: bool = false
|
||||
require_auth: bool = false,
|
||||
custom_headers: PackedStringArray = PackedStringArray()
|
||||
) -> Dictionary:
|
||||
var http := HTTPRequest.new()
|
||||
http.timeout = timeout_seconds
|
||||
@@ -131,6 +153,8 @@ func request(
|
||||
headers.append("Content-Type: " + content_type)
|
||||
if require_auth and has_token():
|
||||
headers.append("Authorization: " + bearer_token)
|
||||
for ch in custom_headers:
|
||||
headers.append(ch)
|
||||
|
||||
var url := _base_url + path
|
||||
var err := http.request(url, headers, method, body)
|
||||
@@ -255,6 +279,209 @@ func save_avatar_loadout(loadout: Dictionary) -> Dictionary:
|
||||
return await request(HTTPClient.METHOD_PUT, "/api/avatar/loadout", body, "application/json", true)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Social Features: Friends, Follow & Block
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Send a friend request to target user_id.
|
||||
func send_friend_request(user_id: int) -> Dictionary:
|
||||
return await SocialApiClient.send_friend_request(self, user_id)
|
||||
|
||||
## Get pending incoming friend requests.
|
||||
func get_friend_requests() -> Dictionary:
|
||||
return await SocialApiClient.get_friend_requests(self)
|
||||
|
||||
## Accept an incoming friend request.
|
||||
func accept_friend_request(request_id: int) -> Dictionary:
|
||||
return await SocialApiClient.accept_friend_request(self, request_id)
|
||||
|
||||
## Decline an incoming friend request.
|
||||
func decline_friend_request(request_id: int) -> Dictionary:
|
||||
return await SocialApiClient.decline_friend_request(self, request_id)
|
||||
|
||||
## Remove an existing friend.
|
||||
func unfriend(user_id: int) -> Dictionary:
|
||||
return await SocialApiClient.unfriend(self, user_id)
|
||||
|
||||
## Get list of mutual friends.
|
||||
func get_friends() -> Dictionary:
|
||||
return await SocialApiClient.get_friends(self)
|
||||
|
||||
## Follow a user.
|
||||
func follow_user(user_id: int) -> Dictionary:
|
||||
return await SocialApiClient.follow_user(self, user_id)
|
||||
|
||||
## Unfollow a user.
|
||||
func unfollow_user(user_id: int) -> Dictionary:
|
||||
return await SocialApiClient.unfollow_user(self, user_id)
|
||||
|
||||
## Get list of users followed by current user.
|
||||
func get_following() -> Dictionary:
|
||||
return await SocialApiClient.get_following(self)
|
||||
|
||||
## Block a user.
|
||||
func block_user(user_id: int) -> Dictionary:
|
||||
return await SocialApiClient.block_user(self, user_id)
|
||||
|
||||
## Unblock a user.
|
||||
func unblock_user(user_id: int) -> Dictionary:
|
||||
return await SocialApiClient.unblock_user(self, user_id)
|
||||
|
||||
## Get list of blocked users.
|
||||
func get_blocked() -> Dictionary:
|
||||
return await SocialApiClient.get_blocked(self)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Social Features: Presence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Send presence heartbeat with optional custom status.
|
||||
func send_presence_heartbeat(custom_status: String = "") -> Dictionary:
|
||||
return await SocialApiClient.send_presence_heartbeat(self, custom_status)
|
||||
|
||||
## Get presence status of mutual friends.
|
||||
func get_friends_presence() -> Dictionary:
|
||||
return await SocialApiClient.get_friends_presence(self)
|
||||
|
||||
## Get presence status of a specific user.
|
||||
func get_user_presence(user_id: int) -> Dictionary:
|
||||
return await SocialApiClient.get_user_presence(self, user_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Social Features: Profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Get current user's own profile.
|
||||
func get_my_profile() -> Dictionary:
|
||||
return await SocialApiClient.get_my_profile(self)
|
||||
|
||||
## Update current user's bio, avatar asset, or privacy settings.
|
||||
func update_my_profile(bio: Variant = null, avatar_asset_id: Variant = null, privacy: Variant = null) -> Dictionary:
|
||||
return await SocialApiClient.update_my_profile(self, bio, avatar_asset_id, privacy)
|
||||
|
||||
## Get current user's favorited world IDs.
|
||||
func get_my_favorites() -> Dictionary:
|
||||
return await SocialApiClient.get_my_favorites(self)
|
||||
|
||||
## Replace current user's favorited world IDs.
|
||||
func replace_my_favorites(world_ids: Array) -> Dictionary:
|
||||
return await SocialApiClient.replace_my_favorites(self, world_ids)
|
||||
|
||||
## Get public profile of another user.
|
||||
func get_public_profile(user_id: int) -> Dictionary:
|
||||
return await SocialApiClient.get_public_profile(self, user_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Social Features: Groups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Create a new group.
|
||||
func create_group(name: String, description: String = "") -> Dictionary:
|
||||
return await SocialApiClient.create_group(self, name, description)
|
||||
|
||||
## List groups.
|
||||
func list_groups(page: int = 1, page_size: int = 50, mine: bool = false) -> Dictionary:
|
||||
return await SocialApiClient.list_groups(self, page, page_size, mine)
|
||||
|
||||
## Get group details and member list.
|
||||
func get_group(group_id: int) -> Dictionary:
|
||||
return await SocialApiClient.get_group(self, group_id)
|
||||
|
||||
## Update group name or description.
|
||||
func update_group(group_id: int, name: Variant = null, description: Variant = null) -> Dictionary:
|
||||
return await SocialApiClient.update_group(self, group_id, name, description)
|
||||
|
||||
## Delete a group (owner only).
|
||||
func delete_group(group_id: int) -> Dictionary:
|
||||
return await SocialApiClient.delete_group(self, group_id)
|
||||
|
||||
## Join a group.
|
||||
func join_group(group_id: int) -> Dictionary:
|
||||
return await SocialApiClient.join_group(self, group_id)
|
||||
|
||||
## Leave a group.
|
||||
func leave_group(group_id: int) -> Dictionary:
|
||||
return await SocialApiClient.leave_group(self, group_id)
|
||||
|
||||
## Set a member's role in a group.
|
||||
func set_group_member_role(group_id: int, user_id: int, role: String) -> Dictionary:
|
||||
return await SocialApiClient.set_group_member_role(self, group_id, user_id, role)
|
||||
|
||||
## Transfer ownership of a group.
|
||||
func transfer_group_ownership(group_id: int, new_owner_id: int) -> Dictionary:
|
||||
return await SocialApiClient.transfer_group_ownership(self, group_id, new_owner_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Social Features: Chat & Moderation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## List DM conversations for the authenticated user.
|
||||
func get_dm_conversations() -> Dictionary:
|
||||
return await SocialApiClient.get_dm_conversations(self)
|
||||
|
||||
## Start or get existing DM conversation with target user.
|
||||
func start_dm_conversation(target_user_id: int) -> Dictionary:
|
||||
return await SocialApiClient.start_dm_conversation(self, target_user_id)
|
||||
|
||||
## Get DM messages for a conversation.
|
||||
func get_dm_messages(conversation_id: int, before_id: int = 0, limit: int = 50) -> Dictionary:
|
||||
return await SocialApiClient.get_dm_messages(self, conversation_id, before_id, limit)
|
||||
|
||||
## Send a DM message to a conversation.
|
||||
func send_dm_message(conversation_id: int, content: String) -> Dictionary:
|
||||
return await SocialApiClient.send_dm_message(self, conversation_id, content)
|
||||
|
||||
## Get messages in world chat.
|
||||
func get_world_chat_messages(world_id: int, before_id: int = 0, limit: int = 50) -> Dictionary:
|
||||
return await SocialApiClient.get_world_chat_messages(self, world_id, before_id, limit)
|
||||
|
||||
## Send a message to world chat.
|
||||
func send_world_chat_message(world_id: int, content: String) -> Dictionary:
|
||||
return await SocialApiClient.send_world_chat_message(self, world_id, content)
|
||||
|
||||
## Get messages in room chat.
|
||||
func get_room_chat_messages(room_id: String, before_id: int = 0, limit: int = 50) -> Dictionary:
|
||||
return await SocialApiClient.get_room_chat_messages(self, room_id, before_id, limit)
|
||||
|
||||
## Send a message to room chat.
|
||||
func send_room_chat_message(room_id: String, content: String) -> Dictionary:
|
||||
return await SocialApiClient.send_room_chat_message(self, room_id, content)
|
||||
|
||||
## Report a message to the moderation queue.
|
||||
func report_message(message_id: int, conversation_type: String, reason: String, context_text: String = "") -> Dictionary:
|
||||
return await SocialApiClient.report_message(self, message_id, conversation_type, reason, context_text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery Features
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## List public worlds for discovery with optional search, tags, category, and sort.
|
||||
func get_discovery_worlds(
|
||||
q: String = "",
|
||||
tags: Array = [],
|
||||
category: String = "",
|
||||
sort: String = "newest",
|
||||
skip: int = 0,
|
||||
limit: int = 50
|
||||
) -> Dictionary:
|
||||
return await DiscoveryApiClient.get_worlds(self, q, tags, category, sort, skip, limit)
|
||||
|
||||
## Get distinct categories of public worlds.
|
||||
func get_discovery_categories() -> Dictionary:
|
||||
return await DiscoveryApiClient.get_categories(self)
|
||||
|
||||
## Record a visit to a world.
|
||||
func record_world_visit(world_id: int, anonymous_token: String = "") -> Dictionary:
|
||||
return await DiscoveryApiClient.record_visit(self, world_id, anonymous_token)
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
extends Control
|
||||
class_name ChatUI
|
||||
## Chat UI for Direct Messages, World/Room Chat, and Message Reporting.
|
||||
##
|
||||
## Features:
|
||||
## - DM Conversations list + message thread
|
||||
## - In-experience World/Room chat thread
|
||||
## - Real-time WebSocket event dispatch for new messages
|
||||
## - Inline "Report" button on each message with reporting popup
|
||||
## - Rate limiting and error feedback display
|
||||
|
||||
signal back_requested
|
||||
|
||||
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
|
||||
@onready var title_label: Label = $VBoxContainer/HeaderHBox/TitleLabel
|
||||
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
|
||||
@onready var status_label: Label = $VBoxContainer/StatusMessageLabel
|
||||
|
||||
@onready var tab_container: TabContainer = $VBoxContainer/TabContainer
|
||||
|
||||
# World chat controls
|
||||
@onready var world_id_edit: LineEdit = $VBoxContainer/TabContainer/WorldChat/ControlsHBox/WorldIdEdit
|
||||
@onready var join_world_chat_btn: Button = $VBoxContainer/TabContainer/WorldChat/ControlsHBox/JoinChatButton
|
||||
@onready var world_messages_container: VBoxContainer = $VBoxContainer/TabContainer/WorldChat/Scroll/MessagesList
|
||||
@onready var world_msg_input: LineEdit = $VBoxContainer/TabContainer/WorldChat/SendHBox/MessageInput
|
||||
@onready var world_send_btn: Button = $VBoxContainer/TabContainer/WorldChat/SendHBox/SendButton
|
||||
|
||||
# DM controls
|
||||
@onready var target_user_edit: LineEdit = $VBoxContainer/TabContainer/DirectMessages/StartHBox/TargetUserEdit
|
||||
@onready var start_dm_btn: Button = $VBoxContainer/TabContainer/DirectMessages/StartHBox/StartDmButton
|
||||
@onready var conv_list_container: VBoxContainer = $VBoxContainer/TabContainer/DirectMessages/HSplit/ConversationsVBox/Scroll/ConvList
|
||||
@onready var dm_header_label: Label = $VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/DmHeaderLabel
|
||||
@onready var dm_messages_container: VBoxContainer = $VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/Scroll/MessagesList
|
||||
@onready var dm_msg_input: LineEdit = $VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/SendHBox/MessageInput
|
||||
@onready var dm_send_btn: Button = $VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/SendHBox/SendButton
|
||||
|
||||
# Report Popup
|
||||
@onready var report_dialog: ConfirmationDialog = $ReportDialog
|
||||
@onready var report_reason_edit: LineEdit = $ReportDialog/VBox/ReasonEdit
|
||||
@onready var report_details_label: Label = $ReportDialog/VBox/DetailsLabel
|
||||
|
||||
var _api_client: Node = null
|
||||
var _current_world_id: int = 1
|
||||
var _active_conversation_id: int = 0
|
||||
var _reporting_message_id: int = 0
|
||||
var _reporting_conv_type: String = ""
|
||||
var _reporting_context: String = ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_api_client = _get_api()
|
||||
_wire_signals()
|
||||
if _api_client != null and _api_client.has_token():
|
||||
refresh_world_chat()
|
||||
refresh_dm_conversations()
|
||||
|
||||
|
||||
func _get_api() -> Node:
|
||||
if has_node("/root/ApiClient"):
|
||||
return get_node("/root/ApiClient")
|
||||
return null
|
||||
|
||||
|
||||
func _wire_signals() -> void:
|
||||
if back_button:
|
||||
back_button.pressed.connect(_on_back_pressed)
|
||||
if refresh_button:
|
||||
refresh_button.pressed.connect(refresh_active_tab)
|
||||
|
||||
# World chat
|
||||
if join_world_chat_btn:
|
||||
join_world_chat_btn.pressed.connect(_on_join_world_chat)
|
||||
if world_send_btn:
|
||||
world_send_btn.pressed.connect(_on_send_world_message)
|
||||
if world_msg_input:
|
||||
world_msg_input.text_submitted.connect(func(_t): _on_send_world_message())
|
||||
|
||||
# DM chat
|
||||
if start_dm_btn:
|
||||
start_dm_btn.pressed.connect(_on_start_dm_pressed)
|
||||
if dm_send_btn:
|
||||
dm_send_btn.pressed.connect(_on_send_dm_message)
|
||||
if dm_msg_input:
|
||||
dm_msg_input.text_submitted.connect(func(_t): _on_send_dm_message())
|
||||
|
||||
# Report dialog
|
||||
if report_dialog:
|
||||
report_dialog.confirmed.connect(_on_report_confirmed)
|
||||
|
||||
# WebSocket real-time hook
|
||||
if _api_client != null and _api_client.ws != null:
|
||||
if not _api_client.ws.chat_message_received.is_connected(_on_ws_chat_message):
|
||||
_api_client.ws.chat_message_received.connect(_on_ws_chat_message)
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
back_requested.emit()
|
||||
if get_tree().current_scene == self:
|
||||
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
|
||||
|
||||
|
||||
func show_message(text: String, is_error: bool = false) -> void:
|
||||
if status_label:
|
||||
status_label.text = text
|
||||
status_label.modulate = Color(1.0, 0.4, 0.4) if is_error else Color(0.4, 1.0, 0.4)
|
||||
|
||||
|
||||
func refresh_active_tab() -> void:
|
||||
if tab_container == null:
|
||||
return
|
||||
if tab_container.current_tab == 0:
|
||||
refresh_world_chat()
|
||||
else:
|
||||
refresh_dm_conversations()
|
||||
if _active_conversation_id > 0:
|
||||
refresh_dm_messages(_active_conversation_id)
|
||||
|
||||
|
||||
# ── World Chat ──
|
||||
|
||||
func _on_join_world_chat() -> void:
|
||||
var wid_str := world_id_edit.text.strip_edges()
|
||||
if not wid_str.is_valid_int():
|
||||
show_message("Enter a valid integer world ID.", true)
|
||||
return
|
||||
_current_world_id = int(wid_str)
|
||||
if _api_client != null and _api_client.ws != null:
|
||||
_api_client.ws.subscribe_world(_current_world_id)
|
||||
show_message("Joined world %d chat." % _current_world_id)
|
||||
refresh_world_chat()
|
||||
|
||||
|
||||
func refresh_world_chat() -> void:
|
||||
if _api_client == null or not _api_client.has_token():
|
||||
return
|
||||
var res: Dictionary = await _api_client.get_world_chat_messages(_current_world_id)
|
||||
_clear_children(world_messages_container)
|
||||
|
||||
if not res.ok:
|
||||
show_message("Failed to load world chat: " + res.detail, true)
|
||||
return
|
||||
|
||||
var msgs: Array = []
|
||||
if res.data is Dictionary and res.data.has("messages"):
|
||||
msgs = res.data["messages"]
|
||||
elif res.data is Array:
|
||||
msgs = res.data
|
||||
|
||||
for m in msgs:
|
||||
_add_message_row(world_messages_container, m, "world")
|
||||
|
||||
|
||||
func _on_send_world_message() -> void:
|
||||
var content := world_msg_input.text.strip_edges()
|
||||
if content == "":
|
||||
return
|
||||
var res: Dictionary = await _api_client.send_world_chat_message(_current_world_id, content)
|
||||
if res.ok:
|
||||
world_msg_input.text = ""
|
||||
refresh_world_chat()
|
||||
else:
|
||||
show_message("Send failed: " + res.detail, true)
|
||||
|
||||
|
||||
# ── Direct Messages (DMs) ──
|
||||
|
||||
func refresh_dm_conversations() -> void:
|
||||
if _api_client == null or not _api_client.has_token():
|
||||
return
|
||||
var res: Dictionary = await _api_client.get_dm_conversations()
|
||||
_clear_children(conv_list_container)
|
||||
|
||||
if not res.ok:
|
||||
show_message("Failed to load DMs: " + res.detail, true)
|
||||
return
|
||||
|
||||
var convs: Array = []
|
||||
if res.data is Dictionary and res.data.has("conversations"):
|
||||
convs = res.data["conversations"]
|
||||
elif res.data is Array:
|
||||
convs = res.data
|
||||
|
||||
if convs.is_empty():
|
||||
var empty_lbl := Label.new()
|
||||
empty_lbl.text = "No conversations yet."
|
||||
empty_lbl.modulate = Color(0.6, 0.6, 0.6)
|
||||
conv_list_container.add_child(empty_lbl)
|
||||
return
|
||||
|
||||
for c in convs:
|
||||
var cid: int = int(c.get("id", 0))
|
||||
var other_name: String = "Conversation #%d" % cid
|
||||
if c.has("other_user") and c["other_user"] is Dictionary:
|
||||
other_name = str(c["other_user"].get("username", other_name))
|
||||
|
||||
var btn := Button.new()
|
||||
btn.text = other_name
|
||||
btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
btn.pressed.connect(func(): _select_dm_conversation(cid, other_name))
|
||||
conv_list_container.add_child(btn)
|
||||
|
||||
|
||||
func _select_dm_conversation(cid: int, name_hint: String) -> void:
|
||||
_active_conversation_id = cid
|
||||
if dm_header_label:
|
||||
dm_header_label.text = "Chat with " + name_hint
|
||||
if _api_client != null and _api_client.ws != null:
|
||||
_api_client.ws.subscribe_dm(cid)
|
||||
refresh_dm_messages(cid)
|
||||
|
||||
|
||||
func refresh_dm_messages(cid: int) -> void:
|
||||
if _api_client == null or cid <= 0:
|
||||
return
|
||||
var res: Dictionary = await _api_client.get_dm_messages(cid)
|
||||
_clear_children(dm_messages_container)
|
||||
|
||||
if not res.ok:
|
||||
show_message("Failed to load messages: " + res.detail, true)
|
||||
return
|
||||
|
||||
var msgs: Array = []
|
||||
if res.data is Dictionary and res.data.has("messages"):
|
||||
msgs = res.data["messages"]
|
||||
elif res.data is Array:
|
||||
msgs = res.data
|
||||
|
||||
for m in msgs:
|
||||
_add_message_row(dm_messages_container, m, "dm")
|
||||
|
||||
|
||||
func _on_start_dm_pressed() -> void:
|
||||
var target_text := target_user_edit.text.strip_edges()
|
||||
if not target_text.is_valid_int():
|
||||
show_message("Enter a valid integer user ID.", true)
|
||||
return
|
||||
var target_id := int(target_text)
|
||||
var res: Dictionary = await _api_client.start_dm_conversation(target_id)
|
||||
if res.ok and res.data is Dictionary:
|
||||
var cid: int = int(res.data.get("id", 0))
|
||||
target_user_edit.text = ""
|
||||
show_message("Conversation started!")
|
||||
await refresh_dm_conversations()
|
||||
_select_dm_conversation(cid, "User #%d" % target_id)
|
||||
else:
|
||||
show_message("Failed to start conversation: " + res.detail, true)
|
||||
|
||||
|
||||
func _on_send_dm_message() -> void:
|
||||
if _active_conversation_id <= 0:
|
||||
show_message("Select or start a conversation first.", true)
|
||||
return
|
||||
var content := dm_msg_input.text.strip_edges()
|
||||
if content == "":
|
||||
return
|
||||
var res: Dictionary = await _api_client.send_dm_message(_active_conversation_id, content)
|
||||
if res.ok:
|
||||
dm_msg_input.text = ""
|
||||
refresh_dm_messages(_active_conversation_id)
|
||||
else:
|
||||
show_message("Send failed: " + res.detail, true)
|
||||
|
||||
|
||||
# ── Message Row & Reporting ──
|
||||
|
||||
func _add_message_row(container: VBoxContainer, msg_data: Dictionary, conv_type: String) -> void:
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 8)
|
||||
|
||||
var mid: int = int(msg_data.get("id", 0))
|
||||
var author: String = "User #%s" % str(msg_data.get("author_id", msg_data.get("sender_id", "?")))
|
||||
if msg_data.has("author") and msg_data["author"] is Dictionary:
|
||||
author = str(msg_data["author"].get("username", author))
|
||||
elif msg_data.has("sender") and msg_data["sender"] is Dictionary:
|
||||
author = str(msg_data["sender"].get("username", author))
|
||||
|
||||
var content: String = str(msg_data.get("content", ""))
|
||||
|
||||
var label := Label.new()
|
||||
label.text = "[%s]: %s" % [author, content]
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
hbox.add_child(label)
|
||||
|
||||
var report_btn := Button.new()
|
||||
report_btn.text = "Report"
|
||||
report_btn.pressed.connect(func(): _open_report_dialog(mid, conv_type, content))
|
||||
hbox.add_child(report_btn)
|
||||
|
||||
container.add_child(hbox)
|
||||
|
||||
|
||||
func _open_report_dialog(msg_id: int, conv_type: String, context_text: String) -> void:
|
||||
_reporting_message_id = msg_id
|
||||
_reporting_conv_type = conv_type
|
||||
_reporting_context = context_text
|
||||
|
||||
if report_details_label:
|
||||
report_details_label.text = "Reporting message: \"%s\"" % context_text.left(60)
|
||||
if report_reason_edit:
|
||||
report_reason_edit.text = "Inappropriate content"
|
||||
if report_dialog:
|
||||
report_dialog.popup_centered()
|
||||
|
||||
|
||||
func _on_report_confirmed() -> void:
|
||||
if _reporting_message_id <= 0 or _api_client == null:
|
||||
return
|
||||
var reason := report_reason_edit.text.strip_edges() if report_reason_edit else "Inappropriate content"
|
||||
var res: Dictionary = await _api_client.report_message(_reporting_message_id, _reporting_conv_type, reason, _reporting_context)
|
||||
if res.ok:
|
||||
show_message("Message reported to moderation queue. Thank you!")
|
||||
else:
|
||||
show_message("Failed to report message: " + res.detail, true)
|
||||
|
||||
|
||||
# ── Real-Time WS Hook ──
|
||||
|
||||
func _on_ws_chat_message(channel: String, id: int, data: Dictionary) -> void:
|
||||
if channel == "world" and id == _current_world_id:
|
||||
_add_message_row(world_messages_container, data, "world")
|
||||
elif channel == "dm" and id == _active_conversation_id:
|
||||
_add_message_row(dm_messages_container, data, "dm")
|
||||
|
||||
|
||||
func _clear_children(node: Node) -> void:
|
||||
if node == null:
|
||||
return
|
||||
for c in node.get_children():
|
||||
c.queue_free()
|
||||
@@ -0,0 +1,140 @@
|
||||
extends RefCounted
|
||||
class_name DiscoveryApiClient
|
||||
## Helper client for AGP Discovery REST endpoints (Browse, Search, Categories, Visit tracking).
|
||||
##
|
||||
## docs/planning/discovery.md (task 56c49440d0a6).
|
||||
|
||||
class WorldSummary extends RefCounted:
|
||||
var id: int = 0
|
||||
var name: String = ""
|
||||
var description: String = ""
|
||||
var category: String = ""
|
||||
var tags: Array = []
|
||||
var play_count: int = 0
|
||||
var created_at: String = ""
|
||||
var owner_id: int = 0
|
||||
var owner_username: String = ""
|
||||
|
||||
static func from_dict(d: Dictionary) -> WorldSummary:
|
||||
var w := WorldSummary.new()
|
||||
w.id = int(d.get("id", 0))
|
||||
w.name = str(d.get("name", ""))
|
||||
w.description = str(d.get("description", "")) if d.get("description") != null else ""
|
||||
w.category = str(d.get("category", "")) if d.get("category") != null else ""
|
||||
var raw_tags = d.get("tags", [])
|
||||
w.tags = raw_tags if raw_tags is Array else []
|
||||
w.play_count = int(d.get("play_count", 0))
|
||||
w.created_at = str(d.get("created_at", "")) if d.get("created_at") != null else ""
|
||||
var owner: Dictionary = d.get("owner", {}) if d.get("owner") is Dictionary else {}
|
||||
w.owner_id = int(owner.get("id", 0))
|
||||
w.owner_username = str(owner.get("username", ""))
|
||||
return w
|
||||
|
||||
|
||||
class DiscoveryResult extends RefCounted:
|
||||
var items: Array[WorldSummary] = []
|
||||
var total: int = 0
|
||||
var skip: int = 0
|
||||
var limit: int = 50
|
||||
|
||||
static func from_dict(d: Dictionary) -> DiscoveryResult:
|
||||
var res := DiscoveryResult.new()
|
||||
res.total = int(d.get("total", 0))
|
||||
res.skip = int(d.get("skip", 0))
|
||||
res.limit = int(d.get("limit", 50))
|
||||
var raw_items = d.get("items", [])
|
||||
if raw_items is Array:
|
||||
for item in raw_items:
|
||||
if item is Dictionary:
|
||||
res.items.append(WorldSummary.from_dict(item))
|
||||
return res
|
||||
|
||||
|
||||
static func get_api_client(node: Node) -> Node:
|
||||
if node.has_node("/root/ApiClient"):
|
||||
return node.get_node("/root/ApiClient")
|
||||
return null
|
||||
|
||||
|
||||
## Fetch worlds with search, tags, category filter, sort, and pagination.
|
||||
## Attaches JWT token if client has one; works anonymously without token.
|
||||
## Surfaces transport/HTTP errors in the standard result dictionary.
|
||||
static func get_worlds(
|
||||
api: Node,
|
||||
q: String = "",
|
||||
tags: Array = [],
|
||||
category: String = "",
|
||||
sort: String = "newest",
|
||||
skip: int = 0,
|
||||
limit: int = 50
|
||||
) -> Dictionary:
|
||||
var params: Array[String] = []
|
||||
if q != "":
|
||||
params.append("q=" + q.uri_encode())
|
||||
if tags.size() > 0:
|
||||
var tag_strs: Array[String] = []
|
||||
for t in tags:
|
||||
tag_strs.append(str(t))
|
||||
params.append("tags=" + ",".join(tag_strs).uri_encode())
|
||||
if category != "":
|
||||
params.append("category=" + category.uri_encode())
|
||||
if sort != "":
|
||||
params.append("sort=" + sort.uri_encode())
|
||||
params.append("skip=%d" % skip)
|
||||
params.append("limit=%d" % limit)
|
||||
|
||||
var url := "/api/discovery/worlds"
|
||||
if params.size() > 0:
|
||||
url += "?" + "&".join(params)
|
||||
|
||||
var custom_headers := PackedStringArray()
|
||||
if api != null and api.has_method("has_token") and api.has_token():
|
||||
custom_headers.append("Authorization: " + api.bearer_token)
|
||||
|
||||
var res: Dictionary = await api.request(
|
||||
HTTPClient.METHOD_GET,
|
||||
url,
|
||||
"",
|
||||
"application/json",
|
||||
false,
|
||||
custom_headers
|
||||
)
|
||||
if res.ok and res.data is Dictionary:
|
||||
res["parsed"] = DiscoveryResult.from_dict(res.data)
|
||||
return res
|
||||
|
||||
|
||||
## Fetch distinct categories of public worlds.
|
||||
static func get_categories(api: Node) -> Dictionary:
|
||||
var custom_headers := PackedStringArray()
|
||||
if api != null and api.has_method("has_token") and api.has_token():
|
||||
custom_headers.append("Authorization: " + api.bearer_token)
|
||||
|
||||
return await api.request(
|
||||
HTTPClient.METHOD_GET,
|
||||
"/api/discovery/categories",
|
||||
"",
|
||||
"application/json",
|
||||
false,
|
||||
custom_headers
|
||||
)
|
||||
|
||||
|
||||
## Record a visit to a world. Supports optional anonymous token or logged in user.
|
||||
static func record_visit(api: Node, world_id: int, anonymous_token: String = "") -> Dictionary:
|
||||
var custom_headers := PackedStringArray()
|
||||
if api != null and api.has_method("has_token") and api.has_token():
|
||||
custom_headers.append("Authorization: " + api.bearer_token)
|
||||
|
||||
var payload := {}
|
||||
if anonymous_token != "":
|
||||
payload["anonymous_token"] = anonymous_token
|
||||
|
||||
return await api.request(
|
||||
HTTPClient.METHOD_POST,
|
||||
"/api/discovery/worlds/%d/visit" % world_id,
|
||||
JSON.stringify(payload) if payload.size() > 0 else "",
|
||||
"application/json",
|
||||
false,
|
||||
custom_headers
|
||||
)
|
||||
@@ -0,0 +1,376 @@
|
||||
extends Control
|
||||
class_name DiscoveryUI
|
||||
## Discovery & Home Screen for browsing, searching, and entering experiences.
|
||||
##
|
||||
## docs/planning/discovery.md (task b77bddf31ba5).
|
||||
|
||||
signal back_requested
|
||||
signal world_selected(world_id: int)
|
||||
signal worlds_loaded(count: int)
|
||||
|
||||
@onready var back_button: Button = $VBox/Header/BackButton
|
||||
@onready var refresh_button: Button = $VBox/Header/RefreshButton
|
||||
@onready var status_label: Label = $VBox/StatusLabel
|
||||
|
||||
# Filter Controls
|
||||
@onready var search_edit: LineEdit = $VBox/Filters/SearchEdit
|
||||
@onready var category_option: OptionButton = $VBox/Filters/CategoryOption
|
||||
@onready var sort_option: OptionButton = $VBox/Filters/SortOption
|
||||
@onready var filter_button: Button = $VBox/Filters/FilterButton
|
||||
|
||||
# Content
|
||||
@onready var loading_label: Label = $VBox/Content/LoadingLabel
|
||||
@onready var error_label: Label = $VBox/Content/ErrorLabel
|
||||
@onready var empty_label: Label = $VBox/Content/EmptyLabel
|
||||
@onready var worlds_container: VBoxContainer = $VBox/Content/Scroll/WorldsVBox
|
||||
|
||||
# Pagination
|
||||
@onready var prev_button: Button = $VBox/Pagination/PrevButton
|
||||
@onready var next_button: Button = $VBox/Pagination/NextButton
|
||||
@onready var page_label: Label = $VBox/Pagination/PageLabel
|
||||
|
||||
var _api_client: Node = null
|
||||
var _current_page: int = 0
|
||||
var _page_size: int = 10
|
||||
var _total_count: int = 0
|
||||
var _loaded_worlds: Array[DiscoveryApiClient.WorldSummary] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_api_client = _get_api()
|
||||
_wire_signals()
|
||||
_setup_sort_options()
|
||||
_setup_initial_ui_state()
|
||||
|
||||
# Populate categories then load initial worlds feed
|
||||
await load_categories()
|
||||
await load_worlds(0)
|
||||
|
||||
|
||||
func _get_api() -> Node:
|
||||
if has_node("/root/ApiClient"):
|
||||
return get_node("/root/ApiClient")
|
||||
var api_script = load("res://scripts/api_client.gd")
|
||||
if api_script:
|
||||
var api = api_script.new()
|
||||
add_child(api)
|
||||
return api
|
||||
return null
|
||||
|
||||
|
||||
func _setup_initial_ui_state() -> void:
|
||||
if loading_label: loading_label.visible = false
|
||||
if error_label: error_label.visible = false
|
||||
if empty_label: empty_label.visible = false
|
||||
if prev_button: prev_button.disabled = true
|
||||
if next_button: next_button.disabled = true
|
||||
if page_label: page_label.text = "Page 1"
|
||||
|
||||
|
||||
func _setup_sort_options() -> void:
|
||||
if not sort_option:
|
||||
return
|
||||
sort_option.clear()
|
||||
sort_option.add_item("Newest", 0)
|
||||
sort_option.add_item("Most Played", 1)
|
||||
sort_option.add_item("Trending", 2)
|
||||
sort_option.selected = 0
|
||||
|
||||
|
||||
func _wire_signals() -> void:
|
||||
if back_button and not back_button.pressed.is_connected(_on_back_pressed):
|
||||
back_button.pressed.connect(_on_back_pressed)
|
||||
if refresh_button and not refresh_button.pressed.is_connected(_on_refresh_pressed):
|
||||
refresh_button.pressed.connect(_on_refresh_pressed)
|
||||
if filter_button and not filter_button.pressed.is_connected(_on_filter_pressed):
|
||||
filter_button.pressed.connect(_on_filter_pressed)
|
||||
if search_edit and not search_edit.text_submitted.is_connected(_on_search_submitted):
|
||||
search_edit.text_submitted.connect(_on_search_submitted)
|
||||
if category_option and not category_option.item_selected.is_connected(_on_category_selected):
|
||||
category_option.item_selected.connect(_on_category_selected)
|
||||
if sort_option and not sort_option.item_selected.is_connected(_on_sort_selected):
|
||||
sort_option.item_selected.connect(_on_sort_selected)
|
||||
if prev_button and not prev_button.pressed.is_connected(_on_prev_page_pressed):
|
||||
prev_button.pressed.connect(_on_prev_page_pressed)
|
||||
if next_button and not next_button.pressed.is_connected(_on_next_page_pressed):
|
||||
next_button.pressed.connect(_on_next_page_pressed)
|
||||
|
||||
|
||||
func load_categories() -> void:
|
||||
if not category_option:
|
||||
return
|
||||
category_option.clear()
|
||||
category_option.add_item("All Categories", 0)
|
||||
|
||||
if _api_client == null:
|
||||
return
|
||||
|
||||
var res: Dictionary = await DiscoveryApiClient.get_categories(_api_client)
|
||||
if res.ok and res.data is Array:
|
||||
var idx := 1
|
||||
for cat in res.data:
|
||||
var cat_str := str(cat).strip_edges()
|
||||
if cat_str != "":
|
||||
category_option.add_item(cat_str, idx)
|
||||
idx += 1
|
||||
|
||||
|
||||
func load_worlds(page: int = 0) -> void:
|
||||
_current_page = page
|
||||
if loading_label: loading_label.visible = true
|
||||
if error_label: error_label.visible = false
|
||||
if empty_label: empty_label.visible = false
|
||||
|
||||
var q := search_edit.text.strip_edges() if search_edit else ""
|
||||
var category := ""
|
||||
if category_option and category_option.selected > 0:
|
||||
category = category_option.get_item_text(category_option.selected)
|
||||
|
||||
var sort := "newest"
|
||||
if sort_option:
|
||||
match sort_option.selected:
|
||||
0: sort = "newest"
|
||||
1: sort = "most_played"
|
||||
2: sort = "trending"
|
||||
|
||||
var skip := _current_page * _page_size
|
||||
var res: Dictionary = await DiscoveryApiClient.get_worlds(
|
||||
_api_client,
|
||||
q,
|
||||
[],
|
||||
category,
|
||||
sort,
|
||||
skip,
|
||||
_page_size
|
||||
)
|
||||
|
||||
if loading_label: loading_label.visible = false
|
||||
|
||||
if not res.ok:
|
||||
if error_label:
|
||||
error_label.text = "Error: %s" % res.detail
|
||||
error_label.visible = true
|
||||
_clear_container()
|
||||
return
|
||||
|
||||
var parsed: DiscoveryApiClient.DiscoveryResult = res.get("parsed")
|
||||
if parsed == null and res.data is Dictionary:
|
||||
parsed = DiscoveryApiClient.DiscoveryResult.from_dict(res.data)
|
||||
|
||||
if parsed == null:
|
||||
if error_label:
|
||||
error_label.text = "Invalid discovery response"
|
||||
error_label.visible = true
|
||||
_clear_container()
|
||||
return
|
||||
|
||||
_total_count = parsed.total
|
||||
_loaded_worlds = parsed.items
|
||||
|
||||
_render_worlds(_loaded_worlds)
|
||||
_update_pagination()
|
||||
worlds_loaded.emit(_loaded_worlds.size())
|
||||
|
||||
|
||||
func _clear_container() -> void:
|
||||
if not worlds_container:
|
||||
return
|
||||
for c in worlds_container.get_children():
|
||||
worlds_container.remove_child(c)
|
||||
c.queue_free()
|
||||
|
||||
|
||||
func _render_worlds(worlds: Array[DiscoveryApiClient.WorldSummary]) -> void:
|
||||
_clear_container()
|
||||
|
||||
if worlds.is_empty():
|
||||
if empty_label:
|
||||
empty_label.text = "No experiences found matching your criteria."
|
||||
empty_label.visible = true
|
||||
return
|
||||
|
||||
if empty_label: empty_label.visible = false
|
||||
|
||||
for world in worlds:
|
||||
var card := _create_world_card(world)
|
||||
worlds_container.add_child(card)
|
||||
|
||||
|
||||
func _create_world_card(world: DiscoveryApiClient.WorldSummary) -> Control:
|
||||
var panel := PanelContainer.new()
|
||||
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
panel.custom_minimum_size = Vector2(0, 90)
|
||||
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.14, 0.16, 0.20, 1.0)
|
||||
style.corner_radius_top_left = 6
|
||||
style.corner_radius_top_right = 6
|
||||
style.corner_radius_bottom_left = 6
|
||||
style.corner_radius_bottom_right = 6
|
||||
style.content_margin_left = 16
|
||||
style.content_margin_right = 16
|
||||
style.content_margin_top = 10
|
||||
style.content_margin_bottom = 10
|
||||
panel.add_theme_stylebox_override("panel", style)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
vbox.add_theme_constant_override("separation", 4)
|
||||
panel.add_child(vbox)
|
||||
|
||||
# Title & Category Row
|
||||
var title_row := HBoxContainer.new()
|
||||
var name_label := Label.new()
|
||||
name_label.text = world.name
|
||||
name_label.add_theme_font_size_override("font_size", 18)
|
||||
name_label.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0, 1.0))
|
||||
title_row.add_child(name_label)
|
||||
|
||||
if world.category != "":
|
||||
var cat_label := Label.new()
|
||||
cat_label.text = " [%s]" % world.category
|
||||
cat_label.add_theme_font_size_override("font_size", 14)
|
||||
cat_label.add_theme_color_override("font_color", Color(0.4, 0.7, 1.0, 1.0))
|
||||
title_row.add_child(cat_label)
|
||||
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
title_row.add_child(spacer)
|
||||
|
||||
# Enter World Button
|
||||
var enter_button := Button.new()
|
||||
enter_button.text = "Enter World"
|
||||
enter_button.custom_minimum_size = Vector2(110, 30)
|
||||
enter_button.pressed.connect(_on_enter_world_pressed.bind(world.id, enter_button))
|
||||
title_row.add_child(enter_button)
|
||||
vbox.add_child(title_row)
|
||||
|
||||
# Description
|
||||
var desc_label := Label.new()
|
||||
desc_label.text = world.description if world.description != "" else "No description provided."
|
||||
desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
desc_label.add_theme_font_size_override("font_size", 13)
|
||||
desc_label.add_theme_color_override("font_color", Color(0.75, 0.75, 0.75, 1.0))
|
||||
vbox.add_child(desc_label)
|
||||
|
||||
# Metadata Row: Tags, Plays, Owner
|
||||
var meta_row := HBoxContainer.new()
|
||||
meta_row.add_theme_constant_override("separation", 16)
|
||||
|
||||
var play_label := Label.new()
|
||||
play_label.text = "%d plays" % world.play_count
|
||||
play_label.add_theme_font_size_override("font_size", 12)
|
||||
play_label.add_theme_color_override("font_color", Color(0.6, 0.9, 0.6, 1.0))
|
||||
meta_row.add_child(play_label)
|
||||
|
||||
if world.tags.size() > 0:
|
||||
var tags_str: String = ""
|
||||
for t in world.tags:
|
||||
tags_str += "#" + str(t) + " "
|
||||
var tags_label := Label.new()
|
||||
tags_label.text = tags_str.strip_edges()
|
||||
tags_label.add_theme_font_size_override("font_size", 12)
|
||||
tags_label.add_theme_color_override("font_color", Color(0.8, 0.7, 0.4, 1.0))
|
||||
meta_row.add_child(tags_label)
|
||||
|
||||
if world.owner_username != "":
|
||||
var owner_label := Label.new()
|
||||
owner_label.text = "Creator: %s" % world.owner_username
|
||||
owner_label.add_theme_font_size_override("font_size", 12)
|
||||
owner_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6, 1.0))
|
||||
meta_row.add_child(owner_label)
|
||||
|
||||
vbox.add_child(meta_row)
|
||||
|
||||
return panel
|
||||
|
||||
|
||||
func _update_pagination() -> void:
|
||||
var total_pages := int(ceil(float(_total_count) / float(_page_size)))
|
||||
if total_pages <= 0:
|
||||
total_pages = 1
|
||||
|
||||
if page_label:
|
||||
page_label.text = "Page %d of %d (%d experiences)" % [_current_page + 1, total_pages, _total_count]
|
||||
|
||||
if prev_button:
|
||||
prev_button.disabled = (_current_page <= 0)
|
||||
if next_button:
|
||||
next_button.disabled = ((_current_page + 1) >= total_pages)
|
||||
|
||||
|
||||
func _on_enter_world_pressed(world_id: int, button: Button) -> void:
|
||||
button.disabled = true
|
||||
button.text = "Joining…"
|
||||
world_selected.emit(world_id)
|
||||
|
||||
# 1. Record visit metric
|
||||
if _api_client != null:
|
||||
await DiscoveryApiClient.record_visit(_api_client, world_id)
|
||||
|
||||
# 2. Join flow (existing game server orchestration)
|
||||
if _api_client != null and _api_client.has_method("join_world"):
|
||||
var result: Dictionary = await _api_client.join_world(world_id)
|
||||
if result.ok and result.data is Dictionary:
|
||||
var d: Dictionary = result.data
|
||||
if has_node("/root/Session"):
|
||||
get_node("/root/Session").begin(world_id, str(d.get("host", "")), int(d.get("port", 0)))
|
||||
get_tree().change_scene_to_file("res://scenes/game.tscn")
|
||||
return
|
||||
|
||||
if result.status == 503:
|
||||
# Dev fallback: local single-player
|
||||
if has_node("/root/Session"):
|
||||
get_node("/root/Session").clear()
|
||||
get_tree().change_scene_to_file("res://scenes/game.tscn")
|
||||
return
|
||||
|
||||
if status_label:
|
||||
status_label.text = "Failed to join: " + str(result.detail)
|
||||
status_label.visible = true
|
||||
else:
|
||||
# Fallback if standalone test or not wired
|
||||
if has_node("/root/Session"):
|
||||
get_node("/root/Session").clear()
|
||||
get_tree().change_scene_to_file("res://scenes/game.tscn")
|
||||
return
|
||||
|
||||
button.disabled = false
|
||||
button.text = "Enter World"
|
||||
|
||||
|
||||
func _on_filter_pressed() -> void:
|
||||
load_worlds(0)
|
||||
|
||||
|
||||
func _on_search_submitted(_new_text: String) -> void:
|
||||
load_worlds(0)
|
||||
|
||||
|
||||
func _on_category_selected(_index: int) -> void:
|
||||
load_worlds(0)
|
||||
|
||||
|
||||
func _on_sort_selected(_index: int) -> void:
|
||||
load_worlds(0)
|
||||
|
||||
|
||||
func _on_refresh_pressed() -> void:
|
||||
load_worlds(_current_page)
|
||||
|
||||
|
||||
func _on_prev_page_pressed() -> void:
|
||||
if _current_page > 0:
|
||||
load_worlds(_current_page - 1)
|
||||
|
||||
|
||||
func _on_next_page_pressed() -> void:
|
||||
var total_pages := int(ceil(float(_total_count) / float(_page_size)))
|
||||
if (_current_page + 1) < total_pages:
|
||||
load_worlds(_current_page + 1)
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
back_requested.emit()
|
||||
if get_tree():
|
||||
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
|
||||
@@ -0,0 +1,142 @@
|
||||
extends RefCounted
|
||||
class_name EconomyApiClient
|
||||
## Helper class for AGP Economy REST endpoints (Wallet, Payments, Marketplace, Payouts).
|
||||
##
|
||||
## docs/planning/economy.md US-6 (task cc09c1428892).
|
||||
|
||||
static func get_api_client(node: Node) -> Node:
|
||||
if node.has_node("/root/ApiClient"):
|
||||
return node.get_node("/root/ApiClient")
|
||||
return null
|
||||
|
||||
static func generate_idempotency_key() -> String:
|
||||
return "%d-%d" % [Time.get_unix_time_from_system(), randi() % 1000000]
|
||||
|
||||
# ── Wallet & Currency ──
|
||||
|
||||
static func get_wallet(api: Node) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/wallet", "", "application/json", true)
|
||||
|
||||
static func get_wallet_transactions(api: Node, limit: int = 50, offset: int = 0) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/wallet/transactions?limit=%d&offset=%d" % [limit, offset], "", "application/json", true)
|
||||
|
||||
# ── Premium Purchases ──
|
||||
|
||||
static func purchase_credits(api: Node, credits: int, idempotency_key: String = "") -> Dictionary:
|
||||
var custom_headers := PackedStringArray()
|
||||
var key := idempotency_key if idempotency_key != "" else generate_idempotency_key()
|
||||
custom_headers.append("Idempotency-Key: " + key)
|
||||
|
||||
var sess_res: Dictionary = await api.request(
|
||||
HTTPClient.METHOD_POST,
|
||||
"/api/payments/purchase",
|
||||
JSON.stringify({"credits": credits}),
|
||||
"application/json",
|
||||
true,
|
||||
custom_headers
|
||||
)
|
||||
if not sess_res.ok:
|
||||
return sess_res
|
||||
|
||||
var sess_id: String = str(sess_res.data.get("session_id", ""))
|
||||
if sess_id == "":
|
||||
return {"ok": false, "status": 0, "data": null, "detail": "Invalid session returned"}
|
||||
|
||||
return await api.request(
|
||||
HTTPClient.METHOD_POST,
|
||||
"/api/payments/confirm",
|
||||
JSON.stringify({"session_id": sess_id}),
|
||||
"application/json",
|
||||
true
|
||||
)
|
||||
|
||||
# ── Catalog Items & Listings ──
|
||||
|
||||
static func get_catalog_items(api: Node, mine: bool = false, item_type: String = "", limit: int = 50, offset: int = 0) -> Dictionary:
|
||||
var q := "/api/marketplace/items?limit=%d&offset=%d" % [limit, offset]
|
||||
if mine:
|
||||
q += "&mine=true"
|
||||
if item_type != "":
|
||||
q += "&item_type=" + item_type.uri_encode()
|
||||
return await api.request(HTTPClient.METHOD_GET, q, "", "application/json", true)
|
||||
|
||||
static func create_catalog_item(api: Node, name: String, item_type: String, description: String = "", metadata_json: Dictionary = {}) -> Dictionary:
|
||||
var payload := {
|
||||
"name": name,
|
||||
"item_type": item_type,
|
||||
"description": description,
|
||||
"metadata_json": metadata_json
|
||||
}
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/marketplace/items", JSON.stringify(payload), "application/json", true)
|
||||
|
||||
static func create_listing(api: Node, catalog_item_id: int, price: int) -> Dictionary:
|
||||
var payload := {
|
||||
"catalog_item_id": catalog_item_id,
|
||||
"price": price
|
||||
}
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/marketplace/listings", JSON.stringify(payload), "application/json", true)
|
||||
|
||||
static func search_listings(api: Node, query: String = "", item_type: String = "", min_price: int = -1, max_price: int = -1, limit: int = 50, offset: int = 0) -> Dictionary:
|
||||
var q := "/api/marketplace/listings?limit=%d&offset=%d" % [limit, offset]
|
||||
if query != "":
|
||||
q += "&q=" + query.uri_encode()
|
||||
if item_type != "":
|
||||
q += "&item_type=" + item_type.uri_encode()
|
||||
if min_price >= 0:
|
||||
q += "&min_price=%d" % min_price
|
||||
if max_price >= 0:
|
||||
q += "&max_price=%d" % max_price
|
||||
return await api.request(HTTPClient.METHOD_GET, q, "", "application/json", false)
|
||||
|
||||
static func get_listing(api: Node, listing_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/marketplace/listings/%d" % listing_id, "", "application/json", false)
|
||||
|
||||
static func update_listing(api: Node, listing_id: int, price: int = -1, status: String = "") -> Dictionary:
|
||||
var payload := {}
|
||||
if price > 0:
|
||||
payload["price"] = price
|
||||
if status != "":
|
||||
payload["status"] = status
|
||||
return await api.request(HTTPClient.METHOD_PATCH, "/api/marketplace/listings/%d" % listing_id, JSON.stringify(payload), "application/json", true)
|
||||
|
||||
static func delist_listing(api: Node, listing_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_DELETE, "/api/marketplace/listings/%d" % listing_id, "", "application/json", true)
|
||||
|
||||
# ── Purchases & Escrow ──
|
||||
|
||||
static func purchase_listing(api: Node, listing_id: int, idempotency_key: String = "") -> Dictionary:
|
||||
var custom_headers := PackedStringArray()
|
||||
var key := idempotency_key if idempotency_key != "" else generate_idempotency_key()
|
||||
custom_headers.append("Idempotency-Key: " + key)
|
||||
|
||||
return await api.request(
|
||||
HTTPClient.METHOD_POST,
|
||||
"/api/marketplace/listings/%d/purchase" % listing_id,
|
||||
"",
|
||||
"application/json",
|
||||
true,
|
||||
custom_headers
|
||||
)
|
||||
|
||||
static func get_purchases(api: Node, role: String = "all", limit: int = 50, offset: int = 0) -> Dictionary:
|
||||
return await api.request(
|
||||
HTTPClient.METHOD_GET,
|
||||
"/api/marketplace/purchases?role=%s&limit=%d&offset=%d" % [role.uri_encode(), limit, offset],
|
||||
"",
|
||||
"application/json",
|
||||
true
|
||||
)
|
||||
|
||||
# ── Creator Balances & Payouts ──
|
||||
|
||||
static func get_creator_balance(api: Node) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/marketplace/creator/balance", "", "application/json", true)
|
||||
|
||||
static func request_payout(api: Node, amount: int, external_ref: String = "") -> Dictionary:
|
||||
var payload := {"amount": amount}
|
||||
if external_ref != "":
|
||||
payload["external_ref"] = external_ref
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/payouts", JSON.stringify(payload), "application/json", true)
|
||||
|
||||
static func get_payouts(api: Node, limit: int = 50, offset: int = 0) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/payouts?limit=%d&offset=%d" % [limit, offset], "", "application/json", true)
|
||||
@@ -0,0 +1,444 @@
|
||||
extends Control
|
||||
class_name FriendsPresenceUI
|
||||
## Friends & Presence UI panel.
|
||||
##
|
||||
## Displays:
|
||||
## - Friends list with real-time online/offline presence & custom status
|
||||
## - Friend requests inbox (with Accept / Decline / Send actions)
|
||||
## - Following list (with Unfollow / Follow actions)
|
||||
## - Blocked users list (with Unblock / Block actions)
|
||||
## - Custom status updater
|
||||
|
||||
signal back_requested
|
||||
|
||||
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
|
||||
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
|
||||
@onready var status_label: Label = $VBoxContainer/StatusMessageLabel
|
||||
|
||||
@onready var tab_container: TabContainer = $VBoxContainer/TabContainer
|
||||
@onready var friends_container: VBoxContainer = $VBoxContainer/TabContainer/Friends/Scroll/FriendsList
|
||||
@onready var requests_container: VBoxContainer = $VBoxContainer/TabContainer/Requests/Scroll/RequestsList
|
||||
@onready var following_container: VBoxContainer = $VBoxContainer/TabContainer/Following/Scroll/FollowingList
|
||||
@onready var blocked_container: VBoxContainer = $VBoxContainer/TabContainer/Blocked/Scroll/BlockedList
|
||||
|
||||
@onready var custom_status_input: LineEdit = $VBoxContainer/MyStatusHBox/StatusLineEdit
|
||||
@onready var set_status_button: Button = $VBoxContainer/MyStatusHBox/SetStatusButton
|
||||
|
||||
# Friend request input
|
||||
@onready var send_req_input: LineEdit = $VBoxContainer/TabContainer/Requests/SendHBox/TargetUserIdEdit
|
||||
@onready var send_req_button: Button = $VBoxContainer/TabContainer/Requests/SendHBox/SendButton
|
||||
|
||||
# Follow input
|
||||
@onready var follow_input: LineEdit = $VBoxContainer/TabContainer/Following/FollowHBox/FollowUserIdEdit
|
||||
@onready var follow_button: Button = $VBoxContainer/TabContainer/Following/FollowHBox/FollowButton
|
||||
|
||||
# Block input
|
||||
@onready var block_input: LineEdit = $VBoxContainer/TabContainer/Blocked/BlockHBox/BlockUserIdEdit
|
||||
@onready var block_button: Button = $VBoxContainer/TabContainer/Blocked/BlockHBox/BlockButton
|
||||
|
||||
var _api_client: Node = null
|
||||
var _friend_presence_map: Dictionary = {} # user_id (int) -> {"status": String, "custom_status": String, "last_seen": String}
|
||||
|
||||
|
||||
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:
|
||||
back_button.pressed.connect(_on_back_pressed)
|
||||
if refresh_button:
|
||||
refresh_button.pressed.connect(refresh_all)
|
||||
if set_status_button:
|
||||
set_status_button.pressed.connect(_on_set_status_pressed)
|
||||
if send_req_button:
|
||||
send_req_button.pressed.connect(_on_send_req_pressed)
|
||||
if follow_button:
|
||||
follow_button.pressed.connect(_on_follow_pressed)
|
||||
if block_button:
|
||||
block_button.pressed.connect(_on_block_pressed)
|
||||
|
||||
if _api_client != null and _api_client.ws != null:
|
||||
if not _api_client.ws.presence_changed.is_connected(_on_presence_changed):
|
||||
_api_client.ws.presence_changed.connect(_on_presence_changed)
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
back_requested.emit()
|
||||
if get_tree().current_scene == self:
|
||||
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
|
||||
|
||||
|
||||
func show_message(text: String, is_error: bool = false) -> void:
|
||||
if status_label:
|
||||
status_label.text = text
|
||||
status_label.modulate = Color(1.0, 0.4, 0.4) if is_error else Color(0.4, 1.0, 0.4)
|
||||
|
||||
|
||||
func refresh_all() -> void:
|
||||
if _api_client == null or not _api_client.has_token():
|
||||
show_message("Please log in to view social features.", true)
|
||||
return
|
||||
|
||||
await refresh_friends()
|
||||
await refresh_requests()
|
||||
await refresh_following()
|
||||
await refresh_blocked()
|
||||
|
||||
|
||||
# ── Friends & Presence ──
|
||||
|
||||
func refresh_friends() -> void:
|
||||
if _api_client == null:
|
||||
return
|
||||
|
||||
# Fetch mutual presence first
|
||||
var pres_res: Dictionary = await _api_client.get_friends_presence()
|
||||
_friend_presence_map.clear()
|
||||
if pres_res.ok and pres_res.data is Dictionary and pres_res.data.has("friends"):
|
||||
for p in pres_res.data["friends"]:
|
||||
var uid: int = int(p.get("user_id", 0))
|
||||
_friend_presence_map[uid] = p
|
||||
|
||||
# Fetch friends list
|
||||
var res: Dictionary = await _api_client.get_friends()
|
||||
_clear_children(friends_container)
|
||||
|
||||
if not res.ok:
|
||||
show_message("Failed to load friends: " + res.detail, true)
|
||||
return
|
||||
|
||||
var friends_list: Array = []
|
||||
if res.data is Dictionary and res.data.has("friends"):
|
||||
friends_list = res.data["friends"]
|
||||
|
||||
if friends_list.is_empty():
|
||||
var empty_label := Label.new()
|
||||
empty_label.text = "No friends yet. Send a friend request to get started!"
|
||||
empty_label.modulate = Color(0.7, 0.7, 0.7)
|
||||
friends_container.add_child(empty_label)
|
||||
return
|
||||
|
||||
for friend in friends_list:
|
||||
var uid: int = int(friend.get("id", 0))
|
||||
var uname: String = str(friend.get("username", "Unknown"))
|
||||
_build_friend_row(uid, uname)
|
||||
|
||||
|
||||
func _build_friend_row(uid: int, uname: String) -> void:
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.name = "FriendRow_%d" % uid
|
||||
hbox.add_theme_constant_override("separation", 12)
|
||||
|
||||
var pres: Dictionary = _friend_presence_map.get(uid, {})
|
||||
var is_online: bool = pres.get("status", "offline") == "online"
|
||||
var custom_status: String = str(pres.get("custom_status", ""))
|
||||
var last_seen: String = str(pres.get("last_seen", ""))
|
||||
|
||||
# Status indicator dot
|
||||
var status_dot := Label.new()
|
||||
status_dot.text = "●"
|
||||
status_dot.modulate = Color(0.2, 0.9, 0.2) if is_online else Color(0.5, 0.5, 0.5)
|
||||
hbox.add_child(status_dot)
|
||||
|
||||
# Username label
|
||||
var name_label := Label.new()
|
||||
name_label.text = uname
|
||||
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
hbox.add_child(name_label)
|
||||
|
||||
# Presence info
|
||||
var pres_label := Label.new()
|
||||
if is_online:
|
||||
pres_label.text = "Online" + ((" - " + custom_status) if custom_status != "" else "")
|
||||
pres_label.modulate = Color(0.3, 0.9, 0.3)
|
||||
else:
|
||||
pres_label.text = "Offline" + ((" (" + last_seen.left(10) + ")") if last_seen != "" else "")
|
||||
pres_label.modulate = Color(0.6, 0.6, 0.6)
|
||||
pres_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
hbox.add_child(pres_label)
|
||||
|
||||
# Action: Unfriend
|
||||
var unfriend_btn := Button.new()
|
||||
unfriend_btn.text = "Unfriend"
|
||||
unfriend_btn.pressed.connect(func(): _on_unfriend(uid))
|
||||
hbox.add_child(unfriend_btn)
|
||||
|
||||
# Action: Block
|
||||
var block_btn := Button.new()
|
||||
block_btn.text = "Block"
|
||||
block_btn.pressed.connect(func(): _on_block_friend(uid))
|
||||
hbox.add_child(block_btn)
|
||||
|
||||
friends_container.add_child(hbox)
|
||||
|
||||
|
||||
func _on_unfriend(uid: int) -> void:
|
||||
var res: Dictionary = await _api_client.unfriend(uid)
|
||||
if res.ok:
|
||||
show_message("Unfriended user.")
|
||||
refresh_friends()
|
||||
else:
|
||||
show_message("Failed to unfriend: " + res.detail, true)
|
||||
|
||||
|
||||
func _on_block_friend(uid: int) -> void:
|
||||
var res: Dictionary = await _api_client.block_user(uid)
|
||||
if res.ok:
|
||||
show_message("Blocked user.")
|
||||
refresh_friends()
|
||||
refresh_blocked()
|
||||
else:
|
||||
show_message("Failed to block user: " + res.detail, true)
|
||||
|
||||
|
||||
func _on_presence_changed(uid: int, p_status: String) -> void:
|
||||
if not _friend_presence_map.has(uid):
|
||||
_friend_presence_map[uid] = {}
|
||||
_friend_presence_map[uid]["status"] = p_status
|
||||
# Refresh UI if row exists
|
||||
var row_name := "FriendRow_%d" % uid
|
||||
if friends_container.has_node(row_name):
|
||||
refresh_friends()
|
||||
|
||||
|
||||
func _on_set_status_pressed() -> void:
|
||||
if _api_client == null:
|
||||
return
|
||||
var new_status := custom_status_input.text.strip_edges()
|
||||
var res: Dictionary = await _api_client.send_presence_heartbeat(new_status)
|
||||
if res.ok:
|
||||
show_message("Status updated.")
|
||||
else:
|
||||
show_message("Failed to update status: " + res.detail, true)
|
||||
|
||||
|
||||
# ── Requests ──
|
||||
|
||||
func refresh_requests() -> void:
|
||||
if _api_client == null:
|
||||
return
|
||||
var res: Dictionary = await _api_client.get_friend_requests()
|
||||
_clear_children(requests_container)
|
||||
|
||||
if not res.ok:
|
||||
show_message("Failed to load requests: " + res.detail, true)
|
||||
return
|
||||
|
||||
var req_list: Array = []
|
||||
if res.data is Dictionary and res.data.has("requests"):
|
||||
req_list = res.data["requests"]
|
||||
|
||||
if req_list.is_empty():
|
||||
var empty_label := Label.new()
|
||||
empty_label.text = "No pending friend requests."
|
||||
empty_label.modulate = Color(0.7, 0.7, 0.7)
|
||||
requests_container.add_child(empty_label)
|
||||
return
|
||||
|
||||
for req in req_list:
|
||||
var req_id: int = int(req.get("id", 0))
|
||||
var from_id: int = int(req.get("requester_id", 0))
|
||||
var created_at: String = str(req.get("created_at", "")).left(10)
|
||||
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 12)
|
||||
|
||||
var lbl := Label.new()
|
||||
lbl.text = "Request from User #%d (%s)" % [from_id, created_at]
|
||||
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
hbox.add_child(lbl)
|
||||
|
||||
var accept_btn := Button.new()
|
||||
accept_btn.text = "Accept"
|
||||
accept_btn.pressed.connect(func(): _on_accept_request(req_id))
|
||||
hbox.add_child(accept_btn)
|
||||
|
||||
var decline_btn := Button.new()
|
||||
decline_btn.text = "Decline"
|
||||
decline_btn.pressed.connect(func(): _on_decline_request(req_id))
|
||||
hbox.add_child(decline_btn)
|
||||
|
||||
requests_container.add_child(hbox)
|
||||
|
||||
|
||||
func _on_accept_request(req_id: int) -> void:
|
||||
var res: Dictionary = await _api_client.accept_friend_request(req_id)
|
||||
if res.ok:
|
||||
show_message("Friend request accepted!")
|
||||
refresh_requests()
|
||||
refresh_friends()
|
||||
else:
|
||||
show_message("Failed to accept: " + res.detail, true)
|
||||
|
||||
|
||||
func _on_decline_request(req_id: int) -> void:
|
||||
var res: Dictionary = await _api_client.decline_friend_request(req_id)
|
||||
if res.ok:
|
||||
show_message("Friend request declined.")
|
||||
refresh_requests()
|
||||
else:
|
||||
show_message("Failed to decline: " + res.detail, true)
|
||||
|
||||
|
||||
func _on_send_req_pressed() -> void:
|
||||
var target_text := send_req_input.text.strip_edges()
|
||||
if not target_text.is_valid_int():
|
||||
show_message("Enter a valid integer user ID.", true)
|
||||
return
|
||||
var target_id := int(target_text)
|
||||
var res: Dictionary = await _api_client.send_friend_request(target_id)
|
||||
if res.ok:
|
||||
show_message("Friend request sent to User #%d!" % target_id)
|
||||
send_req_input.text = ""
|
||||
else:
|
||||
show_message("Failed to send request: " + res.detail, true)
|
||||
|
||||
|
||||
# ── Following ──
|
||||
|
||||
func refresh_following() -> void:
|
||||
if _api_client == null:
|
||||
return
|
||||
var res: Dictionary = await _api_client.get_following()
|
||||
_clear_children(following_container)
|
||||
|
||||
if not res.ok:
|
||||
return
|
||||
|
||||
var list: Array = []
|
||||
if res.data is Dictionary and res.data.has("following"):
|
||||
list = res.data["following"]
|
||||
|
||||
if list.is_empty():
|
||||
var empty_label := Label.new()
|
||||
empty_label.text = "You are not following anyone."
|
||||
empty_label.modulate = Color(0.7, 0.7, 0.7)
|
||||
following_container.add_child(empty_label)
|
||||
return
|
||||
|
||||
for u in list:
|
||||
var uid: int = int(u.get("id", 0))
|
||||
var uname: String = str(u.get("username", "Unknown"))
|
||||
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 12)
|
||||
|
||||
var lbl := Label.new()
|
||||
lbl.text = "%s (ID: %d)" % [uname, uid]
|
||||
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
hbox.add_child(lbl)
|
||||
|
||||
var unfollow_btn := Button.new()
|
||||
unfollow_btn.text = "Unfollow"
|
||||
unfollow_btn.pressed.connect(func(): _on_unfollow(uid))
|
||||
hbox.add_child(unfollow_btn)
|
||||
|
||||
following_container.add_child(hbox)
|
||||
|
||||
|
||||
func _on_unfollow(uid: int) -> void:
|
||||
var res: Dictionary = await _api_client.unfollow_user(uid)
|
||||
if res.ok:
|
||||
show_message("Unfollowed User #%d." % uid)
|
||||
refresh_following()
|
||||
else:
|
||||
show_message("Failed to unfollow: " + res.detail, true)
|
||||
|
||||
|
||||
func _on_follow_pressed() -> void:
|
||||
var target_text := follow_input.text.strip_edges()
|
||||
if not target_text.is_valid_int():
|
||||
show_message("Enter a valid integer user ID.", true)
|
||||
return
|
||||
var target_id := int(target_text)
|
||||
var res: Dictionary = await _api_client.follow_user(target_id)
|
||||
if res.ok:
|
||||
show_message("Following User #%d." % target_id)
|
||||
follow_input.text = ""
|
||||
refresh_following()
|
||||
else:
|
||||
show_message("Failed to follow: " + res.detail, true)
|
||||
|
||||
|
||||
# ── Blocked ──
|
||||
|
||||
func refresh_blocked() -> void:
|
||||
if _api_client == null:
|
||||
return
|
||||
var res: Dictionary = await _api_client.get_blocked()
|
||||
_clear_children(blocked_container)
|
||||
|
||||
if not res.ok:
|
||||
return
|
||||
|
||||
var list: Array = []
|
||||
if res.data is Dictionary and res.data.has("blocked"):
|
||||
list = res.data["blocked"]
|
||||
|
||||
if list.is_empty():
|
||||
var empty_label := Label.new()
|
||||
empty_label.text = "No blocked users."
|
||||
empty_label.modulate = Color(0.7, 0.7, 0.7)
|
||||
blocked_container.add_child(empty_label)
|
||||
return
|
||||
|
||||
for u in list:
|
||||
var uid: int = int(u.get("id", 0))
|
||||
var uname: String = str(u.get("username", "Unknown"))
|
||||
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 12)
|
||||
|
||||
var lbl := Label.new()
|
||||
lbl.text = "%s (ID: %d)" % [uname, uid]
|
||||
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
hbox.add_child(lbl)
|
||||
|
||||
var unblock_btn := Button.new()
|
||||
unblock_btn.text = "Unblock"
|
||||
unblock_btn.pressed.connect(func(): _on_unblock(uid))
|
||||
hbox.add_child(unblock_btn)
|
||||
|
||||
blocked_container.add_child(hbox)
|
||||
|
||||
|
||||
func _on_unblock(uid: int) -> void:
|
||||
var res: Dictionary = await _api_client.unblock_user(uid)
|
||||
if res.ok:
|
||||
show_message("Unblocked User #%d." % uid)
|
||||
refresh_blocked()
|
||||
else:
|
||||
show_message("Failed to unblock: " + res.detail, true)
|
||||
|
||||
|
||||
func _on_block_pressed() -> void:
|
||||
var target_text := block_input.text.strip_edges()
|
||||
if not target_text.is_valid_int():
|
||||
show_message("Enter a valid integer user ID.", true)
|
||||
return
|
||||
var target_id := int(target_text)
|
||||
var res: Dictionary = await _api_client.block_user(target_id)
|
||||
if res.ok:
|
||||
show_message("Blocked User #%d." % target_id)
|
||||
block_input.text = ""
|
||||
refresh_blocked()
|
||||
refresh_friends()
|
||||
else:
|
||||
show_message("Failed to block: " + res.detail, true)
|
||||
|
||||
|
||||
func _clear_children(node: Node) -> void:
|
||||
if node == null:
|
||||
return
|
||||
for c in node.get_children():
|
||||
c.queue_free()
|
||||
@@ -0,0 +1,357 @@
|
||||
extends Control
|
||||
class_name GroupsUI
|
||||
## Groups & Communities UI.
|
||||
##
|
||||
## Features:
|
||||
## - Browse groups with pagination and "My Groups" filter
|
||||
## - Create new group (creator becomes owner)
|
||||
## - Detailed group page: description, member count, member list with roles
|
||||
## - Join and Leave group actions
|
||||
## - Role management for owner (promote to admin, demote to member, transfer ownership)
|
||||
## - Delete group (owner only)
|
||||
|
||||
signal back_requested
|
||||
|
||||
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
|
||||
@onready var title_label: Label = $VBoxContainer/HeaderHBox/TitleLabel
|
||||
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
|
||||
@onready var status_label: Label = $VBoxContainer/StatusMessageLabel
|
||||
|
||||
@onready var tab_container: TabContainer = $VBoxContainer/TabContainer
|
||||
|
||||
# Browse controls
|
||||
@onready var my_groups_check: CheckBox = $VBoxContainer/TabContainer/Browse/FilterHBox/MyGroupsCheck
|
||||
@onready var groups_list_container: VBoxContainer = $VBoxContainer/TabContainer/Browse/Scroll/GroupsList
|
||||
|
||||
# Create controls
|
||||
@onready var new_group_name_edit: LineEdit = $VBoxContainer/TabContainer/Create/FormVBox/NameEdit
|
||||
@onready var new_group_desc_edit: TextEdit = $VBoxContainer/TabContainer/Create/FormVBox/DescEdit
|
||||
@onready var create_group_btn: Button = $VBoxContainer/TabContainer/Create/FormVBox/CreateButton
|
||||
|
||||
# Group Page controls
|
||||
@onready var group_page_vbox: VBoxContainer = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox
|
||||
@onready var group_name_label: Label = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/HeaderHBox/GroupNameLabel
|
||||
@onready var member_count_label: Label = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/HeaderHBox/MemberCountLabel
|
||||
@onready var group_desc_label: Label = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/GroupDescLabel
|
||||
@onready var join_btn: Button = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox/JoinButton
|
||||
@onready var leave_btn: Button = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox/LeaveButton
|
||||
@onready var delete_btn: Button = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox/DeleteButton
|
||||
@onready var members_list_container: VBoxContainer = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/MembersVBox/MembersList
|
||||
|
||||
var _api_client: Node = null
|
||||
var _current_user_id: int = 0
|
||||
var _active_group_id: int = 0
|
||||
var _active_group_data: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_api_client = _get_api()
|
||||
_wire_signals()
|
||||
if _api_client != null and _api_client.has_token():
|
||||
_init_user()
|
||||
refresh_groups()
|
||||
|
||||
|
||||
func _get_api() -> Node:
|
||||
if has_node("/root/ApiClient"):
|
||||
return get_node("/root/ApiClient")
|
||||
return null
|
||||
|
||||
|
||||
func _init_user() -> void:
|
||||
if _api_client == null:
|
||||
return
|
||||
var me_res: Dictionary = await _api_client.get_me()
|
||||
if me_res.ok and me_res.data is Dictionary:
|
||||
_current_user_id = int(me_res.data.get("id", 0))
|
||||
|
||||
|
||||
func _wire_signals() -> void:
|
||||
if back_button:
|
||||
back_button.pressed.connect(_on_back_pressed)
|
||||
if refresh_button:
|
||||
refresh_button.pressed.connect(refresh_groups)
|
||||
if my_groups_check:
|
||||
my_groups_check.toggled.connect(func(_t): refresh_groups())
|
||||
if create_group_btn:
|
||||
create_group_btn.pressed.connect(_on_create_group_pressed)
|
||||
if join_btn:
|
||||
join_btn.pressed.connect(_on_join_pressed)
|
||||
if leave_btn:
|
||||
leave_btn.pressed.connect(_on_leave_pressed)
|
||||
if delete_btn:
|
||||
delete_btn.pressed.connect(_on_delete_pressed)
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
back_requested.emit()
|
||||
if get_tree().current_scene == self:
|
||||
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
|
||||
|
||||
|
||||
func show_message(text: String, is_error: bool = false) -> void:
|
||||
if status_label:
|
||||
status_label.text = text
|
||||
status_label.modulate = Color(1.0, 0.4, 0.4) if is_error else Color(0.4, 1.0, 0.4)
|
||||
|
||||
|
||||
# ── Browse Groups ──
|
||||
|
||||
func refresh_groups() -> void:
|
||||
if _api_client == null or not _api_client.has_token():
|
||||
return
|
||||
var mine: bool = my_groups_check.button_pressed if my_groups_check else false
|
||||
var res: Dictionary = await _api_client.list_groups(1, 50, mine)
|
||||
_clear_children(groups_list_container)
|
||||
|
||||
if not res.ok:
|
||||
show_message("Failed to load groups: " + res.detail, true)
|
||||
return
|
||||
|
||||
var groups: Array = []
|
||||
if res.data is Dictionary and res.data.has("groups"):
|
||||
groups = res.data["groups"]
|
||||
elif res.data is Array:
|
||||
groups = res.data
|
||||
|
||||
if groups.is_empty():
|
||||
var empty_lbl := Label.new()
|
||||
empty_lbl.text = "No groups found. Create one in the Create Group tab!"
|
||||
empty_lbl.modulate = Color(0.6, 0.6, 0.6)
|
||||
groups_list_container.add_child(empty_lbl)
|
||||
return
|
||||
|
||||
for g in groups:
|
||||
var gid: int = int(g.get("id", 0))
|
||||
var gname: String = str(g.get("name", "Group"))
|
||||
var gdesc: String = str(g.get("description", ""))
|
||||
var count: int = int(g.get("member_count", 0))
|
||||
_build_group_card(gid, gname, gdesc, count)
|
||||
|
||||
|
||||
func _build_group_card(gid: int, gname: String, gdesc: String, count: int) -> void:
|
||||
var panel := PanelContainer.new()
|
||||
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)
|
||||
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 16)
|
||||
|
||||
var info_vbox := VBoxContainer.new()
|
||||
info_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
|
||||
var name_lbl := Label.new()
|
||||
name_lbl.text = gname
|
||||
name_lbl.add_theme_font_size_override("font_size", 18)
|
||||
info_vbox.add_child(name_lbl)
|
||||
|
||||
if gdesc != "":
|
||||
var desc_lbl := Label.new()
|
||||
desc_lbl.text = gdesc.left(100)
|
||||
desc_lbl.modulate = Color(0.8, 0.8, 0.8)
|
||||
info_vbox.add_child(desc_lbl)
|
||||
|
||||
var count_lbl := Label.new()
|
||||
count_lbl.text = "%d member%s" % [count, "" if count == 1 else "s"]
|
||||
count_lbl.modulate = Color(0.6, 0.8, 1.0)
|
||||
info_vbox.add_child(count_lbl)
|
||||
|
||||
hbox.add_child(info_vbox)
|
||||
|
||||
var view_btn := Button.new()
|
||||
view_btn.text = "View Group"
|
||||
view_btn.custom_minimum_size = Vector2(110, 36)
|
||||
view_btn.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
view_btn.pressed.connect(func(): open_group_page(gid))
|
||||
hbox.add_child(view_btn)
|
||||
|
||||
margin.add_child(hbox)
|
||||
panel.add_child(margin)
|
||||
groups_list_container.add_child(panel)
|
||||
|
||||
|
||||
# ── Create Group ──
|
||||
|
||||
func _on_create_group_pressed() -> void:
|
||||
if _api_client == null:
|
||||
return
|
||||
var gname := new_group_name_edit.text.strip_edges()
|
||||
var gdesc := new_group_desc_edit.text.strip_edges()
|
||||
if gname == "":
|
||||
show_message("Group name cannot be empty.", true)
|
||||
return
|
||||
|
||||
var res: Dictionary = await _api_client.create_group(gname, gdesc)
|
||||
if res.ok and res.data is Dictionary:
|
||||
var gid: int = int(res.data.get("id", 0))
|
||||
new_group_name_edit.text = ""
|
||||
new_group_desc_edit.text = ""
|
||||
show_message("Group '%s' created!" % gname)
|
||||
await refresh_groups()
|
||||
open_group_page(gid)
|
||||
else:
|
||||
show_message("Failed to create group: " + res.detail, true)
|
||||
|
||||
|
||||
# ── Group Page Details ──
|
||||
|
||||
func open_group_page(group_id: int) -> void:
|
||||
_active_group_id = group_id
|
||||
if tab_container:
|
||||
tab_container.current_tab = 2 # Details tab
|
||||
await refresh_group_page(group_id)
|
||||
|
||||
|
||||
func refresh_group_page(group_id: int) -> void:
|
||||
if _api_client == null or group_id <= 0:
|
||||
return
|
||||
var res: Dictionary = await _api_client.get_group(group_id)
|
||||
if not res.ok:
|
||||
show_message("Failed to load group: " + res.detail, true)
|
||||
return
|
||||
|
||||
_active_group_data = res.data if res.data is Dictionary else {}
|
||||
var gname: String = str(_active_group_data.get("name", "Group"))
|
||||
var gdesc: String = str(_active_group_data.get("description", "No description provided."))
|
||||
var members: Array = _active_group_data.get("members", [])
|
||||
var count: int = int(_active_group_data.get("member_count", members.size()))
|
||||
var owner_id: int = int(_active_group_data.get("owner_id", 0))
|
||||
|
||||
if group_name_label:
|
||||
group_name_label.text = gname
|
||||
if group_desc_label:
|
||||
group_desc_label.text = gdesc
|
||||
if member_count_label:
|
||||
member_count_label.text = "%d Members" % count
|
||||
|
||||
# Check membership of current user
|
||||
var is_member := false
|
||||
var my_role := ""
|
||||
for m in members:
|
||||
var uid: int = int(m.get("user_id", 0))
|
||||
if uid == _current_user_id:
|
||||
is_member = true
|
||||
my_role = str(m.get("role", "member"))
|
||||
break
|
||||
|
||||
var is_owner := (_current_user_id == owner_id) or my_role == "owner"
|
||||
|
||||
if join_btn:
|
||||
join_btn.visible = not is_member
|
||||
if leave_btn:
|
||||
leave_btn.visible = is_member and not is_owner
|
||||
if delete_btn:
|
||||
delete_btn.visible = is_owner
|
||||
|
||||
_populate_members_list(members, is_owner)
|
||||
|
||||
|
||||
func _populate_members_list(members: Array, can_manage_roles: bool) -> void:
|
||||
_clear_children(members_list_container)
|
||||
|
||||
for m in members:
|
||||
var uid: int = int(m.get("user_id", 0))
|
||||
var uname: String = "User #%d" % uid
|
||||
if m.has("user") and m["user"] is Dictionary:
|
||||
uname = str(m["user"].get("username", uname))
|
||||
var role: String = str(m.get("role", "member"))
|
||||
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 12)
|
||||
|
||||
var name_lbl := Label.new()
|
||||
name_lbl.text = "%s (ID: %d)" % [uname, uid]
|
||||
name_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
hbox.add_child(name_lbl)
|
||||
|
||||
var role_badge := Label.new()
|
||||
role_badge.text = "[%s]" % role.to_upper()
|
||||
if role == "owner":
|
||||
role_badge.modulate = Color(1.0, 0.8, 0.2)
|
||||
elif role == "admin":
|
||||
role_badge.modulate = Color(0.4, 0.8, 1.0)
|
||||
else:
|
||||
role_badge.modulate = Color(0.7, 0.7, 0.7)
|
||||
hbox.add_child(role_badge)
|
||||
|
||||
# Role selector for owner (excluding self/owner)
|
||||
if can_manage_roles and role != "owner":
|
||||
var opt := OptionButton.new()
|
||||
opt.add_item("Member")
|
||||
opt.add_item("Admin")
|
||||
opt.selected = 1 if role == "admin" else 0
|
||||
opt.item_selected.connect(func(idx: int): _on_role_changed(uid, "admin" if idx == 1 else "member"))
|
||||
hbox.add_child(opt)
|
||||
|
||||
var xfer_btn := Button.new()
|
||||
xfer_btn.text = "Make Owner"
|
||||
xfer_btn.pressed.connect(func(): _on_transfer_ownership(uid))
|
||||
hbox.add_child(xfer_btn)
|
||||
|
||||
members_list_container.add_child(hbox)
|
||||
|
||||
|
||||
func _on_join_pressed() -> void:
|
||||
if _api_client == null or _active_group_id <= 0:
|
||||
return
|
||||
var res: Dictionary = await _api_client.join_group(_active_group_id)
|
||||
if res.ok:
|
||||
show_message("Joined group!")
|
||||
refresh_group_page(_active_group_id)
|
||||
else:
|
||||
show_message("Failed to join group: " + res.detail, true)
|
||||
|
||||
|
||||
func _on_leave_pressed() -> void:
|
||||
if _api_client == null or _active_group_id <= 0:
|
||||
return
|
||||
var res: Dictionary = await _api_client.leave_group(_active_group_id)
|
||||
if res.ok:
|
||||
show_message("Left group.")
|
||||
refresh_group_page(_active_group_id)
|
||||
else:
|
||||
show_message("Failed to leave group: " + res.detail, true)
|
||||
|
||||
|
||||
func _on_delete_pressed() -> void:
|
||||
if _api_client == null or _active_group_id <= 0:
|
||||
return
|
||||
var res: Dictionary = await _api_client.delete_group(_active_group_id)
|
||||
if res.ok:
|
||||
show_message("Group deleted.")
|
||||
tab_container.current_tab = 0
|
||||
refresh_groups()
|
||||
else:
|
||||
show_message("Failed to delete group: " + res.detail, true)
|
||||
|
||||
|
||||
func _on_role_changed(uid: int, new_role: String) -> void:
|
||||
if _api_client == null or _active_group_id <= 0:
|
||||
return
|
||||
var res: Dictionary = await _api_client.set_group_member_role(_active_group_id, uid, new_role)
|
||||
if res.ok:
|
||||
show_message("Role updated to %s." % new_role)
|
||||
refresh_group_page(_active_group_id)
|
||||
else:
|
||||
show_message("Failed to change role: " + res.detail, true)
|
||||
|
||||
|
||||
func _on_transfer_ownership(new_owner_id: int) -> void:
|
||||
if _api_client == null or _active_group_id <= 0:
|
||||
return
|
||||
var res: Dictionary = await _api_client.transfer_group_ownership(_active_group_id, new_owner_id)
|
||||
if res.ok:
|
||||
show_message("Ownership transferred to User #%d!" % new_owner_id)
|
||||
refresh_group_page(_active_group_id)
|
||||
else:
|
||||
show_message("Failed to transfer ownership: " + res.detail, true)
|
||||
|
||||
|
||||
func _clear_children(node: Node) -> void:
|
||||
if node == null:
|
||||
return
|
||||
for c in node.get_children():
|
||||
c.queue_free()
|
||||
@@ -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,312 @@
|
||||
extends Control
|
||||
class_name ProfileUI
|
||||
## Profile Page UI.
|
||||
##
|
||||
## Displays:
|
||||
## - User avatar representation, username, and bio
|
||||
## - Favorite experiences (worlds) list
|
||||
## - Public/mutual friends list
|
||||
## - Respects server-enforced privacy controls and blocked-user restricted view
|
||||
## - Allows authenticated user to edit their own bio and privacy settings
|
||||
|
||||
signal back_requested
|
||||
|
||||
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
|
||||
@onready var title_label: Label = $VBoxContainer/HeaderHBox/TitleLabel
|
||||
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
|
||||
@onready var status_label: Label = $VBoxContainer/StatusMessageLabel
|
||||
|
||||
# Lookup controls
|
||||
@onready var user_id_edit: LineEdit = $VBoxContainer/LookupHBox/UserIdEdit
|
||||
@onready var view_user_btn: Button = $VBoxContainer/LookupHBox/ViewUserButton
|
||||
@onready var view_my_profile_btn: Button = $VBoxContainer/LookupHBox/ViewMyProfileButton
|
||||
|
||||
# Profile Card details
|
||||
@onready var username_label: Label = $VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox/UsernameLabel
|
||||
@onready var user_id_label: Label = $VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox/UserIdLabel
|
||||
@onready var avatar_preview_label: Label = $VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/AvatarBox/AvatarLabel
|
||||
@onready var bio_edit: TextEdit = $VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox/BioEdit
|
||||
@onready var save_bio_btn: Button = $VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox/SaveBioButton
|
||||
|
||||
# Privacy Controls (visible only for own profile)
|
||||
@onready var privacy_vbox: VBoxContainer = $VBoxContainer/Scroll/ContentVBox/PrivacyVBox
|
||||
@onready var profile_vis_opt: OptionButton = $VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid/ProfileVisOption
|
||||
@onready var friends_vis_opt: OptionButton = $VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid/FriendsVisOption
|
||||
@onready var favorites_vis_opt: OptionButton = $VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid/FavoritesVisOption
|
||||
@onready var save_privacy_btn: Button = $VBoxContainer/Scroll/ContentVBox/PrivacyVBox/SavePrivacyButton
|
||||
|
||||
# Favorites list
|
||||
@onready var favorites_container: VBoxContainer = $VBoxContainer/Scroll/ContentVBox/FavoritesVBox/FavoritesList
|
||||
@onready var favorites_status_label: Label = $VBoxContainer/Scroll/ContentVBox/FavoritesVBox/FavoritesStatusLabel
|
||||
|
||||
# Friends list
|
||||
@onready var friends_container: VBoxContainer = $VBoxContainer/Scroll/ContentVBox/FriendsVBox/FriendsList
|
||||
@onready var friends_status_label: Label = $VBoxContainer/Scroll/ContentVBox/FriendsVBox/FriendsStatusLabel
|
||||
|
||||
var _api_client: Node = null
|
||||
var _viewing_user_id: int = 0
|
||||
var _my_user_id: int = 0
|
||||
var _is_own_profile: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_api_client = _get_api()
|
||||
_setup_privacy_options()
|
||||
_wire_signals()
|
||||
|
||||
if _api_client != null and _api_client.has_token():
|
||||
_init_my_profile()
|
||||
|
||||
|
||||
func _get_api() -> Node:
|
||||
if has_node("/root/ApiClient"):
|
||||
return get_node("/root/ApiClient")
|
||||
return null
|
||||
|
||||
|
||||
func _setup_privacy_options() -> void:
|
||||
var visibilities := ["public", "friends_only", "private"]
|
||||
for opt in [profile_vis_opt, friends_vis_opt, favorites_vis_opt]:
|
||||
if opt:
|
||||
opt.clear()
|
||||
for v in visibilities:
|
||||
opt.add_item(v.capitalize())
|
||||
|
||||
|
||||
func _wire_signals() -> void:
|
||||
if back_button:
|
||||
back_button.pressed.connect(_on_back_pressed)
|
||||
if refresh_button:
|
||||
refresh_button.pressed.connect(_refresh_current)
|
||||
if view_user_btn:
|
||||
view_user_btn.pressed.connect(_on_view_user_pressed)
|
||||
if view_my_profile_btn:
|
||||
view_my_profile_btn.pressed.connect(_init_my_profile)
|
||||
if save_bio_btn:
|
||||
save_bio_btn.pressed.connect(_on_save_bio_pressed)
|
||||
if save_privacy_btn:
|
||||
save_privacy_btn.pressed.connect(_on_save_privacy_pressed)
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
back_requested.emit()
|
||||
if get_tree().current_scene == self:
|
||||
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
|
||||
|
||||
|
||||
func show_message(text: String, is_error: bool = false) -> void:
|
||||
if status_label:
|
||||
status_label.text = text
|
||||
status_label.modulate = Color(1.0, 0.4, 0.4) if is_error else Color(0.4, 1.0, 0.4)
|
||||
|
||||
|
||||
func _init_my_profile() -> void:
|
||||
if _api_client == null or not _api_client.has_token():
|
||||
show_message("Log in to view profile.", true)
|
||||
return
|
||||
|
||||
var me_res: Dictionary = await _api_client.get_me()
|
||||
if me_res.ok and me_res.data is Dictionary:
|
||||
_my_user_id = int(me_res.data.get("id", 0))
|
||||
load_profile(_my_user_id, true)
|
||||
|
||||
|
||||
func _on_view_user_pressed() -> void:
|
||||
var uid_str := user_id_edit.text.strip_edges()
|
||||
if not uid_str.is_valid_int():
|
||||
show_message("Enter a valid integer user ID.", true)
|
||||
return
|
||||
var uid := int(uid_str)
|
||||
load_profile(uid, uid == _my_user_id)
|
||||
|
||||
|
||||
func _refresh_current() -> void:
|
||||
if _viewing_user_id > 0:
|
||||
load_profile(_viewing_user_id, _is_own_profile)
|
||||
|
||||
|
||||
## Load profile for user_id.
|
||||
func load_profile(user_id: int, is_own: bool = false) -> void:
|
||||
_viewing_user_id = user_id
|
||||
_is_own_profile = is_own
|
||||
|
||||
if _is_own_profile:
|
||||
await _load_own_profile()
|
||||
else:
|
||||
await _load_public_profile(user_id)
|
||||
|
||||
|
||||
func _load_own_profile() -> void:
|
||||
title_label.text = "My Profile"
|
||||
if privacy_vbox:
|
||||
privacy_vbox.visible = true
|
||||
if save_bio_btn:
|
||||
save_bio_btn.visible = true
|
||||
if bio_edit:
|
||||
bio_edit.editable = true
|
||||
|
||||
var res: Dictionary = await _api_client.get_my_profile()
|
||||
if not res.ok:
|
||||
show_message("Failed to load own profile: " + res.detail, true)
|
||||
return
|
||||
|
||||
var data: Dictionary = res.data if res.data is Dictionary else {}
|
||||
_populate_card(_my_user_id, "You", str(data.get("bio", "")), data.get("avatar_asset_id"))
|
||||
|
||||
# Privacy
|
||||
if data.has("privacy_json") and data["privacy_json"] is Dictionary:
|
||||
var priv: Dictionary = data["privacy_json"]
|
||||
_set_option_val(profile_vis_opt, priv.get("profile_visibility", "public"))
|
||||
_set_option_val(friends_vis_opt, priv.get("friends_list_visibility", "public"))
|
||||
_set_option_val(favorites_vis_opt, priv.get("favorites_visibility", "public"))
|
||||
|
||||
# Load own favorites
|
||||
var fav_res: Dictionary = await _api_client.get_my_favorites()
|
||||
_populate_favorites(fav_res.data.get("world_ids", []) if fav_res.ok and fav_res.data is Dictionary else [])
|
||||
|
||||
# Load own friends
|
||||
var fr_res: Dictionary = await _api_client.get_friends()
|
||||
_populate_friends(fr_res.data.get("friends", []) if fr_res.ok and fr_res.data is Dictionary else [])
|
||||
|
||||
|
||||
func _load_public_profile(user_id: int) -> void:
|
||||
title_label.text = "User Profile"
|
||||
if privacy_vbox:
|
||||
privacy_vbox.visible = false
|
||||
if save_bio_btn:
|
||||
save_bio_btn.visible = false
|
||||
if bio_edit:
|
||||
bio_edit.editable = false
|
||||
|
||||
var res: Dictionary = await _api_client.get_public_profile(user_id)
|
||||
if not res.ok:
|
||||
show_message("Failed to load profile: " + res.detail, true)
|
||||
return
|
||||
|
||||
var data: Dictionary = res.data if res.data is Dictionary else {}
|
||||
var uname: String = str(data.get("username", "User #%d" % user_id))
|
||||
var bio: String = str(data.get("bio", "")) if data.get("bio") != null else "[Bio hidden or empty]"
|
||||
|
||||
_populate_card(user_id, uname, bio, data.get("avatar_asset_id"))
|
||||
|
||||
# Favorites
|
||||
if data.has("favorites") and data["favorites"] != null:
|
||||
_populate_favorites(data["favorites"])
|
||||
else:
|
||||
_clear_children(favorites_container)
|
||||
if favorites_status_label:
|
||||
favorites_status_label.text = "Favorites are hidden by privacy settings."
|
||||
favorites_status_label.visible = true
|
||||
|
||||
# Friends
|
||||
if data.has("friends") and data["friends"] != null:
|
||||
_populate_friends(data["friends"])
|
||||
else:
|
||||
_clear_children(friends_container)
|
||||
if friends_status_label:
|
||||
friends_status_label.text = "Friends list is hidden by privacy settings."
|
||||
friends_status_label.visible = true
|
||||
|
||||
|
||||
func _populate_card(uid: int, uname: String, bio: String, avatar_asset_id: Variant) -> void:
|
||||
if username_label:
|
||||
username_label.text = uname
|
||||
if user_id_label:
|
||||
user_id_label.text = "User ID: %d" % uid
|
||||
if bio_edit:
|
||||
bio_edit.text = bio
|
||||
if avatar_preview_label:
|
||||
avatar_preview_label.text = "Avatar #%s" % str(avatar_asset_id) if avatar_asset_id != null else "Default Avatar"
|
||||
|
||||
|
||||
func _populate_favorites(favorites: Array) -> void:
|
||||
_clear_children(favorites_container)
|
||||
if favorites_status_label:
|
||||
favorites_status_label.visible = favorites.is_empty()
|
||||
if favorites.is_empty():
|
||||
favorites_status_label.text = "No favorite experiences yet."
|
||||
|
||||
for item in favorites:
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 8)
|
||||
var lbl := Label.new()
|
||||
if item is Dictionary:
|
||||
lbl.text = "★ %s (World #%s)" % [str(item.get("name", "World")), str(item.get("id", ""))]
|
||||
else:
|
||||
lbl.text = "★ World #%s" % str(item)
|
||||
hbox.add_child(lbl)
|
||||
favorites_container.add_child(hbox)
|
||||
|
||||
|
||||
func _populate_friends(friends: Array) -> void:
|
||||
_clear_children(friends_container)
|
||||
if friends_status_label:
|
||||
friends_status_label.visible = friends.is_empty()
|
||||
if friends.is_empty():
|
||||
friends_status_label.text = "No friends listed."
|
||||
|
||||
for friend in friends:
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 8)
|
||||
var lbl := Label.new()
|
||||
if friend is Dictionary:
|
||||
var fid: int = int(friend.get("id", 0))
|
||||
var fname: String = str(friend.get("username", "User"))
|
||||
lbl.text = "👤 %s (ID: %d)" % [fname, fid]
|
||||
hbox.add_child(lbl)
|
||||
var view_btn := Button.new()
|
||||
view_btn.text = "View"
|
||||
view_btn.pressed.connect(func(): load_profile(fid, fid == _my_user_id))
|
||||
hbox.add_child(view_btn)
|
||||
else:
|
||||
lbl.text = "👤 User #%s" % str(friend)
|
||||
hbox.add_child(lbl)
|
||||
friends_container.add_child(hbox)
|
||||
|
||||
|
||||
func _on_save_bio_pressed() -> void:
|
||||
if _api_client == null or not _is_own_profile:
|
||||
return
|
||||
var new_bio := bio_edit.text.strip_edges() if bio_edit else ""
|
||||
var res: Dictionary = await _api_client.update_my_profile(new_bio)
|
||||
if res.ok:
|
||||
show_message("Bio updated successfully!")
|
||||
else:
|
||||
show_message("Failed to update bio: " + res.detail, true)
|
||||
|
||||
|
||||
func _on_save_privacy_pressed() -> void:
|
||||
if _api_client == null or not _is_own_profile:
|
||||
return
|
||||
var privacy := {
|
||||
"profile_visibility": _get_option_val(profile_vis_opt),
|
||||
"friends_list_visibility": _get_option_val(friends_vis_opt),
|
||||
"favorites_visibility": _get_option_val(favorites_vis_opt),
|
||||
}
|
||||
var res: Dictionary = await _api_client.update_my_profile(null, null, privacy)
|
||||
if res.ok:
|
||||
show_message("Privacy settings updated!")
|
||||
else:
|
||||
show_message("Failed to update privacy: " + res.detail, true)
|
||||
|
||||
|
||||
func _set_option_val(opt: OptionButton, val: String) -> void:
|
||||
if opt == null:
|
||||
return
|
||||
for i in range(opt.item_count):
|
||||
if opt.get_item_text(i).to_lower() == val.to_lower():
|
||||
opt.selected = i
|
||||
return
|
||||
|
||||
|
||||
func _get_option_val(opt: OptionButton) -> String:
|
||||
if opt == null or opt.selected < 0:
|
||||
return "public"
|
||||
return opt.get_item_text(opt.selected).to_lower().replace(" ", "_")
|
||||
|
||||
|
||||
func _clear_children(node: Node) -> void:
|
||||
if node == null:
|
||||
return
|
||||
for c in node.get_children():
|
||||
c.queue_free()
|
||||
@@ -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,105 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for ChatUI scene and script.
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
print("SMOKE: Starting Chat UI verification...")
|
||||
var scene_res := load("res://scenes/chat_ui.tscn")
|
||||
if scene_res == null:
|
||||
printerr("FAIL: Could not load res://scenes/chat_ui.tscn")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var ui: Control = scene_res.instantiate()
|
||||
if ui == null:
|
||||
printerr("FAIL: Could not instantiate ChatUI")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
root.add_child(ui)
|
||||
await process_frame
|
||||
|
||||
# Verify essential nodes
|
||||
if ui.world_messages_container == null:
|
||||
printerr("FAIL: world_messages_container is null")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
if ui.dm_messages_container == null:
|
||||
printerr("FAIL: dm_messages_container is null")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
if ui.conv_list_container == null:
|
||||
printerr("FAIL: conv_list_container is null")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
if ui.report_dialog == null:
|
||||
printerr("FAIL: report_dialog is null")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Verify signal declaration
|
||||
if not ui.has_signal("back_requested"):
|
||||
printerr("FAIL: back_requested signal missing")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Test message row creation with Report button
|
||||
var mock_msg := {
|
||||
"id": 101,
|
||||
"author": {"username": "ChattyUser"},
|
||||
"content": "Hello world! This is a test message."
|
||||
}
|
||||
ui._add_message_row(ui.world_messages_container, mock_msg, "world")
|
||||
|
||||
if ui.world_messages_container.get_child_count() == 0:
|
||||
printerr("FAIL: Message row was not added to world_messages_container")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var row: HBoxContainer = ui.world_messages_container.get_child(0)
|
||||
var found_report := false
|
||||
for child in row.get_children():
|
||||
if child is Button and child.text == "Report":
|
||||
found_report = true
|
||||
break
|
||||
|
||||
if not found_report:
|
||||
printerr("FAIL: Report button not found on message row")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("PASS: Message row with Report button verified.")
|
||||
|
||||
# Test report dialog preparation
|
||||
ui._open_report_dialog(101, "world", "Hello world! This is a test message.")
|
||||
if ui._reporting_message_id != 101 or ui._reporting_conv_type != "world":
|
||||
printerr("FAIL: Report dialog state not set correctly")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("PASS: Report dialog setup verified.")
|
||||
|
||||
# Test real-time WS chat message hook
|
||||
var mock_ws_event := {
|
||||
"id": 102,
|
||||
"author": {"username": "LiveUser"},
|
||||
"content": "Real-time incoming message!"
|
||||
}
|
||||
ui._on_ws_chat_message("world", ui._current_world_id, mock_ws_event)
|
||||
if ui.world_messages_container.get_child_count() < 2:
|
||||
printerr("FAIL: Live WS message was not appended to world chat")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("PASS: Real-time WS chat message append verified.")
|
||||
|
||||
ui.queue_free()
|
||||
print("SMOKE RESULT: PASS")
|
||||
quit(0)
|
||||
@@ -0,0 +1,62 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for DiscoveryApiClient.
|
||||
|
||||
func _init() -> void:
|
||||
print("[SMOKE] Starting DiscoveryApiClient smoke test...")
|
||||
|
||||
# 1. Test WorldSummary parsing
|
||||
var test_dict := {
|
||||
"id": 42,
|
||||
"name": "Neon Arcade",
|
||||
"description": "Retro gaming lounge",
|
||||
"category": "Arcade",
|
||||
"tags": ["retro", "multiplayer"],
|
||||
"play_count": 128,
|
||||
"created_at": "2026-09-15T12:00:00Z",
|
||||
"owner": {
|
||||
"id": 7,
|
||||
"username": "arcadegamer"
|
||||
}
|
||||
}
|
||||
|
||||
var summary := DiscoveryApiClient.WorldSummary.from_dict(test_dict)
|
||||
assert(summary.id == 42, "summary id mismatch")
|
||||
assert(summary.name == "Neon Arcade", "summary name mismatch")
|
||||
assert(summary.category == "Arcade", "summary category mismatch")
|
||||
assert(summary.tags.size() == 2, "summary tags size mismatch")
|
||||
assert(summary.play_count == 128, "summary play_count mismatch")
|
||||
assert(summary.owner_id == 7, "summary owner_id mismatch")
|
||||
assert(summary.owner_username == "arcadegamer", "summary owner_username mismatch")
|
||||
print("[PASS] WorldSummary parsing verified")
|
||||
|
||||
# 2. Test DiscoveryResult parsing
|
||||
var page_dict := {
|
||||
"items": [test_dict],
|
||||
"total": 1,
|
||||
"skip": 0,
|
||||
"limit": 50
|
||||
}
|
||||
var res := DiscoveryApiClient.DiscoveryResult.from_dict(page_dict)
|
||||
assert(res.total == 1, "res total mismatch")
|
||||
assert(res.items.size() == 1, "res items size mismatch")
|
||||
assert(res.items[0].id == 42, "res items[0] id mismatch")
|
||||
print("[PASS] DiscoveryResult parsing verified")
|
||||
|
||||
# 3. Test ApiClient integration
|
||||
var api_script = load("res://scripts/api_client.gd")
|
||||
if not api_script:
|
||||
printerr("[FAIL] Could not load api_client.gd")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var api: Node = api_script.new()
|
||||
root.add_child(api)
|
||||
|
||||
assert(api.has_method("get_discovery_worlds"), "Missing get_discovery_worlds on ApiClient")
|
||||
assert(api.has_method("get_discovery_categories"), "Missing get_discovery_categories on ApiClient")
|
||||
assert(api.has_method("record_world_visit"), "Missing record_world_visit on ApiClient")
|
||||
print("[PASS] ApiClient discovery methods verified")
|
||||
|
||||
api.queue_free()
|
||||
print("[SMOKE] DiscoveryApiClient smoke test PASSED")
|
||||
quit(0)
|
||||
@@ -0,0 +1,89 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for DiscoveryUI.
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
func _run() -> void:
|
||||
print("[SMOKE] Starting DiscoveryUI smoke test...")
|
||||
await process_frame
|
||||
|
||||
var scene = load("res://scenes/discovery_ui.tscn")
|
||||
if not scene:
|
||||
printerr("[FAIL] Could not load res://scenes/discovery_ui.tscn")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var ui: DiscoveryUI = scene.instantiate()
|
||||
root.add_child(ui)
|
||||
await process_frame
|
||||
|
||||
# 1. Verify node references
|
||||
assert(ui.search_edit != null, "search_edit is null")
|
||||
assert(ui.category_option != null, "category_option is null")
|
||||
assert(ui.sort_option != null, "sort_option is null")
|
||||
assert(ui.filter_button != null, "filter_button is null")
|
||||
assert(ui.worlds_container != null, "worlds_container is null")
|
||||
assert(ui.prev_button != null, "prev_button is null")
|
||||
assert(ui.next_button != null, "next_button is null")
|
||||
assert(ui.page_label != null, "page_label is null")
|
||||
print("[PASS] Node references verified")
|
||||
|
||||
# 2. Verify sort options
|
||||
assert(ui.sort_option.item_count == 3, "sort_option should have 3 items")
|
||||
assert(ui.sort_option.get_item_text(0) == "Newest", "sort 0 should be Newest")
|
||||
assert(ui.sort_option.get_item_text(1) == "Most Played", "sort 1 should be Most Played")
|
||||
assert(ui.sort_option.get_item_text(2) == "Trending", "sort 2 should be Trending")
|
||||
print("[PASS] Sort options verified")
|
||||
|
||||
# 3. Verify category dropdown initial state
|
||||
assert(ui.category_option.item_count >= 1, "category_option should have at least All Categories")
|
||||
assert(ui.category_option.get_item_text(0) == "All Categories", "category 0 should be All Categories")
|
||||
print("[PASS] Category dropdown verified")
|
||||
|
||||
# 4. Test rendering of WorldSummary cards
|
||||
var mock_w := DiscoveryApiClient.WorldSummary.new()
|
||||
mock_w.id = 101
|
||||
mock_w.name = "Cyber Nexus"
|
||||
mock_w.category = "Action"
|
||||
mock_w.tags = ["cyberpunk", "pvp"]
|
||||
mock_w.play_count = 256
|
||||
mock_w.description = "A futuristic arena"
|
||||
mock_w.owner_username = "neon_builder"
|
||||
|
||||
var mock_worlds: Array[DiscoveryApiClient.WorldSummary] = [mock_w]
|
||||
ui._render_worlds(mock_worlds)
|
||||
assert(ui.worlds_container.get_child_count() == 1, "Should render 1 card")
|
||||
assert(ui.empty_label.visible == false, "empty_label should be hidden when items present")
|
||||
print("[PASS] World card rendering verified")
|
||||
|
||||
# 5. Test empty state
|
||||
var empty_list: Array[DiscoveryApiClient.WorldSummary] = []
|
||||
ui._render_worlds(empty_list)
|
||||
assert(ui.worlds_container.get_child_count() == 0, "Container should have 0 children")
|
||||
assert(ui.empty_label.visible == true, "empty_label should be visible when empty")
|
||||
print("[PASS] Empty state verified")
|
||||
|
||||
# 6. Test pagination logic
|
||||
ui._total_count = 25
|
||||
ui._page_size = 10
|
||||
ui._current_page = 0
|
||||
ui._update_pagination()
|
||||
assert(ui.prev_button.disabled == true, "Page 0 prev_button should be disabled")
|
||||
assert(ui.next_button.disabled == false, "Page 0 next_button should be enabled")
|
||||
assert(ui.page_label.text.contains("Page 1 of 3"), "Page 1 of 3 mismatch: %s" % ui.page_label.text)
|
||||
|
||||
ui._current_page = 1
|
||||
ui._update_pagination()
|
||||
assert(ui.prev_button.disabled == false, "Page 1 prev_button should be enabled")
|
||||
assert(ui.next_button.disabled == false, "Page 1 next_button should be enabled")
|
||||
|
||||
ui._current_page = 2
|
||||
ui._update_pagination()
|
||||
assert(ui.prev_button.disabled == false, "Page 2 prev_button should be enabled")
|
||||
assert(ui.next_button.disabled == true, "Page 2 next_button should be disabled")
|
||||
print("[PASS] Pagination logic verified")
|
||||
|
||||
ui.queue_free()
|
||||
print("[SMOKE] DiscoveryUI smoke test PASSED")
|
||||
quit(0)
|
||||
@@ -0,0 +1,44 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for EconomyApiClient.
|
||||
|
||||
func _init() -> void:
|
||||
print("[SMOKE] Starting EconomyApiClient smoke test...")
|
||||
|
||||
var api_script = load("res://scripts/api_client.gd")
|
||||
if not api_script:
|
||||
printerr("[FAIL] Could not load api_client.gd")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var api: Node = api_script.new()
|
||||
root.add_child(api)
|
||||
|
||||
# Verify idempotency key generation
|
||||
var key1: String = EconomyApiClient.generate_idempotency_key()
|
||||
var key2: String = EconomyApiClient.generate_idempotency_key()
|
||||
assert(key1 != "", "Key should not be empty")
|
||||
assert(key1 != key2, "Keys should be unique")
|
||||
print("[PASS] Idempotency key generation: ", key1)
|
||||
|
||||
# Verify method existence on EconomyApiClient
|
||||
var client = EconomyApiClient.new()
|
||||
assert(client.has_method("get_wallet"), "Missing get_wallet")
|
||||
assert(client.has_method("get_wallet_transactions"), "Missing get_wallet_transactions")
|
||||
assert(client.has_method("purchase_credits"), "Missing purchase_credits")
|
||||
assert(client.has_method("get_catalog_items"), "Missing get_catalog_items")
|
||||
assert(client.has_method("create_catalog_item"), "Missing create_catalog_item")
|
||||
assert(client.has_method("create_listing"), "Missing create_listing")
|
||||
assert(client.has_method("search_listings"), "Missing search_listings")
|
||||
assert(client.has_method("get_listing"), "Missing get_listing")
|
||||
assert(client.has_method("update_listing"), "Missing update_listing")
|
||||
assert(client.has_method("delist_listing"), "Missing delist_listing")
|
||||
assert(client.has_method("purchase_listing"), "Missing purchase_listing")
|
||||
assert(client.has_method("get_purchases"), "Missing get_purchases")
|
||||
assert(client.has_method("get_creator_balance"), "Missing get_creator_balance")
|
||||
assert(client.has_method("request_payout"), "Missing request_payout")
|
||||
assert(client.has_method("get_payouts"), "Missing get_payouts")
|
||||
print("[PASS] All EconomyApiClient methods verified")
|
||||
|
||||
api.queue_free()
|
||||
print("[SMOKE] EconomyApiClient smoke test PASSED")
|
||||
quit(0)
|
||||
@@ -0,0 +1,77 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for FriendsPresenceUI scene and script.
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
print("SMOKE: Starting Friends & Presence UI verification...")
|
||||
var scene_res := load("res://scenes/friends_presence_ui.tscn")
|
||||
if scene_res == null:
|
||||
printerr("FAIL: Could not load res://scenes/friends_presence_ui.tscn")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var ui: Control = scene_res.instantiate()
|
||||
if ui == null:
|
||||
printerr("FAIL: Could not instantiate FriendsPresenceUI")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
root.add_child(ui)
|
||||
await process_frame
|
||||
|
||||
# Verify essential nodes
|
||||
if ui.friends_container == null:
|
||||
printerr("FAIL: friends_container is null")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
if ui.requests_container == null:
|
||||
printerr("FAIL: requests_container is null")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
if ui.following_container == null:
|
||||
printerr("FAIL: following_container is null")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
if ui.blocked_container == null:
|
||||
printerr("FAIL: blocked_container is null")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Verify signal declaration
|
||||
if not ui.has_signal("back_requested"):
|
||||
printerr("FAIL: back_requested signal missing")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Test mock row building
|
||||
ui._friend_presence_map[42] = {
|
||||
"status": "online",
|
||||
"custom_status": "Building cool stuff",
|
||||
"last_seen": "2026-09-15T12:00:00Z"
|
||||
}
|
||||
ui._build_friend_row(42, "TesterFriend")
|
||||
|
||||
if not ui.friends_container.has_node("FriendRow_42"):
|
||||
printerr("FAIL: FriendRow_42 was not added to friends_container")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("PASS: Friend row built successfully with presence indicator.")
|
||||
|
||||
# Test message display
|
||||
ui.show_message("Test message", false)
|
||||
if ui.status_label.text != "Test message":
|
||||
printerr("FAIL: status_label text not updated")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
ui.queue_free()
|
||||
print("SMOKE RESULT: PASS")
|
||||
quit(0)
|
||||
@@ -0,0 +1,68 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for GroupsUI scene and script.
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
print("SMOKE: Starting Groups UI verification...")
|
||||
var scene_res := load("res://scenes/groups_ui.tscn")
|
||||
if scene_res == null:
|
||||
printerr("FAIL: Could not load res://scenes/groups_ui.tscn")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var ui: Control = scene_res.instantiate()
|
||||
if ui == null:
|
||||
printerr("FAIL: Could not instantiate GroupsUI")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
root.add_child(ui)
|
||||
await process_frame
|
||||
|
||||
# Verify essential nodes
|
||||
if ui.groups_list_container == null or ui.members_list_container == null:
|
||||
printerr("FAIL: container nodes are null")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
if ui.group_name_label == null or ui.member_count_label == null:
|
||||
printerr("FAIL: label nodes are null")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Verify signal declaration
|
||||
if not ui.has_signal("back_requested"):
|
||||
printerr("FAIL: back_requested signal missing")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Test group card building
|
||||
ui._build_group_card(1, "Alpha Guild", "Top tier world creators", 5)
|
||||
if ui.groups_list_container.get_child_count() != 1:
|
||||
printerr("FAIL: Group card not added to groups_list_container")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("PASS: Group card building verified.")
|
||||
|
||||
# Test members list population with owner management
|
||||
var mock_members := [
|
||||
{"user_id": 10, "role": "owner", "user": {"username": "OwnerUser"}},
|
||||
{"user_id": 20, "role": "admin", "user": {"username": "AdminUser"}},
|
||||
{"user_id": 30, "role": "member", "user": {"username": "NormalUser"}}
|
||||
]
|
||||
ui._populate_members_list(mock_members, true)
|
||||
if ui.members_list_container.get_child_count() != 3:
|
||||
printerr("FAIL: Member list count mismatch")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("PASS: Group members list population with role management verified.")
|
||||
|
||||
ui.queue_free()
|
||||
print("SMOKE RESULT: PASS")
|
||||
quit(0)
|
||||
@@ -0,0 +1,72 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for MarketplaceUI scene and script.
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
print("[SMOKE] Starting MarketplaceUI smoke test...")
|
||||
|
||||
var scene = load("res://scenes/marketplace_ui.tscn")
|
||||
if not scene:
|
||||
printerr("[FAIL] Could not load scenes/marketplace_ui.tscn")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var ui: Control = scene.instantiate()
|
||||
root.add_child(ui)
|
||||
await process_frame
|
||||
|
||||
# Verify onready nodes
|
||||
assert(ui.back_button != null, "Missing back_button")
|
||||
assert(ui.refresh_button != null, "Missing refresh_button")
|
||||
assert(ui.balance_label != null, "Missing balance_label")
|
||||
assert(ui.status_label != null, "Missing status_label")
|
||||
assert(ui.search_edit != null, "Missing search_edit")
|
||||
assert(ui.type_option != null, "Missing type_option")
|
||||
assert(ui.min_price_edit != null, "Missing min_price_edit")
|
||||
assert(ui.max_price_edit != null, "Missing max_price_edit")
|
||||
assert(ui.search_btn != null, "Missing search_btn")
|
||||
assert(ui.listings_container != null, "Missing listings_container")
|
||||
|
||||
# Verify type options populated
|
||||
assert(ui.type_option.item_count >= 4, "Type options not populated")
|
||||
|
||||
# Test rendering empty listings
|
||||
ui.render_listings([])
|
||||
assert(ui.listings_container.get_child_count() == 1, "Should show empty notice")
|
||||
|
||||
# Test rendering sample listings
|
||||
var sample_listings = [
|
||||
{
|
||||
"id": 10,
|
||||
"price": 200,
|
||||
"seller_username": "merchant_bob",
|
||||
"catalog_item": {
|
||||
"name": "Wizard Robe",
|
||||
"item_type": "avatar_item",
|
||||
"description": "Flowing velvet robe"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"price": 500,
|
||||
"seller_username": "dungeon_master",
|
||||
"catalog_item": {
|
||||
"name": "Castle Map",
|
||||
"item_type": "asset",
|
||||
"description": "Detailed 3D fortress map"
|
||||
}
|
||||
}
|
||||
]
|
||||
ui.render_listings(sample_listings)
|
||||
assert(ui.listings_container.get_child_count() == 2, "Should render 2 listing cards")
|
||||
|
||||
# Verify signals
|
||||
assert(ui.has_signal("back_requested"), "Missing back_requested signal")
|
||||
assert(ui.has_signal("item_purchased"), "Missing item_purchased signal")
|
||||
|
||||
ui.queue_free()
|
||||
print("[SMOKE] MarketplaceUI smoke test PASSED")
|
||||
quit(0)
|
||||
@@ -0,0 +1,80 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for ProfileUI scene and script.
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
print("SMOKE: Starting Profile UI verification...")
|
||||
var scene_res := load("res://scenes/profile_ui.tscn")
|
||||
if scene_res == null:
|
||||
printerr("FAIL: Could not load res://scenes/profile_ui.tscn")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var ui: Control = scene_res.instantiate()
|
||||
if ui == null:
|
||||
printerr("FAIL: Could not instantiate ProfileUI")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
root.add_child(ui)
|
||||
await process_frame
|
||||
|
||||
# Verify essential nodes
|
||||
if ui.username_label == null or ui.bio_edit == null:
|
||||
printerr("FAIL: username_label or bio_edit is null")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
if ui.privacy_vbox == null or ui.favorites_container == null or ui.friends_container == null:
|
||||
printerr("FAIL: container nodes are null")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Verify signal declaration
|
||||
if not ui.has_signal("back_requested"):
|
||||
printerr("FAIL: back_requested signal missing")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Test card population
|
||||
ui._populate_card(99, "TestExplorer", "Just another world builder", 12)
|
||||
if ui.username_label.text != "TestExplorer" or ui.bio_edit.text != "Just another world builder":
|
||||
printerr("FAIL: Card population failed")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("PASS: Profile card population verified.")
|
||||
|
||||
# Test favorites population
|
||||
var mock_favs := [
|
||||
{"id": 1, "name": "Sunset Plains"},
|
||||
{"id": 2, "name": "Neon City Rooftops"}
|
||||
]
|
||||
ui._populate_favorites(mock_favs)
|
||||
if ui.favorites_container.get_child_count() != 2:
|
||||
printerr("FAIL: Favorites count mismatch")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("PASS: Favorites list population verified.")
|
||||
|
||||
# Test friends population
|
||||
var mock_friends := [
|
||||
{"id": 10, "username": "Alice"},
|
||||
{"id": 20, "username": "Bob"}
|
||||
]
|
||||
ui._populate_friends(mock_friends)
|
||||
if ui.friends_container.get_child_count() != 2:
|
||||
printerr("FAIL: Friends list count mismatch")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("PASS: Friends list population verified.")
|
||||
|
||||
ui.queue_free()
|
||||
print("SMOKE RESULT: PASS")
|
||||
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,75 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for Godot API client (auth + HTTP + WebSocket gateway).
|
||||
##
|
||||
## Exercises:
|
||||
## 1. ApiClient autoload has WsGatewayClient child node.
|
||||
## 2. ApiClient has all social methods adhering to {ok, status, data, detail}.
|
||||
## 3. SocialApiClient helper methods work properly.
|
||||
## 4. WsGatewayClient URL parsing, signal declarations, state transitions.
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
print("SMOKE: Starting Social API client verification...")
|
||||
var api_client := root.get_node_or_null("ApiClient")
|
||||
if api_client == null:
|
||||
printerr("FAIL: ApiClient node not found in root")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Verify ws node exists on ApiClient
|
||||
if api_client.ws == null:
|
||||
printerr("FAIL: api_client.ws is null")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
if not (api_client.ws is WsGatewayClient):
|
||||
printerr("FAIL: api_client.ws is not a WsGatewayClient")
|
||||
quit(1)
|
||||
return
|
||||
print("PASS: ApiClient has WsGatewayClient attached.")
|
||||
|
||||
# Verify WsGatewayClient states and signals
|
||||
var test_ws := WsGatewayClient.new()
|
||||
if test_ws.is_connected_to_gateway:
|
||||
printerr("FAIL: new test_ws should not be connected")
|
||||
quit(1)
|
||||
return
|
||||
print("PASS: WsGatewayClient initial state is disconnected.")
|
||||
|
||||
# Verify ApiClient social methods exist
|
||||
var social_methods := [
|
||||
"send_friend_request", "get_friend_requests", "accept_friend_request",
|
||||
"decline_friend_request", "unfriend", "get_friends", "follow_user",
|
||||
"unfollow_user", "get_following", "block_user", "unblock_user", "get_blocked",
|
||||
"send_presence_heartbeat", "get_friends_presence", "get_user_presence",
|
||||
"get_my_profile", "update_my_profile", "get_my_favorites", "replace_my_favorites",
|
||||
"get_public_profile", "create_group", "list_groups", "get_group",
|
||||
"update_group", "delete_group", "join_group", "leave_group",
|
||||
"set_group_member_role", "transfer_group_ownership", "get_dm_conversations",
|
||||
"start_dm_conversation", "get_dm_messages", "send_dm_message",
|
||||
"get_world_chat_messages", "send_world_chat_message", "get_room_chat_messages",
|
||||
"send_room_chat_message", "report_message"
|
||||
]
|
||||
|
||||
for m in social_methods:
|
||||
if not api_client.has_method(m):
|
||||
printerr("FAIL: ApiClient missing method: ", m)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("PASS: All %d social REST methods verified on ApiClient." % social_methods.size())
|
||||
|
||||
# Verify unauthenticated call correctly yields ok == false without crashing
|
||||
var result: Dictionary = await api_client.get_friends()
|
||||
if not result.has("ok") or not result.has("status") or not result.has("detail"):
|
||||
printerr("FAIL: result does not adhere to Result Dictionary Contract: ", result)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("PASS: Social REST call returns valid result dictionary (ok=%s, status=%d)." % [result.ok, result.status])
|
||||
print("SMOKE RESULT: PASS")
|
||||
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,171 @@
|
||||
extends RefCounted
|
||||
class_name SocialApiClient
|
||||
## Helper class for AGP Social REST endpoints and WebSocket gateway interactions.
|
||||
##
|
||||
## Can be used as a standalone helper or via ApiClient.
|
||||
|
||||
static func get_api_client(node: Node) -> Node:
|
||||
if node.has_node("/root/ApiClient"):
|
||||
return node.get_node("/root/ApiClient")
|
||||
return null
|
||||
|
||||
# ── Friends & Follow & Block ──
|
||||
|
||||
static func send_friend_request(api: Node, user_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/friends/requests", JSON.stringify({"user_id": user_id}), "application/json", true)
|
||||
|
||||
static func get_friend_requests(api: Node) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/friends/requests", "", "application/json", true)
|
||||
|
||||
static func accept_friend_request(api: Node, request_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/friends/requests/%d/accept" % request_id, "", "application/json", true)
|
||||
|
||||
static func decline_friend_request(api: Node, request_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/friends/requests/%d/decline" % request_id, "", "application/json", true)
|
||||
|
||||
static func unfriend(api: Node, user_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_DELETE, "/api/friends/%d" % user_id, "", "application/json", true)
|
||||
|
||||
static func get_friends(api: Node) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/friends", "", "application/json", true)
|
||||
|
||||
static func follow_user(api: Node, user_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/friends/%d/follow" % user_id, "", "application/json", true)
|
||||
|
||||
static func unfollow_user(api: Node, user_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_DELETE, "/api/friends/%d/follow" % user_id, "", "application/json", true)
|
||||
|
||||
static func get_following(api: Node) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/friends/following", "", "application/json", true)
|
||||
|
||||
static func block_user(api: Node, user_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/friends/%d/block" % user_id, "", "application/json", true)
|
||||
|
||||
static func unblock_user(api: Node, user_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_DELETE, "/api/friends/%d/block" % user_id, "", "application/json", true)
|
||||
|
||||
static func get_blocked(api: Node) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/friends/blocked", "", "application/json", true)
|
||||
|
||||
# ── Presence ──
|
||||
|
||||
static func send_presence_heartbeat(api: Node, custom_status: String = "") -> Dictionary:
|
||||
var payload := {}
|
||||
if custom_status != "":
|
||||
payload["custom_status"] = custom_status
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/presence/heartbeat", JSON.stringify(payload), "application/json", true)
|
||||
|
||||
static func get_friends_presence(api: Node) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/presence/friends", "", "application/json", true)
|
||||
|
||||
static func get_user_presence(api: Node, user_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/presence/%d" % user_id, "", "application/json", true)
|
||||
|
||||
# ── Profiles ──
|
||||
|
||||
static func get_my_profile(api: Node) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/profile/me", "", "application/json", true)
|
||||
|
||||
static func update_my_profile(api: Node, bio: Variant = null, avatar_asset_id: Variant = null, privacy: Variant = null) -> Dictionary:
|
||||
var payload := {}
|
||||
if bio != null:
|
||||
payload["bio"] = bio
|
||||
if avatar_asset_id != null:
|
||||
payload["avatar_asset_id"] = avatar_asset_id
|
||||
if privacy != null:
|
||||
payload["privacy"] = privacy
|
||||
return await api.request(HTTPClient.METHOD_PUT, "/api/profile/me", JSON.stringify(payload), "application/json", true)
|
||||
|
||||
static func get_my_favorites(api: Node) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/profile/me/favorites", "", "application/json", true)
|
||||
|
||||
static func replace_my_favorites(api: Node, world_ids: Array) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_PUT, "/api/profile/me/favorites", JSON.stringify({"world_ids": world_ids}), "application/json", true)
|
||||
|
||||
static func get_public_profile(api: Node, user_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/profile/%d" % user_id, "", "application/json", true)
|
||||
|
||||
# ── Groups ──
|
||||
|
||||
static func create_group(api: Node, name: String, description: String = "") -> Dictionary:
|
||||
var payload := {"name": name}
|
||||
if description != "":
|
||||
payload["description"] = description
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/groups", JSON.stringify(payload), "application/json", true)
|
||||
|
||||
static func list_groups(api: Node, page: int = 1, page_size: int = 50, mine: bool = false) -> Dictionary:
|
||||
var path := "/api/groups?page=%d&page_size=%d" % [page, page_size]
|
||||
if mine:
|
||||
path += "&mine=true"
|
||||
return await api.request(HTTPClient.METHOD_GET, path, "", "application/json", true)
|
||||
|
||||
static func get_group(api: Node, group_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/groups/%d" % group_id, "", "application/json", true)
|
||||
|
||||
static func update_group(api: Node, group_id: int, name: Variant = null, description: Variant = null) -> Dictionary:
|
||||
var payload := {}
|
||||
if name != null:
|
||||
payload["name"] = name
|
||||
if description != null:
|
||||
payload["description"] = description
|
||||
return await api.request(HTTPClient.METHOD_PATCH, "/api/groups/%d" % group_id, JSON.stringify(payload), "application/json", true)
|
||||
|
||||
static func delete_group(api: Node, group_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_DELETE, "/api/groups/%d" % group_id, "", "application/json", true)
|
||||
|
||||
static func join_group(api: Node, group_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/groups/%d/join" % group_id, "", "application/json", true)
|
||||
|
||||
static func leave_group(api: Node, group_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/groups/%d/leave" % group_id, "", "application/json", true)
|
||||
|
||||
static func set_group_member_role(api: Node, group_id: int, user_id: int, role: String) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/groups/%d/members/%d/role" % [group_id, user_id], JSON.stringify({"role": role}), "application/json", true)
|
||||
|
||||
static func transfer_group_ownership(api: Node, group_id: int, new_owner_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/groups/%d/transfer" % group_id, JSON.stringify({"new_owner_id": new_owner_id}), "application/json", true)
|
||||
|
||||
# ── Chat & Moderation ──
|
||||
|
||||
static func get_dm_conversations(api: Node) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/chat/dm/conversations", "", "application/json", true)
|
||||
|
||||
static func start_dm_conversation(api: Node, target_user_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/chat/dm/conversations", JSON.stringify({"target_user_id": target_user_id}), "application/json", true)
|
||||
|
||||
static func get_dm_messages(api: Node, conversation_id: int, before_id: int = 0, limit: int = 50) -> Dictionary:
|
||||
var path := "/api/chat/dm/conversations/%d/messages?limit=%d" % [conversation_id, limit]
|
||||
if before_id > 0:
|
||||
path += "&before_id=%d" % before_id
|
||||
return await api.request(HTTPClient.METHOD_GET, path, "", "application/json", true)
|
||||
|
||||
static func send_dm_message(api: Node, conversation_id: int, content: String) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/chat/dm/conversations/%d/messages" % conversation_id, JSON.stringify({"content": content}), "application/json", true)
|
||||
|
||||
static func get_world_chat_messages(api: Node, world_id: int, before_id: int = 0, limit: int = 50) -> Dictionary:
|
||||
var path := "/api/chat/world/%d/messages?limit=%d" % [world_id, limit]
|
||||
if before_id > 0:
|
||||
path += "&before_id=%d" % before_id
|
||||
return await api.request(HTTPClient.METHOD_GET, path, "", "application/json", true)
|
||||
|
||||
static func send_world_chat_message(api: Node, world_id: int, content: String) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/chat/world/%d/messages" % world_id, JSON.stringify({"content": content}), "application/json", true)
|
||||
|
||||
static func get_room_chat_messages(api: Node, room_id: String, before_id: int = 0, limit: int = 50) -> Dictionary:
|
||||
var path := "/api/chat/room/%s/messages?limit=%d" % [room_id.uri_encode(), limit]
|
||||
if before_id > 0:
|
||||
path += "&before_id=%d" % before_id
|
||||
return await api.request(HTTPClient.METHOD_GET, path, "", "application/json", true)
|
||||
|
||||
static func send_room_chat_message(api: Node, room_id: String, content: String) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/chat/room/%s/messages" % room_id.uri_encode(), JSON.stringify({"content": content}), "application/json", true)
|
||||
|
||||
static func report_message(api: Node, message_id: int, conversation_type: String, reason: String, context_text: String = "") -> Dictionary:
|
||||
var payload := {
|
||||
"message_id": message_id,
|
||||
"conversation_type": conversation_type,
|
||||
"reason": reason
|
||||
}
|
||||
if context_text != "":
|
||||
payload["context_text"] = context_text
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/chat/reports/messages", JSON.stringify(payload), "application/json", true)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,210 @@
|
||||
extends Node
|
||||
class_name WsGatewayClient
|
||||
## WebSocket gateway client for real-time presence and chat.
|
||||
##
|
||||
## Manages connection to the AGP gateway endpoint (/api/ws or /v1/ws).
|
||||
## Handles JWT authentication, presence heartbeat, DM/world channel
|
||||
## subscriptions, and event dispatch.
|
||||
|
||||
signal connected
|
||||
signal disconnected
|
||||
signal authenticated(user_id: int, username: String)
|
||||
signal presence_changed(user_id: int, status: String)
|
||||
signal chat_message_received(channel: String, id: int, data: Dictionary)
|
||||
signal subscribed(channel: String, id: int)
|
||||
signal unsubscribed(channel: String, id: int)
|
||||
signal gateway_error(message: String)
|
||||
|
||||
enum ConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED,
|
||||
AUTHENTICATED,
|
||||
CLOSING
|
||||
}
|
||||
|
||||
var _peer: WebSocketPeer = WebSocketPeer.new()
|
||||
var _state: ConnectionState = ConnectionState.DISCONNECTED
|
||||
var _token: String = ""
|
||||
var _authenticated_user_id: int = 0
|
||||
var _authenticated_username: String = ""
|
||||
var _heartbeat_timer: float = 0.0
|
||||
var _heartbeat_interval: float = 25.0
|
||||
var _last_url: String = ""
|
||||
|
||||
var is_connected_to_gateway: bool:
|
||||
get:
|
||||
return _state == ConnectionState.CONNECTED or _state == ConnectionState.AUTHENTICATED
|
||||
|
||||
var is_authenticated: bool:
|
||||
get:
|
||||
return _state == ConnectionState.AUTHENTICATED
|
||||
|
||||
var user_id: int:
|
||||
get:
|
||||
return _authenticated_user_id
|
||||
|
||||
var username: String:
|
||||
get:
|
||||
return _authenticated_username
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _state == ConnectionState.DISCONNECTED:
|
||||
return
|
||||
|
||||
_peer.poll()
|
||||
var peer_state := _peer.get_ready_state()
|
||||
|
||||
match peer_state:
|
||||
WebSocketPeer.STATE_OPEN:
|
||||
if _state == ConnectionState.CONNECTING:
|
||||
_state = ConnectionState.CONNECTED
|
||||
connected.emit()
|
||||
# If token was provided, send auth frame if not already sent via query param
|
||||
if _token != "" and _state != ConnectionState.AUTHENTICATED:
|
||||
send_json_message({"type": "auth", "token": _token})
|
||||
|
||||
# Process incoming packets
|
||||
while _peer.get_available_packet_count() > 0:
|
||||
var packet := _peer.get_packet()
|
||||
_handle_packet(packet)
|
||||
|
||||
# Send periodic heartbeat
|
||||
if _state == ConnectionState.AUTHENTICATED:
|
||||
_heartbeat_timer += delta
|
||||
if _heartbeat_timer >= _heartbeat_interval:
|
||||
_heartbeat_timer = 0.0
|
||||
send_heartbeat()
|
||||
|
||||
WebSocketPeer.STATE_CLOSING:
|
||||
_state = ConnectionState.CLOSING
|
||||
|
||||
WebSocketPeer.STATE_CLOSED:
|
||||
if _state != ConnectionState.DISCONNECTED:
|
||||
var code := _peer.get_close_code()
|
||||
var reason := _peer.get_close_reason()
|
||||
_cleanup()
|
||||
disconnected.emit()
|
||||
|
||||
|
||||
## Connect to the WebSocket gateway.
|
||||
## If ws_url is empty, derives ws:// or wss:// from base_http_url + "/api/ws".
|
||||
func connect_gateway(base_http_url: String = "http://127.0.0.1:8000", token: String = "") -> Error:
|
||||
_cleanup()
|
||||
_token = token
|
||||
|
||||
var url: String
|
||||
if base_http_url.begins_with("ws://") or base_http_url.begins_with("wss://"):
|
||||
url = base_http_url
|
||||
else:
|
||||
var ws_scheme := "wss://" if base_http_url.begins_with("https://") else "ws://"
|
||||
var stripped := base_http_url.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
url = ws_scheme + stripped + "/api/ws"
|
||||
|
||||
if token != "":
|
||||
url += "?token=" + token.uri_encode()
|
||||
|
||||
_last_url = url
|
||||
_peer = WebSocketPeer.new()
|
||||
var err := _peer.connect_to_url(url)
|
||||
if err == OK:
|
||||
_state = ConnectionState.CONNECTING
|
||||
else:
|
||||
_state = ConnectionState.DISCONNECTED
|
||||
gateway_error.emit("Failed to initiate connection: %d" % err)
|
||||
return err
|
||||
|
||||
|
||||
## Disconnect from the WebSocket gateway.
|
||||
func disconnect_gateway(code: int = 1000, reason: String = "Normal closure") -> void:
|
||||
if _state != ConnectionState.DISCONNECTED and _peer != null:
|
||||
_peer.close(code, reason)
|
||||
_cleanup()
|
||||
disconnected.emit()
|
||||
|
||||
|
||||
## Send a ping frame.
|
||||
func send_ping() -> void:
|
||||
send_json_message({"type": "ping"})
|
||||
|
||||
|
||||
## Send presence heartbeat.
|
||||
func send_heartbeat() -> void:
|
||||
send_json_message({"type": "heartbeat"})
|
||||
|
||||
|
||||
## Subscribe to world chat events.
|
||||
func subscribe_world(world_id: int) -> void:
|
||||
send_json_message({"type": "subscribe", "channel": "world", "id": world_id})
|
||||
|
||||
|
||||
## Unsubscribe from world chat events.
|
||||
func unsubscribe_world(world_id: int) -> void:
|
||||
send_json_message({"type": "unsubscribe", "channel": "world", "id": world_id})
|
||||
|
||||
|
||||
## Subscribe to DM conversation events.
|
||||
func subscribe_dm(conversation_id: int) -> void:
|
||||
send_json_message({"type": "subscribe", "channel": "dm", "id": conversation_id})
|
||||
|
||||
|
||||
## Unsubscribe from DM conversation events.
|
||||
func unsubscribe_dm(conversation_id: int) -> void:
|
||||
send_json_message({"type": "unsubscribe", "channel": "dm", "id": conversation_id})
|
||||
|
||||
|
||||
## Send any dictionary as a JSON text packet.
|
||||
func send_json_message(data: Dictionary) -> Error:
|
||||
if _peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
|
||||
return ERR_UNCONFIGURED
|
||||
var text := JSON.stringify(data)
|
||||
return _peer.send_text(text)
|
||||
|
||||
|
||||
func _handle_packet(packet: PackedByteArray) -> void:
|
||||
var text := packet.get_string_from_utf8()
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var msg: Dictionary = parsed as Dictionary
|
||||
var msg_type: String = str(msg.get("type", ""))
|
||||
|
||||
match msg_type:
|
||||
"auth_ok":
|
||||
_state = ConnectionState.AUTHENTICATED
|
||||
_authenticated_user_id = int(msg.get("user_id", 0))
|
||||
_authenticated_username = str(msg.get("username", ""))
|
||||
authenticated.emit(_authenticated_user_id, _authenticated_username)
|
||||
|
||||
"presence":
|
||||
var uid: int = int(msg.get("user_id", 0))
|
||||
var p_status: String = str(msg.get("status", "offline"))
|
||||
presence_changed.emit(uid, p_status)
|
||||
|
||||
"chat_message":
|
||||
var channel: String = str(msg.get("channel", ""))
|
||||
var target_id: int = int(msg.get("id", 0))
|
||||
var data: Dictionary = msg.get("data", {}) if msg.get("data") is Dictionary else {}
|
||||
chat_message_received.emit(channel, target_id, data)
|
||||
|
||||
"subscribed":
|
||||
var channel: String = str(msg.get("channel", ""))
|
||||
var id: int = int(msg.get("id", 0))
|
||||
subscribed.emit(channel, id)
|
||||
|
||||
"unsubscribed":
|
||||
var channel: String = str(msg.get("channel", ""))
|
||||
var id: int = int(msg.get("id", 0))
|
||||
unsubscribed.emit(channel, id)
|
||||
|
||||
"error":
|
||||
var err_msg: String = str(msg.get("message", "Unknown error"))
|
||||
gateway_error.emit(err_msg)
|
||||
|
||||
|
||||
func _cleanup() -> void:
|
||||
_state = ConnectionState.DISCONNECTED
|
||||
_authenticated_user_id = 0
|
||||
_authenticated_username = ""
|
||||
_heartbeat_timer = 0.0
|
||||
Reference in New Issue
Block a user