Initial commit: Complete PetBot IRC Game

🎮 Features implemented:
- Pokemon-style pet collection and battles
- Multi-location exploration system
- Dynamic weather with background updates
- Achievement system with location unlocks
- Web dashboard for player stats
- Modular command system
- Async database with SQLite
- PM flood prevention
- Persistent player data

🌤️ Weather System:
- 6 weather types with spawn modifiers
- 30min-3hour dynamic durations
- Background task for automatic updates
- Location-specific weather patterns

🐛 Recent Bug Fixes:
- Database persistence on restart
- Player page SQLite row conversion
- Achievement count calculations
- Travel requirement messages
- Battle move color coding
- Locations page display

🔧 Generated with Claude Code
🤖 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
megaproxy 2025-07-13 23:57:39 +01:00
commit 47f160a295
31 changed files with 6235 additions and 0 deletions

522
src/database.py Normal file
View file

@ -0,0 +1,522 @@
import aiosqlite
import json
from typing import Dict, List, Optional, Tuple
from datetime import datetime
class Database:
def __init__(self, db_path: str = "data/petbot.db"):
self.db_path = db_path
async def init_database(self):
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nickname TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
level INTEGER DEFAULT 1,
experience INTEGER DEFAULT 0,
money INTEGER DEFAULT 100,
current_location_id INTEGER DEFAULT NULL,
FOREIGN KEY (current_location_id) REFERENCES locations (id)
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS pet_species (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
type1 TEXT NOT NULL,
type2 TEXT,
base_hp INTEGER NOT NULL,
base_attack INTEGER NOT NULL,
base_defense INTEGER NOT NULL,
base_speed INTEGER NOT NULL,
evolution_level INTEGER,
evolution_species_id INTEGER,
rarity INTEGER DEFAULT 1,
FOREIGN KEY (evolution_species_id) REFERENCES pet_species (id)
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS pets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_id INTEGER NOT NULL,
species_id INTEGER NOT NULL,
nickname TEXT,
level INTEGER DEFAULT 1,
experience INTEGER DEFAULT 0,
hp INTEGER NOT NULL,
max_hp INTEGER NOT NULL,
attack INTEGER NOT NULL,
defense INTEGER NOT NULL,
speed INTEGER NOT NULL,
happiness INTEGER DEFAULT 50,
caught_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT FALSE,
FOREIGN KEY (player_id) REFERENCES players (id),
FOREIGN KEY (species_id) REFERENCES pet_species (id)
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS moves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
type TEXT NOT NULL,
category TEXT NOT NULL,
power INTEGER,
accuracy INTEGER DEFAULT 100,
pp INTEGER DEFAULT 10,
description TEXT
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS pet_moves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pet_id INTEGER NOT NULL,
move_id INTEGER NOT NULL,
current_pp INTEGER,
FOREIGN KEY (pet_id) REFERENCES pets (id),
FOREIGN KEY (move_id) REFERENCES moves (id),
UNIQUE(pet_id, move_id)
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS battles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player1_id INTEGER NOT NULL,
player2_id INTEGER,
battle_type TEXT NOT NULL,
status TEXT DEFAULT 'active',
winner_id INTEGER,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ended_at TIMESTAMP,
FOREIGN KEY (player1_id) REFERENCES players (id),
FOREIGN KEY (player2_id) REFERENCES players (id),
FOREIGN KEY (winner_id) REFERENCES players (id)
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
description TEXT,
level_min INTEGER DEFAULT 1,
level_max INTEGER DEFAULT 5
)
""")
# Add current_location_id column if it doesn't exist
try:
await db.execute("ALTER TABLE players ADD COLUMN current_location_id INTEGER DEFAULT 1")
await db.commit()
print("Added current_location_id column to players table")
except:
pass # Column already exists
await db.execute("""
CREATE TABLE IF NOT EXISTS location_spawns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
location_id INTEGER NOT NULL,
species_id INTEGER NOT NULL,
spawn_rate REAL DEFAULT 0.1,
min_level INTEGER DEFAULT 1,
max_level INTEGER DEFAULT 5,
FOREIGN KEY (location_id) REFERENCES locations (id),
FOREIGN KEY (species_id) REFERENCES pet_species (id)
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS achievements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
description TEXT,
requirement_type TEXT NOT NULL,
requirement_data TEXT,
unlock_location_id INTEGER,
FOREIGN KEY (unlock_location_id) REFERENCES locations (id)
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS player_achievements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_id INTEGER NOT NULL,
achievement_id INTEGER NOT NULL,
completed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (player_id) REFERENCES players (id),
FOREIGN KEY (achievement_id) REFERENCES achievements (id),
UNIQUE(player_id, achievement_id)
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS location_weather (
id INTEGER PRIMARY KEY AUTOINCREMENT,
location_id INTEGER NOT NULL,
weather_type TEXT NOT NULL,
active_until TIMESTAMP,
spawn_modifier REAL DEFAULT 1.0,
affected_types TEXT,
FOREIGN KEY (location_id) REFERENCES locations (id)
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS active_battles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_id INTEGER NOT NULL,
wild_pet_data TEXT NOT NULL,
player_pet_id INTEGER NOT NULL,
player_hp INTEGER NOT NULL,
wild_hp INTEGER NOT NULL,
turn_count INTEGER DEFAULT 1,
current_turn TEXT DEFAULT 'player',
battle_status TEXT DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (player_id) REFERENCES players (id),
FOREIGN KEY (player_pet_id) REFERENCES pets (id)
)
""")
await db.commit()
async def get_player(self, nickname: str) -> Optional[Dict]:
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM players WHERE nickname = ?", (nickname,)
)
row = await cursor.fetchone()
return dict(row) if row else None
async def create_player(self, nickname: str) -> int:
async with aiosqlite.connect(self.db_path) as db:
# Get Starter Town ID
cursor = await db.execute("SELECT id FROM locations WHERE name = 'Starter Town'")
starter_town = await cursor.fetchone()
if not starter_town:
raise Exception("Starter Town location not found in database - ensure game data is loaded first")
starter_town_id = starter_town[0]
cursor = await db.execute(
"INSERT INTO players (nickname, current_location_id) VALUES (?, ?)",
(nickname, starter_town_id)
)
await db.commit()
return cursor.lastrowid
async def get_player_pets(self, player_id: int, active_only: bool = False) -> List[Dict]:
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
query = """
SELECT p.*, ps.name as species_name, ps.type1, ps.type2
FROM pets p
JOIN pet_species ps ON p.species_id = ps.id
WHERE p.player_id = ?
"""
params = [player_id]
if active_only:
query += " AND p.is_active = TRUE"
cursor = await db.execute(query, params)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
async def get_player_location(self, player_id: int) -> Optional[Dict]:
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
SELECT l.* FROM locations l
JOIN players p ON p.current_location_id = l.id
WHERE p.id = ?
""", (player_id,))
row = await cursor.fetchone()
return dict(row) if row else None
async def update_player_location(self, player_id: int, location_id: int):
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"UPDATE players SET current_location_id = ? WHERE id = ?",
(location_id, player_id)
)
await db.commit()
async def get_location_by_name(self, location_name: str) -> Optional[Dict]:
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM locations WHERE name = ?", (location_name,)
)
row = await cursor.fetchone()
return dict(row) if row else None
async def check_player_achievements(self, player_id: int, achievement_type: str, data: str):
"""Check and award achievements based on player actions"""
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
# Get relevant achievements not yet earned
cursor = await db.execute("""
SELECT a.* FROM achievements a
WHERE a.requirement_type = ?
AND a.id NOT IN (
SELECT pa.achievement_id FROM player_achievements pa
WHERE pa.player_id = ?
)
""", (achievement_type, player_id))
achievements = await cursor.fetchall()
newly_earned = []
for achievement in achievements:
if await self._check_achievement_requirement(player_id, achievement, data):
# Award achievement
await db.execute("""
INSERT INTO player_achievements (player_id, achievement_id)
VALUES (?, ?)
""", (player_id, achievement["id"]))
newly_earned.append(dict(achievement))
await db.commit()
return newly_earned
async def _check_achievement_requirement(self, player_id: int, achievement: Dict, data: str) -> bool:
"""Check if player meets achievement requirement"""
req_type = achievement["requirement_type"]
req_data = achievement["requirement_data"]
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
if req_type == "catch_type":
# Count unique species of a specific type caught
required_count, pet_type = req_data.split(":")
cursor = await db.execute("""
SELECT COUNT(DISTINCT ps.id) as count
FROM pets p
JOIN pet_species ps ON p.species_id = ps.id
WHERE p.player_id = ? AND (ps.type1 = ? OR ps.type2 = ?)
""", (player_id, pet_type, pet_type))
result = await cursor.fetchone()
return result["count"] >= int(required_count)
elif req_type == "catch_total":
# Count total pets caught
required_count = int(req_data)
cursor = await db.execute("""
SELECT COUNT(*) as count FROM pets WHERE player_id = ?
""", (player_id,))
result = await cursor.fetchone()
return result["count"] >= required_count
elif req_type == "explore_count":
# This would need exploration tracking - placeholder for now
return False
return False
async def get_player_achievements(self, player_id: int) -> List[Dict]:
"""Get all achievements earned by player"""
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
SELECT a.*, pa.completed_at
FROM achievements a
JOIN player_achievements pa ON a.id = pa.achievement_id
WHERE pa.player_id = ?
ORDER BY pa.completed_at DESC
""", (player_id,))
rows = await cursor.fetchall()
return [dict(row) for row in rows]
async def can_access_location(self, player_id: int, location_id: int) -> bool:
"""Check if player can access a location based on achievements"""
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
# Check if location requires any achievements
cursor = await db.execute("""
SELECT a.* FROM achievements a
WHERE a.unlock_location_id = ?
""", (location_id,))
required_achievements = await cursor.fetchall()
if not required_achievements:
return True # No requirements
# Check if player has ALL required achievements
for achievement in required_achievements:
cursor = await db.execute("""
SELECT 1 FROM player_achievements
WHERE player_id = ? AND achievement_id = ?
""", (player_id, achievement["id"]))
if not await cursor.fetchone():
return False # Missing required achievement
return True
async def get_missing_location_requirements(self, player_id: int, location_id: int) -> List[Dict]:
"""Get list of achievements required to access a location that the player doesn't have"""
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
# Get all achievements required for this location
cursor = await db.execute("""
SELECT a.* FROM achievements a
WHERE a.unlock_location_id = ?
""", (location_id,))
required_achievements = await cursor.fetchall()
missing_achievements = []
# Check which ones the player doesn't have
for achievement in required_achievements:
cursor = await db.execute("""
SELECT 1 FROM player_achievements
WHERE player_id = ? AND achievement_id = ?
""", (player_id, achievement["id"]))
if not await cursor.fetchone():
# Player doesn't have this achievement - convert to dict manually
achievement_dict = {
'id': achievement[0],
'name': achievement[1],
'description': achievement[2],
'requirement_type': achievement[3],
'requirement_data': achievement[4],
'unlock_location_id': achievement[5]
}
missing_achievements.append(achievement_dict)
return missing_achievements
async def get_location_weather(self, location_id: int) -> Optional[Dict]:
"""Get current weather for a location"""
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
SELECT * FROM location_weather
WHERE location_id = ? AND active_until > datetime('now')
ORDER BY id DESC LIMIT 1
""", (location_id,))
row = await cursor.fetchone()
return dict(row) if row else None
async def activate_pet(self, player_id: int, pet_identifier: str) -> Dict:
"""Activate a pet by name or species name. Returns result dict."""
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
# Find pet by nickname or species name
cursor = await db.execute("""
SELECT p.*, ps.name as species_name
FROM pets p
JOIN pet_species ps ON p.species_id = ps.id
WHERE p.player_id = ? AND p.is_active = FALSE
AND (p.nickname = ? OR ps.name = ?)
LIMIT 1
""", (player_id, pet_identifier, pet_identifier))
pet = await cursor.fetchone()
if not pet:
return {"success": False, "error": f"No inactive pet found named '{pet_identifier}'"}
# Activate the pet
await db.execute("UPDATE pets SET is_active = TRUE WHERE id = ?", (pet["id"],))
await db.commit()
return {"success": True, "pet": dict(pet)}
async def deactivate_pet(self, player_id: int, pet_identifier: str) -> Dict:
"""Deactivate a pet by name or species name. Returns result dict."""
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
# Find pet by nickname or species name
cursor = await db.execute("""
SELECT p.*, ps.name as species_name
FROM pets p
JOIN pet_species ps ON p.species_id = ps.id
WHERE p.player_id = ? AND p.is_active = TRUE
AND (p.nickname = ? OR ps.name = ?)
LIMIT 1
""", (player_id, pet_identifier, pet_identifier))
pet = await cursor.fetchone()
if not pet:
return {"success": False, "error": f"No active pet found named '{pet_identifier}'"}
# Check if this is the only active pet
cursor = await db.execute("SELECT COUNT(*) as count FROM pets WHERE player_id = ? AND is_active = TRUE", (player_id,))
active_count = await cursor.fetchone()
if active_count["count"] <= 1:
return {"success": False, "error": "You must have at least one active pet!"}
# Deactivate the pet
await db.execute("UPDATE pets SET is_active = FALSE WHERE id = ?", (pet["id"],))
await db.commit()
return {"success": True, "pet": dict(pet)}
async def swap_pets(self, player_id: int, pet1_identifier: str, pet2_identifier: str) -> Dict:
"""Swap the active status of two pets. Returns result dict."""
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
# Find both pets
cursor = await db.execute("""
SELECT p.*, ps.name as species_name
FROM pets p
JOIN pet_species ps ON p.species_id = ps.id
WHERE p.player_id = ?
AND (p.nickname = ? OR ps.name = ?)
LIMIT 1
""", (player_id, pet1_identifier, pet1_identifier))
pet1 = await cursor.fetchone()
cursor = await db.execute("""
SELECT p.*, ps.name as species_name
FROM pets p
JOIN pet_species ps ON p.species_id = ps.id
WHERE p.player_id = ?
AND (p.nickname = ? OR ps.name = ?)
LIMIT 1
""", (player_id, pet2_identifier, pet2_identifier))
pet2 = await cursor.fetchone()
if not pet1:
return {"success": False, "error": f"Pet '{pet1_identifier}' not found"}
if not pet2:
return {"success": False, "error": f"Pet '{pet2_identifier}' not found"}
if pet1["id"] == pet2["id"]:
return {"success": False, "error": "Cannot swap a pet with itself"}
# Swap their active status
await db.execute("UPDATE pets SET is_active = ? WHERE id = ?", (not pet1["is_active"], pet1["id"]))
await db.execute("UPDATE pets SET is_active = ? WHERE id = ?", (not pet2["is_active"], pet2["id"]))
await db.commit()
return {
"success": True,
"pet1": dict(pet1),
"pet2": dict(pet2),
"pet1_now": "active" if not pet1["is_active"] else "storage",
"pet2_now": "active" if not pet2["is_active"] else "storage"
}