Added trading and quests, checkpoint push
This commit is contained in:
291
api/database.py
291
api/database.py
@@ -308,6 +308,50 @@ player_statistics = Table(
|
||||
)
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# QUESTS AND TRADE TABLES
|
||||
# ========================================================================
|
||||
|
||||
# Quests: Character progress
|
||||
character_quests = Table(
|
||||
"character_quests",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("quest_id", String, nullable=False),
|
||||
Column("status", String(20), default="active"), # active, completed, failed
|
||||
Column("progress", JSON, default={}), # {"rat_kills": 1, "wood_delivered": 50}
|
||||
Column("started_at", Float, default=lambda: time.time()),
|
||||
Column("completed_at", Float, nullable=True),
|
||||
Column("last_completed_at", Float, nullable=True), # For repeatable quests
|
||||
Column("cooldown_expires_at", Float, nullable=True), # For repeatable quests
|
||||
Column("times_completed", Integer, default=0),
|
||||
UniqueConstraint("character_id", "quest_id", name="uix_char_quest")
|
||||
)
|
||||
|
||||
# Quests: Global progress
|
||||
global_quests = Table(
|
||||
"global_quests",
|
||||
metadata,
|
||||
Column("quest_id", String, primary_key=True),
|
||||
Column("global_progress", JSON, default={}),
|
||||
Column("is_completed", Boolean, default=False),
|
||||
Column("updated_at", Float, default=lambda: time.time()),
|
||||
)
|
||||
|
||||
# Trade: Merchant Stock
|
||||
merchant_stock = Table(
|
||||
"merchant_stock",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("npc_id", String, nullable=False),
|
||||
Column("item_id", String, nullable=False),
|
||||
Column("unique_item_id", Integer, ForeignKey("unique_items.id", ondelete="SET NULL"), nullable=True),
|
||||
Column("quantity", Integer, default=0),
|
||||
Column("last_restock_at", Float, default=0),
|
||||
)
|
||||
|
||||
|
||||
# Database session context manager
|
||||
class DatabaseSession:
|
||||
"""Context manager for database sessions"""
|
||||
@@ -357,6 +401,13 @@ async def init_db():
|
||||
|
||||
# Interactable cooldowns - checked on interact attempts
|
||||
"CREATE INDEX IF NOT EXISTS idx_interactable_cooldowns_instance ON interactable_cooldowns(interactable_instance_id);",
|
||||
|
||||
# Quests
|
||||
"CREATE INDEX IF NOT EXISTS idx_character_quests_char ON character_quests(character_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_character_quests_status ON character_quests(status);",
|
||||
|
||||
# Merchant Stock
|
||||
"CREATE INDEX IF NOT EXISTS idx_merchant_stock_npc ON merchant_stock(npc_id);",
|
||||
]
|
||||
|
||||
for index_sql in indexes:
|
||||
@@ -692,6 +743,246 @@ async def can_create_character(account_id: int) -> tuple[bool, str]:
|
||||
# ========================================================================
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# QUEST OPERATIONS
|
||||
# ========================================================================
|
||||
|
||||
async def get_character_quests(character_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all quests for a character"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(
|
||||
select(character_quests).where(character_quests.c.character_id == character_id)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
return [dict(row._mapping) for row in rows]
|
||||
|
||||
|
||||
async def get_character_quest(character_id: int, quest_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific quest for a character"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(
|
||||
select(character_quests).where(
|
||||
and_(
|
||||
character_quests.c.character_id == character_id,
|
||||
character_quests.c.quest_id == quest_id
|
||||
)
|
||||
)
|
||||
)
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
async def accept_quest(character_id: int, quest_id: str) -> Dict[str, Any]:
|
||||
"""Accept a new quest or restart a repeatable one"""
|
||||
# Check if exists first to handle restarts
|
||||
existing = await get_character_quest(character_id, quest_id)
|
||||
|
||||
async with DatabaseSession() as session:
|
||||
if existing:
|
||||
# Check if repeatable and cooldown passed
|
||||
# Validation should happen in logic layer, but good to be safe here
|
||||
stmt = update(character_quests).where(
|
||||
character_quests.c.id == existing['id']
|
||||
).values(
|
||||
status="active",
|
||||
progress={},
|
||||
started_at=time.time(),
|
||||
completed_at=None,
|
||||
# Preserve statistics
|
||||
).returning(character_quests)
|
||||
else:
|
||||
stmt = insert(character_quests).values(
|
||||
character_id=character_id,
|
||||
quest_id=quest_id,
|
||||
status="active",
|
||||
progress={},
|
||||
started_at=time.time(),
|
||||
times_completed=0
|
||||
).returning(character_quests)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
row = result.first()
|
||||
await session.commit()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
async def update_quest_progress(character_id: int, quest_id: str, progress: Dict, status: Optional[str] = None) -> bool:
|
||||
"""Update quest progress"""
|
||||
values = {"progress": progress}
|
||||
if status:
|
||||
values["status"] = status
|
||||
if status == "completed":
|
||||
values["completed_at"] = time.time()
|
||||
values["last_completed_at"] = time.time()
|
||||
# Increment times_completed
|
||||
# We need to read first or use a raw update expression,
|
||||
# simplest is to just increment in python for now or assume caller logic handles it
|
||||
# But let's do it right:
|
||||
# We can't easily do col + 1 in a simple update call without pulling in Table object to the values
|
||||
# So we'll rely on a fetch-update pattern or standard SQL
|
||||
pass
|
||||
|
||||
async with DatabaseSession() as session:
|
||||
# If completing, increment counter
|
||||
if status == "completed":
|
||||
# We can use the column expression for atomic increment
|
||||
values["times_completed"] = character_quests.c.times_completed + 1
|
||||
|
||||
stmt = update(character_quests).where(
|
||||
and_(
|
||||
character_quests.c.character_id == character_id,
|
||||
character_quests.c.quest_id == quest_id
|
||||
)
|
||||
).values(**values)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def set_quest_cooldown(character_id: int, quest_id: str, cooldown_expires_at: float) -> bool:
|
||||
"""Set cooldown for a quest"""
|
||||
async with DatabaseSession() as session:
|
||||
stmt = update(character_quests).where(
|
||||
and_(
|
||||
character_quests.c.character_id == character_id,
|
||||
character_quests.c.quest_id == quest_id
|
||||
)
|
||||
).values(cooldown_expires_at=cooldown_expires_at)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
# Global Quests
|
||||
async def get_global_quest(quest_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get global quest progress"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(
|
||||
select(global_quests).where(global_quests.c.quest_id == quest_id)
|
||||
)
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
async def update_global_quest(quest_id: str, progress: Dict, is_completed: bool = False) -> bool:
|
||||
"""Update or create global quest progress"""
|
||||
# Upsert logic
|
||||
existing = await get_global_quest(quest_id)
|
||||
|
||||
async with DatabaseSession() as session:
|
||||
if existing:
|
||||
stmt = update(global_quests).where(
|
||||
global_quests.c.quest_id == quest_id
|
||||
).values(
|
||||
global_progress=progress,
|
||||
is_completed=is_completed,
|
||||
updated_at=time.time()
|
||||
)
|
||||
else:
|
||||
stmt = insert(global_quests).values(
|
||||
quest_id=quest_id,
|
||||
global_progress=progress,
|
||||
is_completed=is_completed,
|
||||
updated_at=time.time()
|
||||
)
|
||||
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# MERCHANT OPERATIONS
|
||||
# ========================================================================
|
||||
|
||||
async def get_merchant_stock(npc_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get stock for a merchant"""
|
||||
async with DatabaseSession() as session:
|
||||
# Join with unique_items to get stats if applicable
|
||||
# This is a bit complex, let's just get the stock and helper can resolve details
|
||||
result = await session.execute(
|
||||
select(merchant_stock).where(merchant_stock.c.npc_id == npc_id)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
return [dict(row._mapping) for row in rows]
|
||||
|
||||
|
||||
async def update_merchant_stock(npc_id: str, item_id: str, quantity: int, unique_item_id: Optional[int] = None, update_restock_time: bool = False) -> bool:
|
||||
"""
|
||||
Update merchant stock quantity.
|
||||
If unique_item_id is provided, it targets that specific instance.
|
||||
If quantity <= 0, remove the row.
|
||||
If update_restock_time is True, updates last_restock_at to now.
|
||||
"""
|
||||
async with DatabaseSession() as session:
|
||||
# Check if exists
|
||||
conditions = [
|
||||
merchant_stock.c.npc_id == npc_id,
|
||||
merchant_stock.c.item_id == item_id
|
||||
]
|
||||
if unique_item_id is not None:
|
||||
conditions.append(merchant_stock.c.unique_item_id == unique_item_id)
|
||||
else:
|
||||
conditions.append(merchant_stock.c.unique_item_id.is_(None))
|
||||
|
||||
stmt = select(merchant_stock).where(and_(*conditions))
|
||||
result = await session.execute(stmt)
|
||||
existing = result.first()
|
||||
|
||||
if quantity <= 0:
|
||||
if existing:
|
||||
await session.execute(delete(merchant_stock).where(merchant_stock.c.id == existing.id))
|
||||
else:
|
||||
if existing:
|
||||
values = {"quantity": quantity}
|
||||
if update_restock_time:
|
||||
values["last_restock_at"] = time.time()
|
||||
|
||||
await session.execute(
|
||||
update(merchant_stock)
|
||||
.where(merchant_stock.c.id == existing.id)
|
||||
.values(**values)
|
||||
)
|
||||
else:
|
||||
await session.execute(
|
||||
insert(merchant_stock).values(
|
||||
npc_id=npc_id,
|
||||
item_id=item_id,
|
||||
unique_item_id=unique_item_id,
|
||||
quantity=quantity,
|
||||
last_restock_at=time.time()
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def get_merchant_stock_item(npc_id: str, item_id: str, unique_item_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get specific item from merchant stock"""
|
||||
async with DatabaseSession() as session:
|
||||
conditions = [
|
||||
merchant_stock.c.npc_id == npc_id,
|
||||
merchant_stock.c.item_id == item_id
|
||||
]
|
||||
if unique_item_id is not None:
|
||||
conditions.append(merchant_stock.c.unique_item_id == unique_item_id)
|
||||
else:
|
||||
conditions.append(merchant_stock.c.unique_item_id.is_(None))
|
||||
|
||||
result = await session.execute(select(merchant_stock).where(and_(*conditions)))
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
async def get_all_merchants() -> List[str]:
|
||||
"""Get list of all NPC IDs that have stock"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(select(merchant_stock.c.npc_id).distinct())
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
|
||||
|
||||
# Inventory operations
|
||||
# NOTE: Functions below use 'player_id' parameter name for backward compatibility
|
||||
|
||||
Reference in New Issue
Block a user