Files
echoes-of-the-ash/api/database.py
2025-11-07 15:27:13 +01:00

1647 lines
60 KiB
Python

"""
Standalone database module for the API.
All database operations are contained here, making the API independent.
"""
import os
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import (
MetaData, Table, Column, Integer, String, Boolean, ForeignKey, Float, JSON,
select, insert, update, delete, and_, or_, text
)
import time
# Database connection
DB_USER = os.getenv("POSTGRES_USER")
DB_PASS = os.getenv("POSTGRES_PASSWORD")
DB_NAME = os.getenv("POSTGRES_DB")
DB_HOST = os.getenv("POSTGRES_HOST", "echoes_of_the_ashes_db")
DB_PORT = os.getenv("POSTGRES_PORT", "5432")
DATABASE_URL = f"postgresql+psycopg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
engine = create_async_engine(
DATABASE_URL,
echo=False,
pool_size=20, # Increased from default 5 to support 8 workers
max_overflow=30, # Allow bursts up to 50 total connections
pool_timeout=30, # Wait up to 30s for connection
pool_recycle=3600, # Recycle connections every hour
pool_pre_ping=True # Verify connections before use
)
async_session_maker = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
metadata = MetaData()
# Define all tables
players = Table(
"players",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("telegram_id", Integer, unique=True, nullable=True), # For Telegram users
Column("username", String(50), unique=True, nullable=True), # For web users
Column("password_hash", String(255), nullable=True), # For web users
Column("name", String, default="Survivor"),
Column("hp", Integer, default=100),
Column("max_hp", Integer, default=100),
Column("stamina", Integer, default=20),
Column("max_stamina", Integer, default=20),
Column("strength", Integer, default=5),
Column("agility", Integer, default=5),
Column("endurance", Integer, default=5),
Column("intellect", Integer, default=5),
Column("location_id", String, default="start_point"),
Column("is_dead", Boolean, default=False),
Column("level", Integer, default=1),
Column("xp", Integer, default=0),
Column("unspent_points", Integer, default=0),
Column("last_movement_time", Float, default=0), # Timestamp of last movement for cooldown
)
# Unique items table - single source of truth for individual item instances
unique_items = Table(
"unique_items",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("item_id", String, nullable=False), # References item template in items.json
Column("durability", Integer, nullable=True),
Column("max_durability", Integer, nullable=True),
Column("tier", Integer, default=1),
Column("unique_stats", JSON, nullable=True),
Column("created_at", Float, default=lambda: time.time()),
)
inventory = Table(
"inventory",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("player_id", Integer, ForeignKey("players.id", ondelete="CASCADE")),
Column("item_id", String), # For stackable items
Column("quantity", Integer, default=1),
Column("is_equipped", Boolean, default=False),
Column("unique_item_id", Integer, ForeignKey("unique_items.id", ondelete="CASCADE"), nullable=True), # For unique items
# Old columns kept for backward compatibility (can be removed in future)
Column("durability", Integer, nullable=True),
Column("max_durability", Integer, nullable=True),
Column("tier", Integer, nullable=True),
Column("unique_stats", JSON, nullable=True),
)
dropped_items = Table(
"dropped_items",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("item_id", String), # For stackable items
Column("quantity", Integer, default=1),
Column("location_id", String),
Column("drop_timestamp", Float),
Column("unique_item_id", Integer, ForeignKey("unique_items.id", ondelete="CASCADE"), nullable=True), # For unique items
# Old columns kept for backward compatibility (can be removed in future)
Column("durability", Integer, nullable=True),
Column("max_durability", Integer, nullable=True),
Column("tier", Integer, default=1),
Column("unique_stats", JSON, nullable=True),
)
active_combats = Table(
"active_combats",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("player_id", Integer, ForeignKey("players.id", ondelete="CASCADE"), unique=True),
Column("npc_id", String, nullable=False),
Column("npc_hp", Integer, nullable=False),
Column("npc_max_hp", Integer, nullable=False),
Column("turn", String, nullable=False), # "player" or "npc"
Column("turn_started_at", Float, nullable=False),
Column("player_status_effects", String, default=""),
Column("npc_status_effects", String, default=""),
Column("location_id", String, nullable=False),
Column("from_wandering_enemy", Boolean, default=False),
)
pvp_combats = Table(
"pvp_combats",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("attacker_id", Integer, ForeignKey("players.id", ondelete="CASCADE"), nullable=False),
Column("defender_id", Integer, ForeignKey("players.id", ondelete="CASCADE"), nullable=False),
Column("attacker_hp", Integer, nullable=False),
Column("defender_hp", Integer, nullable=False),
Column("turn", String, nullable=False), # "attacker" or "defender"
Column("turn_started_at", Float, nullable=False),
Column("turn_timeout_seconds", Integer, default=300), # 5 minutes default
Column("location_id", String, nullable=False),
Column("created_at", Float, nullable=False),
Column("attacker_fled", Boolean, default=False),
Column("defender_fled", Boolean, default=False),
Column("last_action", String, nullable=True), # Last combat action message
Column("attacker_acknowledged", Boolean, default=False), # Has attacker acknowledged combat end
Column("defender_acknowledged", Boolean, default=False), # Has defender acknowledged combat end
)
player_corpses = Table(
"player_corpses",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("player_name", String, nullable=False),
Column("location_id", String, nullable=False),
Column("items", String, nullable=False), # JSON string
Column("death_timestamp", Float, nullable=False),
)
npc_corpses = Table(
"npc_corpses",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("npc_id", String, nullable=False),
Column("location_id", String, nullable=False),
Column("loot_remaining", String, nullable=False),
Column("death_timestamp", Float, nullable=False),
)
interactable_cooldowns = Table(
"interactable_cooldowns",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("interactable_instance_id", String, nullable=False, unique=True),
Column("expiry_timestamp", Float, nullable=False),
)
wandering_enemies = Table(
"wandering_enemies",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("npc_id", String, nullable=False),
Column("location_id", String, nullable=False),
Column("spawn_timestamp", Float, nullable=False),
Column("despawn_timestamp", Float, nullable=False),
)
image_cache = Table(
"image_cache",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("image_path", String, nullable=False, unique=True),
Column("telegram_file_id", String, nullable=False),
Column("uploaded_at", Float, nullable=False),
)
player_status_effects = Table(
"player_status_effects",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("player_id", Integer, ForeignKey("players.id", ondelete="CASCADE"), nullable=False),
Column("effect_name", String(50), nullable=False),
Column("effect_icon", String(10), nullable=False),
Column("damage_per_tick", Integer, nullable=False, default=0),
Column("ticks_remaining", Integer, nullable=False),
Column("applied_at", Float, nullable=False),
)
player_statistics = Table(
"player_statistics",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("player_id", Integer, ForeignKey("players.id", ondelete="CASCADE"), nullable=False, unique=True),
Column("distance_walked", Integer, default=0), # Number of location moves
Column("enemies_killed", Integer, default=0),
Column("damage_dealt", Integer, default=0),
Column("damage_taken", Integer, default=0),
Column("hp_restored", Integer, default=0),
Column("stamina_used", Integer, default=0),
Column("stamina_restored", Integer, default=0),
Column("items_collected", Integer, default=0),
Column("items_dropped", Integer, default=0),
Column("items_used", Integer, default=0),
Column("deaths", Integer, default=0),
Column("successful_flees", Integer, default=0),
Column("failed_flees", Integer, default=0),
Column("combats_initiated", Integer, default=0),
# PvP Statistics
Column("pvp_combats_initiated", Integer, default=0),
Column("pvp_combats_won", Integer, default=0),
Column("pvp_combats_lost", Integer, default=0),
Column("pvp_damage_dealt", Integer, default=0),
Column("pvp_damage_taken", Integer, default=0),
Column("players_killed", Integer, default=0),
Column("pvp_deaths", Integer, default=0),
Column("pvp_successful_flees", Integer, default=0),
Column("pvp_failed_flees", Integer, default=0),
Column("pvp_attacks_landed", Integer, default=0),
Column("pvp_attacks_received", Integer, default=0),
Column("total_playtime", Integer, default=0), # Seconds
Column("last_activity", Float, nullable=True),
Column("created_at", Float, nullable=False),
)
# Database session context manager
class DatabaseSession:
"""Context manager for database sessions"""
def __init__(self):
self.session: Optional[AsyncSession] = None
async def __aenter__(self):
self.session = async_session_maker()
return self.session
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.session:
if exc_type is not None:
await self.session.rollback()
else:
await self.session.commit()
await self.session.close()
# Initialize database
async def init_db():
"""Create all tables and indexes if they don't exist"""
async with engine.begin() as conn:
await conn.run_sync(metadata.create_all)
# Create performance indexes
# These indexes significantly improve query performance on frequently accessed columns
indexes = [
# Players table - most commonly queried
"CREATE INDEX IF NOT EXISTS idx_players_username ON players(username);",
"CREATE INDEX IF NOT EXISTS idx_players_location_id ON players(location_id);",
# Dropped items - queried on every location view
"CREATE INDEX IF NOT EXISTS idx_dropped_items_location ON dropped_items(location_id);",
# Wandering enemies - checked frequently
"CREATE INDEX IF NOT EXISTS idx_wandering_enemies_location ON wandering_enemies(location_id);",
"CREATE INDEX IF NOT EXISTS idx_wandering_enemies_despawn ON wandering_enemies(despawn_timestamp);",
# Inventory - queried on every inventory operation
"CREATE INDEX IF NOT EXISTS idx_inventory_player_item ON inventory(player_id, item_id);",
"CREATE INDEX IF NOT EXISTS idx_inventory_player ON inventory(player_id);",
# Active combats - checked on most actions
"CREATE INDEX IF NOT EXISTS idx_active_combats_player ON active_combats(player_id);",
# Interactable cooldowns - checked on interact attempts
"CREATE INDEX IF NOT EXISTS idx_interactable_cooldowns_instance ON interactable_cooldowns(interactable_instance_id);",
]
for index_sql in indexes:
await conn.execute(text(index_sql))
# Player operations
async def get_player_by_id(player_id: int) -> Optional[Dict[str, Any]]:
"""Get player by internal ID"""
async with DatabaseSession() as session:
result = await session.execute(
select(players).where(players.c.id == player_id)
)
row = result.first()
return dict(row._mapping) if row else None
async def get_player_by_username(username: str) -> Optional[Dict[str, Any]]:
"""Get player by username (web users)"""
async with DatabaseSession() as session:
result = await session.execute(
select(players).where(players.c.username == username)
)
row = result.first()
return dict(row._mapping) if row else None
async def get_player_by_telegram_id(telegram_id: int) -> Optional[Dict[str, Any]]:
"""Get player by Telegram ID"""
async with DatabaseSession() as session:
result = await session.execute(
select(players).where(players.c.telegram_id == telegram_id)
)
row = result.first()
return dict(row._mapping) if row else None
async def create_player(
username: Optional[str] = None,
password_hash: Optional[str] = None,
telegram_id: Optional[int] = None,
name: str = "Survivor"
) -> Dict[str, Any]:
"""Create a new player"""
async with DatabaseSession() as session:
stmt = insert(players).values(
username=username,
password_hash=password_hash,
telegram_id=telegram_id,
name=name,
hp=100,
max_hp=100,
stamina=20,
max_stamina=20,
strength=5,
agility=5,
endurance=5,
intellect=5,
location_id="start_point",
is_dead=False,
level=1,
xp=0,
unspent_points=0,
).returning(players)
result = await session.execute(stmt)
row = result.first()
await session.commit()
return dict(row._mapping) if row else None
async def update_player(player_id: int, **kwargs) -> bool:
"""Update player fields"""
async with DatabaseSession() as session:
stmt = update(players).where(players.c.id == player_id).values(**kwargs)
await session.execute(stmt)
await session.commit()
return True
async def update_player_location(player_id: int, location_id: str) -> bool:
"""Update player location"""
return await update_player(player_id, location_id=location_id)
async def update_player_hp(player_id: int, hp: int) -> bool:
"""Update player HP"""
return await update_player(player_id, hp=hp)
async def update_player_stamina(player_id: int, stamina: int) -> bool:
"""Update player stamina"""
return await update_player(player_id, stamina=stamina)
# Inventory operations
async def get_inventory(player_id: int) -> List[Dict[str, Any]]:
"""Get player inventory"""
async with DatabaseSession() as session:
result = await session.execute(
select(inventory).where(inventory.c.player_id == player_id)
)
return [dict(row._mapping) for row in result.fetchall()]
async def add_item_to_inventory(
player_id: int,
item_id: str,
quantity: int = 1,
unique_item_id: Optional[int] = None, # Reference to existing unique_item
durability: Optional[int] = None, # For creating new unique items
max_durability: Optional[int] = None,
tier: Optional[int] = None,
unique_stats: Optional[Dict[str, Any]] = None
) -> bool:
"""
Add item to inventory.
For unique items: Either pass unique_item_id (existing) or durability/tier/stats (create new)
For stackable items: Just pass item_id and quantity
"""
async with DatabaseSession() as session:
# Determine if this is a unique item
is_unique = unique_item_id is not None or any([durability is not None, tier is not None, unique_stats is not None])
if is_unique:
# Create unique_item if needed
if unique_item_id is None:
unique_item_id = await create_unique_item(
item_id=item_id,
durability=durability,
max_durability=max_durability,
tier=tier,
unique_stats=unique_stats
)
# Insert inventory row referencing the unique_item
stmt = insert(inventory).values(
player_id=player_id,
item_id=item_id,
quantity=1, # Unique items are always quantity 1
is_equipped=False,
unique_item_id=unique_item_id
)
else:
# Stackable items - check if item already exists
result = await session.execute(
select(inventory).where(
and_(
inventory.c.player_id == player_id,
inventory.c.item_id == item_id,
inventory.c.unique_item_id.is_(None) # Only stack with other stackable items
)
)
)
existing = result.first()
if existing:
# Update quantity
stmt = update(inventory).where(
inventory.c.id == existing.id
).values(quantity=existing.quantity + quantity)
else:
# Insert new item
stmt = insert(inventory).values(
player_id=player_id,
item_id=item_id,
quantity=quantity,
is_equipped=False
)
await session.execute(stmt)
await session.commit()
return True
# Combat operations
async def get_active_combat(player_id: int) -> Optional[Dict[str, Any]]:
"""Get active combat for player"""
async with DatabaseSession() as session:
result = await session.execute(
select(active_combats).where(active_combats.c.player_id == player_id)
)
row = result.first()
return dict(row._mapping) if row else None
async def create_combat(player_id: int, npc_id: str, npc_hp: int, npc_max_hp: int, location_id: str, from_wandering: bool = False) -> Dict[str, Any]:
"""Create new combat"""
async with DatabaseSession() as session:
stmt = insert(active_combats).values(
player_id=player_id,
npc_id=npc_id,
npc_hp=npc_hp,
npc_max_hp=npc_max_hp,
turn="player",
turn_started_at=time.time(),
player_status_effects="",
npc_status_effects="",
location_id=location_id,
from_wandering_enemy=from_wandering
).returning(active_combats)
result = await session.execute(stmt)
row = result.first()
await session.commit()
return dict(row._mapping) if row else None
async def update_combat(player_id: int, updates: dict) -> bool:
"""Update combat state for player"""
async with DatabaseSession() as session:
stmt = update(active_combats).where(
active_combats.c.player_id == player_id
).values(**updates)
await session.execute(stmt)
await session.commit()
return True
async def end_combat(player_id: int) -> bool:
"""End combat for player"""
async with DatabaseSession() as session:
stmt = delete(active_combats).where(active_combats.c.player_id == player_id)
await session.execute(stmt)
await session.commit()
return True
# PvP Combat Functions
async def create_pvp_combat(attacker_id: int, defender_id: int, location_id: str, turn_timeout: int = 300) -> dict:
"""Create a new PvP combat. First turn goes to defender."""
async with DatabaseSession() as session:
# Get both players' HP
attacker = await get_player_by_id(attacker_id)
defender = await get_player_by_id(defender_id)
if not attacker or not defender:
return None
stmt = insert(pvp_combats).values(
attacker_id=attacker_id,
defender_id=defender_id,
attacker_hp=attacker['hp'],
defender_hp=defender['hp'],
turn='defender', # Defender goes first
turn_started_at=time.time(),
turn_timeout_seconds=turn_timeout,
location_id=location_id,
created_at=time.time(),
attacker_fled=False,
defender_fled=False
).returning(pvp_combats.c.id)
result = await session.execute(stmt)
await session.commit()
# Return the created combat
combat_id = result.scalar_one()
return await get_pvp_combat_by_id(combat_id)
async def get_pvp_combat_by_player(player_id: int) -> dict:
"""Get PvP combat involving a player (as attacker or defender)"""
async with DatabaseSession() as session:
stmt = select(pvp_combats).where(
or_(
pvp_combats.c.attacker_id == player_id,
pvp_combats.c.defender_id == player_id
)
)
result = await session.execute(stmt)
row = result.fetchone()
return dict(row._mapping) if row else None
async def get_pvp_combat_by_id(combat_id: int) -> dict:
"""Get PvP combat by ID"""
async with DatabaseSession() as session:
stmt = select(pvp_combats).where(pvp_combats.c.id == combat_id)
result = await session.execute(stmt)
row = result.fetchone()
return dict(row._mapping) if row else None
async def update_pvp_combat(combat_id: int, updates: dict) -> bool:
"""Update PvP combat"""
async with DatabaseSession() as session:
stmt = update(pvp_combats).where(pvp_combats.c.id == combat_id).values(**updates)
await session.execute(stmt)
await session.commit()
return True
async def end_pvp_combat(combat_id: int) -> bool:
"""Mark PvP combat as ended (don't delete yet - wait for acknowledgment)"""
# Combat is marked as ended via attacker_fled or defender_fled flags
# or by HP reaching 0. Don't delete until both players acknowledge.
return True
async def acknowledge_pvp_combat(combat_id: int, player_id: int) -> bool:
"""Acknowledge PvP combat end. Delete if both players acknowledged."""
async with DatabaseSession() as session:
# Get the combat
result = await session.execute(
select(pvp_combats).where(pvp_combats.c.id == combat_id)
)
combat = result.fetchone()
if not combat:
return False
# Determine if player is attacker or defender
is_attacker = combat.attacker_id == player_id
# Mark as acknowledged
if is_attacker:
stmt = update(pvp_combats).where(pvp_combats.c.id == combat_id).values(
attacker_acknowledged=True
)
else:
stmt = update(pvp_combats).where(pvp_combats.c.id == combat_id).values(
defender_acknowledged=True
)
await session.execute(stmt)
await session.commit()
# Check if both acknowledged - if so, delete the combat
result = await session.execute(
select(pvp_combats).where(pvp_combats.c.id == combat_id)
)
combat = result.fetchone()
if combat and combat.attacker_acknowledged and combat.defender_acknowledged:
stmt = delete(pvp_combats).where(pvp_combats.c.id == combat_id)
await session.execute(stmt)
await session.commit()
return True
async def get_all_pvp_combats() -> list:
"""Get all active PvP combats"""
async with DatabaseSession() as session:
result = await session.execute(select(pvp_combats))
return [dict(row._mapping) for row in result.fetchall()]
# Interactable cooldowns
async def set_interactable_cooldown(instance_id: str, cooldown_seconds: int) -> bool:
"""Set cooldown for an interactable"""
async with DatabaseSession() as session:
expiry = time.time() + cooldown_seconds
# Check if cooldown exists
result = await session.execute(
select(interactable_cooldowns).where(
interactable_cooldowns.c.interactable_instance_id == instance_id
)
)
existing = result.first()
if existing:
stmt = update(interactable_cooldowns).where(
interactable_cooldowns.c.interactable_instance_id == instance_id
).values(expiry_timestamp=expiry)
else:
stmt = insert(interactable_cooldowns).values(
interactable_instance_id=instance_id,
expiry_timestamp=expiry
)
await session.execute(stmt)
await session.commit()
return True
async def get_interactable_cooldown(instance_id: str) -> Optional[float]:
"""Get cooldown expiry timestamp for an interactable"""
async with DatabaseSession() as session:
result = await session.execute(
select(interactable_cooldowns).where(
interactable_cooldowns.c.interactable_instance_id == instance_id
)
)
row = result.first()
if row and row.expiry_timestamp > time.time():
return row.expiry_timestamp
return None
# Dropped items
async def get_dropped_items(location_id: str) -> List[Dict[str, Any]]:
"""Get all dropped items at a location"""
async with DatabaseSession() as session:
result = await session.execute(
select(dropped_items).where(dropped_items.c.location_id == location_id)
)
return [dict(row._mapping) for row in result.fetchall()]
async def add_dropped_item(
location_id: str,
item_id: str,
quantity: int = 1,
unique_item_id: Optional[int] = None
) -> bool:
"""Add a dropped item to a location (references unique_item if applicable)"""
async with DatabaseSession() as session:
# If this is a unique item, NEVER stack it - always create a new row
if unique_item_id is not None:
stmt = insert(dropped_items).values(
item_id=item_id,
quantity=1, # Unique items are always quantity 1
location_id=location_id,
drop_timestamp=time.time(),
unique_item_id=unique_item_id
)
else:
# For stackable items, try to stack with existing items in the same location
result = await session.execute(
select(dropped_items).where(
and_(
dropped_items.c.item_id == item_id,
dropped_items.c.location_id == location_id,
dropped_items.c.unique_item_id.is_(None) # Only stack with other stackable items
)
)
)
existing = result.first()
if existing:
# Stack with existing item
stmt = update(dropped_items).where(
dropped_items.c.id == existing.id
).values(
quantity=existing.quantity + quantity,
drop_timestamp=time.time()
)
else:
# Create new stack
stmt = insert(dropped_items).values(
item_id=item_id,
quantity=quantity,
location_id=location_id,
drop_timestamp=time.time(),
unique_item_id=None
)
await session.execute(stmt)
await session.commit()
return True
async def remove_item_from_inventory(player_id: int, item_id: str, quantity: int = 1) -> bool:
"""Remove item from inventory (for stackable items only)"""
async with DatabaseSession() as session:
# Get current item (only stackable items - no unique_item_id)
result = await session.execute(
select(inventory).where(
and_(
inventory.c.player_id == player_id,
inventory.c.item_id == item_id,
inventory.c.unique_item_id.is_(None) # Only target stackable items
)
)
)
existing = result.first()
if not existing:
return False
if existing.quantity <= quantity:
# Remove item completely
stmt = delete(inventory).where(inventory.c.id == existing.id)
else:
# Decrease quantity
stmt = update(inventory).where(inventory.c.id == existing.id).values(
quantity=existing.quantity - quantity
)
await session.execute(stmt)
await session.commit()
return True
async def remove_inventory_row(inventory_id: int) -> bool:
"""Remove a specific inventory row by ID (for unique items)"""
async with DatabaseSession() as session:
stmt = delete(inventory).where(inventory.c.id == inventory_id)
await session.execute(stmt)
await session.commit()
return True
async def update_item_equipped_status(player_id: int, item_id: str, is_equipped: bool) -> bool:
"""Update item equipped status"""
async with DatabaseSession() as session:
stmt = update(inventory).where(
and_(
inventory.c.player_id == player_id,
inventory.c.item_id == item_id
)
).values(is_equipped=is_equipped)
await session.execute(stmt)
await session.commit()
return True
async def get_inventory_item(item_db_id: int) -> Optional[Dict[str, Any]]:
"""Get a specific inventory item by database ID"""
async with DatabaseSession() as session:
stmt = select(inventory).where(inventory.c.id == item_db_id)
result = await session.execute(stmt)
row = result.first()
return dict(row._mapping) if row else None
# ============= DROPPED ITEMS =============
async def drop_item_to_world(
item_id: str,
quantity: int,
location_id: str,
unique_item_id: Optional[int] = None
) -> bool:
"""Drop an item to the world at a location (references unique_item if applicable)"""
async with DatabaseSession() as session:
stmt = insert(dropped_items).values(
item_id=item_id,
quantity=quantity,
location_id=location_id,
drop_timestamp=time.time(),
unique_item_id=unique_item_id
)
await session.execute(stmt)
await session.commit()
return True
async def get_dropped_item(dropped_item_id: int) -> Optional[Dict[str, Any]]:
"""Get a specific dropped item by ID"""
async with DatabaseSession() as session:
stmt = select(dropped_items).where(dropped_items.c.id == dropped_item_id)
result = await session.execute(stmt)
row = result.first()
return dict(row._mapping) if row else None
async def get_dropped_items_in_location(location_id: str) -> List[Dict[str, Any]]:
"""Get all dropped items in a specific location"""
async with DatabaseSession() as session:
stmt = select(dropped_items).where(dropped_items.c.location_id == location_id)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result.all()]
async def update_dropped_item(dropped_item_id: int, quantity: int) -> bool:
"""Update dropped item quantity"""
async with DatabaseSession() as session:
stmt = update(dropped_items).where(
dropped_items.c.id == dropped_item_id
).values(quantity=quantity)
await session.execute(stmt)
await session.commit()
return True
async def remove_dropped_item(dropped_item_id: int) -> bool:
"""Remove a dropped item from the world"""
async with DatabaseSession() as session:
stmt = delete(dropped_items).where(dropped_items.c.id == dropped_item_id)
await session.execute(stmt)
await session.commit()
return True
async def update_dropped_item_quantity(dropped_item_id: int, new_quantity: int) -> bool:
"""Update the quantity of a dropped item"""
async with DatabaseSession() as session:
stmt = update(dropped_items).where(
dropped_items.c.id == dropped_item_id
).values(quantity=new_quantity)
await session.execute(stmt)
await session.commit()
return True
# ============= CORPSES =============
async def create_player_corpse(player_name: str, location_id: str, items: str) -> int:
"""Create a player corpse with items"""
async with DatabaseSession() as session:
stmt = insert(player_corpses).values(
player_name=player_name,
location_id=location_id,
items=items,
death_timestamp=time.time()
).returning(player_corpses.c.id)
result = await session.execute(stmt)
corpse_id = result.scalar()
await session.commit()
return corpse_id
async def get_player_corpse(corpse_id: int) -> Optional[Dict[str, Any]]:
"""Get a player corpse by ID"""
async with DatabaseSession() as session:
stmt = select(player_corpses).where(player_corpses.c.id == corpse_id)
result = await session.execute(stmt)
row = result.first()
return dict(row._mapping) if row else None
async def update_player_corpse(corpse_id: int, items: str) -> bool:
"""Update player corpse items"""
async with DatabaseSession() as session:
stmt = update(player_corpses).where(
player_corpses.c.id == corpse_id
).values(items=items)
await session.execute(stmt)
await session.commit()
return True
async def remove_player_corpse(corpse_id: int) -> bool:
"""Remove a player corpse"""
async with DatabaseSession() as session:
stmt = delete(player_corpses).where(player_corpses.c.id == corpse_id)
await session.execute(stmt)
await session.commit()
return True
async def create_npc_corpse(npc_id: str, location_id: str, loot_remaining: str) -> int:
"""Create an NPC corpse with loot"""
async with DatabaseSession() as session:
stmt = insert(npc_corpses).values(
npc_id=npc_id,
location_id=location_id,
loot_remaining=loot_remaining,
death_timestamp=time.time()
).returning(npc_corpses.c.id)
result = await session.execute(stmt)
corpse_id = result.scalar()
await session.commit()
return corpse_id
async def get_npc_corpse(corpse_id: int) -> Optional[Dict[str, Any]]:
"""Get an NPC corpse by ID"""
async with DatabaseSession() as session:
stmt = select(npc_corpses).where(npc_corpses.c.id == corpse_id)
result = await session.execute(stmt)
row = result.first()
return dict(row._mapping) if row else None
async def update_npc_corpse(corpse_id: int, loot_remaining: str) -> bool:
"""Update NPC corpse loot"""
async with DatabaseSession() as session:
stmt = update(npc_corpses).where(
npc_corpses.c.id == corpse_id
).values(loot_remaining=loot_remaining)
await session.execute(stmt)
await session.commit()
return True
async def remove_npc_corpse(corpse_id: int) -> bool:
"""Remove an NPC corpse"""
async with DatabaseSession() as session:
stmt = delete(npc_corpses).where(npc_corpses.c.id == corpse_id)
await session.execute(stmt)
await session.commit()
return True
async def get_npc_corpses_in_location(location_id: str) -> list:
"""Get all NPC corpses at a location, sorted by death_timestamp (newest first)"""
async with DatabaseSession() as session:
stmt = select(npc_corpses).where(npc_corpses.c.location_id == location_id).order_by(npc_corpses.c.death_timestamp.desc())
result = await session.execute(stmt)
rows = result.fetchall()
return [dict(row._mapping) for row in rows]
async def get_player_corpses_in_location(location_id: str) -> list:
"""Get all player corpses at a location, sorted by death_timestamp (newest first)"""
async with DatabaseSession() as session:
stmt = select(player_corpses).where(player_corpses.c.location_id == location_id).order_by(player_corpses.c.death_timestamp.desc())
result = await session.execute(stmt)
rows = result.fetchall()
return [dict(row._mapping) for row in rows]
# ============= WANDERING ENEMIES =============
async def spawn_wandering_enemy(npc_id: str, location_id: str, current_hp: int, max_hp: int) -> int:
"""Spawn a wandering enemy at a location"""
async with DatabaseSession() as session:
stmt = insert(wandering_enemies).values(
npc_id=npc_id,
location_id=location_id,
current_hp=current_hp,
max_hp=max_hp,
spawn_timestamp=time.time()
).returning(wandering_enemies.c.id)
result = await session.execute(stmt)
enemy_id = result.scalar()
await session.commit()
return enemy_id
async def get_wandering_enemies_in_location(location_id: str) -> List[Dict[str, Any]]:
"""Get all wandering enemies in a location"""
async with DatabaseSession() as session:
stmt = select(wandering_enemies).where(wandering_enemies.c.location_id == location_id)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result.all()]
async def remove_wandering_enemy(enemy_id: int) -> bool:
"""Remove a wandering enemy"""
async with DatabaseSession() as session:
stmt = delete(wandering_enemies).where(wandering_enemies.c.id == enemy_id)
await session.execute(stmt)
await session.commit()
return True
# ============= COOLDOWNS =============
async def get_cooldown(cooldown_key: str) -> int:
"""Get remaining cooldown time in seconds (0 if expired or not found)"""
async with DatabaseSession() as session:
stmt = select(interactable_cooldowns).where(
interactable_cooldowns.c.interactable_instance_id == cooldown_key
)
result = await session.execute(stmt)
row = result.first()
if not row:
return 0
expiry = row.expiry_timestamp
current_time = time.time()
if current_time >= expiry:
# Expired, clean up
await session.execute(
delete(interactable_cooldowns).where(
interactable_cooldowns.c.interactable_instance_id == cooldown_key
)
)
await session.commit()
return 0
return int(expiry - current_time)
async def set_cooldown(cooldown_key: str, duration_seconds: int = 600) -> bool:
"""Set a cooldown (default 10 minutes)"""
async with DatabaseSession() as session:
expiry_time = time.time() + duration_seconds
# Upsert - update if exists, insert if not
stmt = insert(interactable_cooldowns).values(
interactable_instance_id=cooldown_key,
expiry_timestamp=expiry_time
)
# PostgreSQL specific upsert syntax
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = pg_insert(interactable_cooldowns).values(
interactable_instance_id=cooldown_key,
expiry_timestamp=expiry_time
).on_conflict_do_update(
index_elements=['interactable_instance_id'],
set_={'expiry_timestamp': expiry_time}
)
await session.execute(stmt)
await session.commit()
return True
# ============= CORPSE LISTS =============
async def get_player_corpses_in_location(location_id: str) -> List[Dict[str, Any]]:
"""Get all player corpses in a location, sorted by death_timestamp (oldest first)"""
async with DatabaseSession() as session:
stmt = select(player_corpses).where(player_corpses.c.location_id == location_id).order_by(player_corpses.c.death_timestamp.asc())
result = await session.execute(stmt)
return [dict(row._mapping) for row in result.all()]
async def get_npc_corpses_in_location(location_id: str) -> List[Dict[str, Any]]:
"""Get all NPC corpses in a location, sorted by death_timestamp (oldest first)"""
async with DatabaseSession() as session:
stmt = select(npc_corpses).where(npc_corpses.c.location_id == location_id).order_by(npc_corpses.c.death_timestamp.asc())
result = await session.execute(stmt)
return [dict(row._mapping) for row in result.all()]
# ============= IMAGE CACHE =============
async def get_cached_image(image_path: str) -> Optional[str]:
"""Get cached telegram file ID for an image path"""
async with DatabaseSession() as session:
stmt = select(image_cache).where(image_cache.c.image_path == image_path)
result = await session.execute(stmt)
row = result.first()
return row.telegram_file_id if row else None
async def cache_image(image_path: str, telegram_file_id: str) -> bool:
"""Cache a telegram file ID for an image path"""
async with DatabaseSession() as session:
stmt = insert(image_cache).values(
image_path=image_path,
telegram_file_id=telegram_file_id,
uploaded_at=time.time()
)
await session.execute(stmt)
await session.commit()
return True
# ============= STATUS EFFECTS =============
async def get_player_status_effects(player_id: int) -> List[Dict[str, Any]]:
"""Get all active status effects for a player"""
async with DatabaseSession() as session:
stmt = select(player_status_effects).where(player_status_effects.c.player_id == player_id)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result.all()]
# ============= PLAYER STATISTICS =============
async def get_player_statistics(player_id: int) -> Optional[Dict[str, Any]]:
"""Get player statistics"""
async with DatabaseSession() as session:
stmt = select(player_statistics).where(player_statistics.c.player_id == player_id)
result = await session.execute(stmt)
row = result.first()
if row:
return dict(row._mapping)
else:
# Create initial statistics for player
stmt = insert(player_statistics).values(
player_id=player_id,
created_at=time.time(),
last_activity=time.time()
)
await session.execute(stmt)
await session.commit()
# Return the newly created stats
stmt = select(player_statistics).where(player_statistics.c.player_id == player_id)
result = await session.execute(stmt)
row = result.first()
return dict(row._mapping) if row else None
async def update_player_statistics(player_id: int, **kwargs) -> bool:
"""
Update player statistics. Use increment=True in kwargs to add to existing value.
Example: update_player_statistics(1, enemies_killed=1, increment=True)
"""
async with DatabaseSession() as session:
# Ensure stats exist
await get_player_statistics(player_id)
increment = kwargs.pop('increment', False)
kwargs['last_activity'] = time.time()
if increment:
# Get current stats to increment
current_stats = await get_player_statistics(player_id)
for key, value in kwargs.items():
if key in current_stats and key != 'last_activity':
kwargs[key] = current_stats[key] + value
stmt = update(player_statistics).where(
player_statistics.c.player_id == player_id
).values(**kwargs)
await session.execute(stmt)
await session.commit()
return True
async def get_leaderboard(stat_name: str, limit: int = 100) -> List[Dict[str, Any]]:
"""Get leaderboard for a specific stat"""
async with DatabaseSession() as session:
# Join with players table to get username
stmt = select(
player_statistics,
players.c.username,
players.c.name,
players.c.level
).join(
players, player_statistics.c.player_id == players.c.id
).where(
getattr(player_statistics.c, stat_name) > 0
).order_by(
getattr(player_statistics.c, stat_name).desc()
).limit(limit)
result = await session.execute(stmt)
rows = result.all()
leaderboard = []
for i, row in enumerate(rows, 1):
data = dict(row._mapping)
leaderboard.append({
"rank": i,
"player_id": data['player_id'],
"username": data['username'],
"name": data['name'],
"level": data['level'],
"value": data[stat_name]
})
return leaderboard
# ============================================================================
# EQUIPMENT SYSTEM
# ============================================================================
async def get_equipped_item_in_slot(player_id: int, slot: str) -> Optional[Dict[str, Any]]:
"""Get the equipped item in a specific slot"""
async with DatabaseSession() as session:
stmt = text("""
SELECT * FROM equipment_slots
WHERE player_id = :player_id AND slot_type = :slot
""")
result = await session.execute(stmt, {"player_id": player_id, "slot": slot})
row = result.first()
return dict(row._mapping) if row else None
async def equip_item(player_id: int, slot: str, inventory_item_id: int) -> bool:
"""Equip an item to a slot"""
async with DatabaseSession() as session:
stmt = text("""
INSERT INTO equipment_slots (player_id, slot_type, item_id)
VALUES (:player_id, :slot, :item_id)
ON CONFLICT (player_id, slot_type)
DO UPDATE SET item_id = :item_id
""")
await session.execute(stmt, {
"player_id": player_id,
"slot": slot,
"item_id": inventory_item_id
})
await session.commit()
return True
async def unequip_item(player_id: int, slot: str) -> bool:
"""Unequip an item from a slot"""
async with DatabaseSession() as session:
stmt = text("""
UPDATE equipment_slots
SET item_id = NULL
WHERE player_id = :player_id AND slot_type = :slot
""")
await session.execute(stmt, {"player_id": player_id, "slot": slot})
await session.commit()
return True
async def get_all_equipment(player_id: int) -> Dict[str, Optional[Dict[str, Any]]]:
"""Get all equipped items for a player"""
async with DatabaseSession() as session:
stmt = text("""
SELECT slot_type, item_id FROM equipment_slots
WHERE player_id = :player_id
""")
result = await session.execute(stmt, {"player_id": player_id})
rows = result.fetchall()
equipment = {}
for row in rows:
slot = row[0]
item_id = row[1]
equipment[slot] = {"item_id": item_id} if item_id else None
return equipment
async def update_encumbrance(player_id: int) -> int:
"""Calculate and update player encumbrance based on equipped items"""
# This will be called after equip/unequip
# For now, just set to 0, we'll implement the calculation in game logic
async with DatabaseSession() as session:
stmt = text("""
UPDATE players SET encumbrance = 0
WHERE id = :player_id
""")
await session.execute(stmt, {"player_id": player_id})
await session.commit()
return 0
async def get_inventory_item_by_id(inventory_id: int) -> Optional[Dict[str, Any]]:
"""Get a specific inventory item by its ID"""
async with DatabaseSession() as session:
stmt = text("""
SELECT * FROM inventory WHERE id = :id
""")
result = await session.execute(stmt, {"id": inventory_id})
row = result.first()
return dict(row._mapping) if row else None
async def update_inventory_item(inventory_id: int, **kwargs) -> bool:
"""Update an inventory item's properties"""
if not kwargs:
return False
async with DatabaseSession() as session:
# Build UPDATE statement dynamically
set_clauses = [f"{key} = :{key}" for key in kwargs.keys()]
stmt_str = f"""
UPDATE inventory
SET {', '.join(set_clauses)}
WHERE id = :inventory_id
"""
params = {"inventory_id": inventory_id, **kwargs}
await session.execute(text(stmt_str), params)
await session.commit()
return True
async def decrease_item_durability(inventory_id: int, amount: int = 1) -> Optional[int]:
"""Decrease an item's durability and return new value"""
async with DatabaseSession() as session:
# Get current durability
stmt = text("SELECT durability FROM inventory WHERE id = :id")
result = await session.execute(stmt, {"id": inventory_id})
row = result.first()
if not row or row[0] is None:
return None
new_durability = max(0, row[0] - amount)
# Update durability
stmt = text("""
UPDATE inventory SET durability = :durability
WHERE id = :id
""")
await session.execute(stmt, {"durability": new_durability, "id": inventory_id})
await session.commit()
return new_durability
# ============================================================================
# UNIQUE ITEMS MANAGEMENT
# ============================================================================
async def create_unique_item(
item_id: str,
durability: Optional[int] = None,
max_durability: Optional[int] = None,
tier: Optional[int] = None,
unique_stats: Optional[Dict[str, Any]] = None
) -> int:
"""Create a new unique item instance and return its ID"""
async with DatabaseSession() as session:
stmt = insert(unique_items).values(
item_id=item_id,
durability=durability,
max_durability=max_durability,
tier=tier,
unique_stats=unique_stats
)
result = await session.execute(stmt)
await session.commit()
return result.inserted_primary_key[0]
async def get_unique_item(unique_item_id: int) -> Optional[Dict[str, Any]]:
"""Get a unique item by ID"""
async with DatabaseSession() as session:
result = await session.execute(
select(unique_items).where(unique_items.c.id == unique_item_id)
)
row = result.first()
return dict(row._mapping) if row else None
async def update_unique_item(unique_item_id: int, **kwargs) -> bool:
"""Update a unique item's properties"""
async with DatabaseSession() as session:
stmt = update(unique_items).where(
unique_items.c.id == unique_item_id
).values(**kwargs)
await session.execute(stmt)
await session.commit()
return True
async def delete_unique_item(unique_item_id: int) -> bool:
"""Delete a unique item (will cascade to inventory/dropped_items references)"""
async with DatabaseSession() as session:
stmt = delete(unique_items).where(unique_items.c.id == unique_item_id)
await session.execute(stmt)
await session.commit()
return True
async def decrease_unique_item_durability(unique_item_id: int, amount: int = 1) -> Optional[int]:
"""
Decrease durability of a unique item. If it reaches 0, delete the item.
Returns new durability, or None if item was deleted.
"""
async with DatabaseSession() as session:
# Get current durability
result = await session.execute(
select(unique_items.c.durability).where(unique_items.c.id == unique_item_id)
)
row = result.first()
if not row or row[0] is None:
return None
new_durability = max(0, row[0] - amount)
if new_durability <= 0:
# Item broken - delete it (cascades to inventory/dropped_items)
await delete_unique_item(unique_item_id)
return None
else:
# Update durability
stmt = update(unique_items).where(
unique_items.c.id == unique_item_id
).values(durability=new_durability)
await session.execute(stmt)
await session.commit()
return new_durability
# ============================================================================
# COMBAT TIMER FUNCTIONS
# ============================================================================
async def get_all_idle_combats(idle_threshold: float):
"""Get all combats where the turn has been idle too long."""
async with DatabaseSession() as session:
result = await session.execute(
select(active_combats).where(active_combats.c.turn_started_at < idle_threshold)
)
return [row._asdict() for row in result.fetchall()]
# ============================================================================
# CORPSE MANAGEMENT FUNCTIONS
# ============================================================================
async def create_player_corpse(player_name: str, location_id: str, items: list):
"""Create a player corpse bag."""
import time
async with DatabaseSession() as session:
stmt = player_corpses.insert().values(
player_name=player_name,
location_id=location_id,
items=items,
death_timestamp=time.time()
)
await session.execute(stmt)
await session.commit()
async def remove_expired_player_corpses(timestamp_limit: float) -> int:
"""Remove old player corpses."""
async with DatabaseSession() as session:
stmt = delete(player_corpses).where(player_corpses.c.death_timestamp < timestamp_limit)
result = await session.execute(stmt)
await session.commit()
return result.rowcount
async def remove_expired_npc_corpses(timestamp_limit: float) -> int:
"""Remove old NPC corpses."""
async with DatabaseSession() as session:
stmt = delete(npc_corpses).where(npc_corpses.c.death_timestamp < timestamp_limit)
result = await session.execute(stmt)
await session.commit()
return result.rowcount
# ============================================================================
# STATUS EFFECTS FUNCTIONS
# ============================================================================
async def get_player_status_effects(player_id: int):
"""Get all active status effects for a player."""
async with DatabaseSession() as session:
result = await session.execute(
select(player_status_effects).where(
and_(
player_status_effects.c.player_id == player_id,
player_status_effects.c.ticks_remaining > 0
)
)
)
return [row._asdict() for row in result.fetchall()]
async def remove_all_status_effects(player_id: int):
"""Remove all status effects from a player."""
async with DatabaseSession() as session:
await session.execute(
delete(player_status_effects).where(player_status_effects.c.player_id == player_id)
)
await session.commit()
async def decrement_all_status_effect_ticks():
"""
Decrement ticks for all active status effects and return affected player IDs.
Used by background processor.
"""
async with DatabaseSession() as session:
# Get player IDs with effects before updating
from sqlalchemy import distinct
result = await session.execute(
select(distinct(player_status_effects.c.player_id)).where(
player_status_effects.c.ticks_remaining > 0
)
)
affected_players = [row[0] for row in result.fetchall()]
# Decrement ticks
await session.execute(
update(player_status_effects).where(
player_status_effects.c.ticks_remaining > 0
).values(ticks_remaining=player_status_effects.c.ticks_remaining - 1)
)
# Remove expired effects
await session.execute(
delete(player_status_effects).where(player_status_effects.c.ticks_remaining <= 0)
)
await session.commit()
return affected_players
# ============================================================================
# WANDERING ENEMY SPAWN FUNCTIONS
# ============================================================================
async def spawn_wandering_enemy(npc_id: str, location_id: str, lifetime_seconds: int = 600):
"""Spawn a wandering enemy at a location. Lifetime defaults to 10 minutes."""
import time
async with DatabaseSession() as session:
current_time = time.time()
despawn_time = current_time + lifetime_seconds
stmt = wandering_enemies.insert().values(
npc_id=npc_id,
location_id=location_id,
spawn_timestamp=current_time,
despawn_timestamp=despawn_time
)
await session.execute(stmt)
await session.commit()
async def cleanup_expired_wandering_enemies():
"""Remove all expired wandering enemies."""
import time
async with DatabaseSession() as session:
current_time = time.time()
stmt = delete(wandering_enemies).where(wandering_enemies.c.despawn_timestamp <= current_time)
result = await session.execute(stmt)
await session.commit()
return result.rowcount # Number of enemies despawned
async def get_wandering_enemy_count_in_location(location_id: str) -> int:
"""Count active wandering enemies at a location."""
import time
async with DatabaseSession() as session:
current_time = time.time()
result = await session.execute(
select(wandering_enemies).where(
and_(
wandering_enemies.c.location_id == location_id,
wandering_enemies.c.despawn_timestamp > current_time
)
)
)
return len(result.fetchall())
async def get_all_active_wandering_enemies():
"""Get all active wandering enemies across all locations."""
import time
async with DatabaseSession() as session:
current_time = time.time()
result = await session.execute(
select(wandering_enemies).where(wandering_enemies.c.despawn_timestamp > current_time)
)
return [row._asdict() for row in result.fetchall()]
# ============================================================================
# STAMINA REGENERATION FUNCTIONS
# ============================================================================
async def regenerate_all_players_stamina() -> int:
"""
Regenerate stamina for all active players using a single optimized query.
Recovery formula:
- Base recovery: 1 stamina per cycle (5 minutes)
- Endurance bonus: +1 stamina per 10 endurance points
- Example: 5 endurance = 1 stamina, 15 endurance = 2 stamina, 25 endurance = 3 stamina
- Only regenerates up to max_stamina
- Only regenerates for living players
PERFORMANCE: Single SQL query, scales to 100K+ players efficiently.
"""
from sqlalchemy import text
async with DatabaseSession() as session:
# Single UPDATE query with database-side calculation
# Much more efficient than fetching all players and updating individually
stmt = text("""
UPDATE players
SET stamina = LEAST(
stamina + 1 + (endurance / 10),
max_stamina
)
WHERE is_dead = FALSE
AND stamina < max_stamina
""")
result = await session.execute(stmt)
await session.commit()
return result.rowcount
# ============================================================================
# DROPPED ITEMS CLEANUP FUNCTIONS
# ============================================================================
async def remove_expired_dropped_items(timestamp_limit: float) -> int:
"""Remove old dropped items from the world."""
async with DatabaseSession() as session:
stmt = delete(dropped_items).where(dropped_items.c.drop_timestamp < timestamp_limit)
result = await session.execute(stmt)
await session.commit()
return result.rowcount