Added trading and quests, checkpoint push

This commit is contained in:
Joan
2026-02-08 20:18:42 +01:00
parent 8820cd897e
commit 70dc35b4b2
36 changed files with 3583 additions and 279 deletions

View File

@@ -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