Implement comprehensive team management and fix critical bugs
Team Management Features: - Added 6 new IRC commands: \!teamlist, \!activeteam, \!teamname, \!teamswap, \!heal, \!verifyteamswap - \!teamlist shows teams with pet names in format "Team name - pet1 - pet2 - pet3" - \!teamname redirects to web interface for secure PIN-based renaming - \!teamswap enables team switching with PIN verification via IRC - \!activeteam displays current team with health status indicators - \!heal command with 1-hour cooldown for pet health restoration Critical Bug Fixes: - Fixed \!teamlist SQL binding error - handled new team data format correctly - Fixed \!wild command duplicates - now shows unique species types only - Removed all debug print statements and implemented proper logging - Fixed data format inconsistencies in team management system Production Improvements: - Added logging infrastructure to BaseModule and core components - Converted 45+ print statements to professional logging calls - Database query optimization with DISTINCT for spawn deduplication - Enhanced error handling and user feedback messages Cross-platform Integration: - Seamless sync between IRC commands and web interface - PIN authentication leverages existing secure infrastructure - Team operations maintain consistency across all interfaces 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e920503dbd
commit
e17705dc63
10 changed files with 2012 additions and 384 deletions
|
|
@ -2,6 +2,7 @@
|
|||
"""Base module class for PetBot command modules"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
class BaseModule(ABC):
|
||||
|
|
@ -11,6 +12,7 @@ class BaseModule(ABC):
|
|||
self.bot = bot
|
||||
self.database = database
|
||||
self.game_engine = game_engine
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
@staticmethod
|
||||
def normalize_input(user_input):
|
||||
|
|
|
|||
|
|
@ -134,7 +134,6 @@ class BattleSystem(BaseModule):
|
|||
gym_battle = await self.database.get_active_gym_battle(player["id"])
|
||||
|
||||
if gym_battle:
|
||||
print(f"DEBUG: Gym battle completion - player: {player['id']}, result: {result.get('winner')}")
|
||||
await self.handle_gym_battle_completion(channel, nickname, player, result, gym_battle)
|
||||
else:
|
||||
# Regular wild battle
|
||||
|
|
@ -312,9 +311,9 @@ class BattleSystem(BaseModule):
|
|||
|
||||
except Exception as e:
|
||||
self.send_message(channel, f"❌ {nickname}: Gym battle error occurred - please !forfeit and try again")
|
||||
print(f"Gym battle completion error: {e}")
|
||||
self.logger.error(f"Gym battle completion error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self.logger.error(traceback.format_exc())
|
||||
|
||||
async def award_battle_experience(self, channel, nickname, player, defeated_pet, battle_type="wild"):
|
||||
"""Award experience to active pets for battle victory"""
|
||||
|
|
|
|||
|
|
@ -106,7 +106,6 @@ class Exploration(BaseModule):
|
|||
|
||||
# CRITICAL FIX: Check and award any outstanding achievements before checking travel requirements
|
||||
# This ensures players get credit for achievements they've earned but haven't been awarded yet
|
||||
print(f"🔄 Checking all achievements for {nickname} before travel...")
|
||||
|
||||
# Check ALL possible achievements comprehensively
|
||||
all_new_achievements = await self.game_engine.check_all_achievements(player["id"])
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class NPCEventsModule(BaseModule):
|
|||
self.events_manager = NPCEventsManager(database)
|
||||
|
||||
def get_commands(self):
|
||||
return ['events', 'event', 'help', 'contribute', 'eventhelp']
|
||||
return ['events', 'event', 'contribute', 'eventhelp']
|
||||
|
||||
async def handle_command(self, command, channel, nickname, args):
|
||||
"""Handle NPC events commands"""
|
||||
|
|
@ -27,8 +27,6 @@ class NPCEventsModule(BaseModule):
|
|||
await self.cmd_events(channel, nickname)
|
||||
elif command == 'event':
|
||||
await self.cmd_event(channel, nickname, args)
|
||||
elif command == 'help' and len(args) > 0 and args[0].lower() == 'events':
|
||||
await self.cmd_event_help(channel, nickname)
|
||||
elif command == 'contribute':
|
||||
await self.cmd_contribute(channel, nickname, args)
|
||||
elif command == 'eventhelp':
|
||||
|
|
@ -74,7 +72,7 @@ class NPCEventsModule(BaseModule):
|
|||
self.send_message(channel, message)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in cmd_events: {e}")
|
||||
self.logger.error(f"Error in cmd_events: {e}")
|
||||
self.send_message(channel, f"❌ Error fetching events: {str(e)}")
|
||||
|
||||
async def cmd_event(self, channel, nickname, args):
|
||||
|
|
@ -132,7 +130,7 @@ class NPCEventsModule(BaseModule):
|
|||
except ValueError:
|
||||
self.send_message(channel, "❌ Invalid event ID. Please use a number.")
|
||||
except Exception as e:
|
||||
print(f"Error in cmd_event: {e}")
|
||||
self.logger.error(f"Error in cmd_event: {e}")
|
||||
self.send_message(channel, f"❌ Error fetching event details: {str(e)}")
|
||||
|
||||
async def cmd_contribute(self, channel, nickname, args):
|
||||
|
|
@ -200,7 +198,7 @@ class NPCEventsModule(BaseModule):
|
|||
except ValueError:
|
||||
self.send_message(channel, "❌ Invalid event ID. Please use a number.")
|
||||
except Exception as e:
|
||||
print(f"Error in cmd_contribute: {e}")
|
||||
self.logger.error(f"Error in cmd_contribute: {e}")
|
||||
self.send_message(channel, f"❌ Error contributing to event: {str(e)}")
|
||||
|
||||
async def cmd_event_help(self, channel, nickname):
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ class PetManagement(BaseModule):
|
|||
"""Handles team, pets, and future pet management commands"""
|
||||
|
||||
def get_commands(self):
|
||||
return ["team", "pets", "activate", "deactivate", "nickname"]
|
||||
return ["team", "pets", "activate", "deactivate", "nickname", "heal", "teamname", "teamswap", "teamlist", "activeteam", "verifyteamswap"]
|
||||
|
||||
async def handle_command(self, channel, nickname, command, args):
|
||||
if command == "team":
|
||||
|
|
@ -20,6 +20,18 @@ class PetManagement(BaseModule):
|
|||
await self.cmd_deactivate(channel, nickname, args)
|
||||
elif command == "nickname":
|
||||
await self.cmd_nickname(channel, nickname, args)
|
||||
elif command == "heal":
|
||||
await self.cmd_heal(channel, nickname)
|
||||
elif command == "teamname":
|
||||
await self.cmd_teamname(channel, nickname, args)
|
||||
elif command == "teamswap":
|
||||
await self.cmd_teamswap(channel, nickname, args)
|
||||
elif command == "teamlist":
|
||||
await self.cmd_teamlist(channel, nickname)
|
||||
elif command == "activeteam":
|
||||
await self.cmd_activeteam(channel, nickname)
|
||||
elif command == "verifyteamswap":
|
||||
await self.cmd_verifyteamswap(channel, nickname, args)
|
||||
|
||||
async def cmd_team(self, channel, nickname):
|
||||
"""Redirect player to their team builder page"""
|
||||
|
|
@ -110,4 +122,273 @@ class PetManagement(BaseModule):
|
|||
new_name = result["new_nickname"]
|
||||
self.send_message(channel, f"✨ {nickname}: {old_name} is now nicknamed '{new_name}'!")
|
||||
else:
|
||||
self.send_message(channel, f"❌ {nickname}: {result['error']}")
|
||||
self.send_message(channel, f"❌ {nickname}: {result['error']}")
|
||||
|
||||
async def cmd_heal(self, channel, nickname):
|
||||
"""Heal active pets (available to all users with 1-hour cooldown)"""
|
||||
try:
|
||||
player = await self.require_player(channel, nickname)
|
||||
if not player:
|
||||
return
|
||||
|
||||
# Check cooldown
|
||||
from datetime import datetime, timedelta
|
||||
last_heal = await self.database.get_last_heal_time(player["id"])
|
||||
if last_heal:
|
||||
time_since_heal = datetime.now() - last_heal
|
||||
if time_since_heal < timedelta(hours=1):
|
||||
remaining = timedelta(hours=1) - time_since_heal
|
||||
minutes_remaining = int(remaining.total_seconds() / 60)
|
||||
self.send_message(channel, f"⏰ {nickname}: Heal command is on cooldown! {minutes_remaining} minutes remaining.")
|
||||
return
|
||||
|
||||
# Get active pets
|
||||
active_pets = await self.database.get_active_pets(player["id"])
|
||||
if not active_pets:
|
||||
self.send_message(channel, f"❌ {nickname}: You don't have any active pets to heal!")
|
||||
return
|
||||
|
||||
# Count how many pets need healing
|
||||
pets_healed = 0
|
||||
for pet in active_pets:
|
||||
if pet["hp"] < pet["max_hp"]:
|
||||
# Heal pet to full HP
|
||||
await self.database.update_pet_hp(pet["id"], pet["max_hp"])
|
||||
pets_healed += 1
|
||||
|
||||
if pets_healed == 0:
|
||||
self.send_message(channel, f"✅ {nickname}: All your active pets are already at full health!")
|
||||
return
|
||||
|
||||
# Update cooldown
|
||||
await self.database.update_last_heal_time(player["id"])
|
||||
|
||||
self.send_message(channel, f"💊 {nickname}: Healed {pets_healed} pet{'s' if pets_healed != 1 else ''} to full health! Next heal available in 1 hour.")
|
||||
|
||||
except Exception as e:
|
||||
self.send_message(channel, f"{nickname}: ❌ Error with heal command: {str(e)}")
|
||||
|
||||
async def cmd_teamname(self, channel, nickname, args):
|
||||
"""Redirect player to team builder for team naming"""
|
||||
player = await self.require_player(channel, nickname)
|
||||
if not player:
|
||||
return
|
||||
|
||||
# Redirect to web interface for team management
|
||||
self.send_message(channel, f"🏷️ {nickname}: Rename your teams at: http://petz.rdx4.com/teambuilder/{nickname}")
|
||||
self.send_message(channel, f"💡 Click on team names to rename them with secure PIN verification!")
|
||||
|
||||
async def cmd_teamswap(self, channel, nickname, args):
|
||||
"""Switch active team to specified slot with PIN verification"""
|
||||
if not args:
|
||||
self.send_message(channel, f"{nickname}: Usage: !teamswap <slot>")
|
||||
self.send_message(channel, f"Example: !teamswap 2")
|
||||
self.send_message(channel, f"Slots: 1-3")
|
||||
return
|
||||
|
||||
player = await self.require_player(channel, nickname)
|
||||
if not player:
|
||||
return
|
||||
|
||||
try:
|
||||
slot = int(args[0])
|
||||
if slot < 1 or slot > 3:
|
||||
self.send_message(channel, f"❌ {nickname}: Invalid slot! Must be 1, 2, or 3")
|
||||
return
|
||||
except ValueError:
|
||||
self.send_message(channel, f"❌ {nickname}: Slot must be a number (1-3)")
|
||||
return
|
||||
|
||||
try:
|
||||
# Check if team slot exists and has pets
|
||||
config = await self.database.load_team_configuration(player["id"], slot)
|
||||
if not config:
|
||||
self.send_message(channel, f"❌ {nickname}: Team slot {slot} is empty! Create a team first via the web interface")
|
||||
return
|
||||
|
||||
# Parse team data to check if it has pets
|
||||
import json
|
||||
team_data = json.loads(config["team_data"]) if config["team_data"] else []
|
||||
if not team_data:
|
||||
self.send_message(channel, f"❌ {nickname}: Team slot {slot} '{config['config_name']}' has no pets!")
|
||||
return
|
||||
|
||||
# Check if this team is already active
|
||||
current_active_slot = await self.database.get_active_team_slot(player["id"])
|
||||
if current_active_slot == slot:
|
||||
self.send_message(channel, f"✅ {nickname}: Team slot {slot} '{config['config_name']}' is already active!")
|
||||
return
|
||||
|
||||
# Get team management service for PIN-based swap
|
||||
from src.team_management import TeamManagementService
|
||||
from src.pin_authentication import PinAuthenticationService
|
||||
|
||||
pin_service = PinAuthenticationService(self.database, self.bot)
|
||||
team_service = TeamManagementService(self.database, pin_service)
|
||||
|
||||
# Request team swap with PIN verification
|
||||
swap_result = await team_service.request_team_swap(player["id"], nickname, slot)
|
||||
|
||||
if swap_result["success"]:
|
||||
pet_count = len(team_data)
|
||||
self.send_message(channel, f"🔐 {nickname}: PIN sent for swapping to '{config['config_name']}' ({pet_count} pets). Check your PM!")
|
||||
else:
|
||||
self.send_message(channel, f"❌ {nickname}: {swap_result.get('error', 'Failed to request team swap')}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in teamswap command: {e}")
|
||||
self.send_message(channel, f"❌ {nickname}: Error processing team swap request")
|
||||
|
||||
async def cmd_teamlist(self, channel, nickname):
|
||||
"""Show all team slots with names and pet names"""
|
||||
player = await self.require_player(channel, nickname)
|
||||
if not player:
|
||||
return
|
||||
|
||||
try:
|
||||
import json
|
||||
|
||||
# Get team configurations directly from database
|
||||
team_configs = await self.database.get_player_team_configurations(player["id"])
|
||||
current_active_slot = await self.database.get_active_team_slot(player["id"])
|
||||
|
||||
# Build team list display
|
||||
team_lines = [f"📋 {nickname}'s Teams:"]
|
||||
|
||||
for slot in range(1, 4):
|
||||
# Find config for this slot
|
||||
config = next((c for c in team_configs if c.get("slot") == slot), None)
|
||||
|
||||
if config and config.get("team_data"):
|
||||
team_name = config["name"]
|
||||
team_data = config["team_data"]
|
||||
|
||||
# Get pet names from team data
|
||||
pet_names = []
|
||||
if isinstance(team_data, list):
|
||||
# New format: list of pet objects (already fetched by get_player_team_configurations)
|
||||
for pet in team_data:
|
||||
if pet and isinstance(pet, dict):
|
||||
display_name = pet.get("nickname") or pet.get("species_name", "Unknown")
|
||||
pet_names.append(display_name)
|
||||
elif isinstance(team_data, dict):
|
||||
# Old format: dict with positions containing pet IDs
|
||||
for pos in sorted(team_data.keys()):
|
||||
pet_id = team_data[pos]
|
||||
if pet_id:
|
||||
pet = await self.database.get_pet_by_id(pet_id)
|
||||
if pet:
|
||||
display_name = pet.get("nickname") or pet.get("species_name", "Unknown")
|
||||
pet_names.append(display_name)
|
||||
|
||||
# Mark active team
|
||||
active_marker = " 🟢" if current_active_slot == slot else ""
|
||||
|
||||
if pet_names:
|
||||
pets_text = " - ".join(pet_names)
|
||||
team_lines.append(f" {slot}. {team_name} - {pets_text}{active_marker}")
|
||||
else:
|
||||
team_lines.append(f" {slot}. {team_name} - empty{active_marker}")
|
||||
else:
|
||||
team_lines.append(f" {slot}. Team {slot} - empty")
|
||||
|
||||
team_lines.append("")
|
||||
team_lines.append("Commands: !teamswap <slot> | Web: http://petz.rdx4.com/teambuilder/" + nickname)
|
||||
|
||||
# Send each line separately to avoid IRC length limits
|
||||
for line in team_lines:
|
||||
self.send_message(channel, line)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in teamlist command: {e}")
|
||||
self.send_message(channel, f"❌ {nickname}: Error loading team list")
|
||||
|
||||
async def cmd_activeteam(self, channel, nickname):
|
||||
"""Show current active team details"""
|
||||
player = await self.require_player(channel, nickname)
|
||||
if not player:
|
||||
return
|
||||
|
||||
try:
|
||||
# Get active team
|
||||
active_pets = await self.database.get_active_team(player["id"])
|
||||
current_slot = await self.database.get_active_team_slot(player["id"])
|
||||
|
||||
if not active_pets:
|
||||
self.send_message(channel, f"❌ {nickname}: You don't have an active team! Use !teamswap or the web interface to set one")
|
||||
return
|
||||
|
||||
# Get team name if it's from a saved configuration
|
||||
team_name = "Active Team"
|
||||
if current_slot:
|
||||
config = await self.database.load_team_configuration(player["id"], current_slot)
|
||||
if config:
|
||||
team_name = config["config_name"]
|
||||
|
||||
# Build active team display
|
||||
team_lines = [f"⚔️ {nickname}'s {team_name}:"]
|
||||
|
||||
for i, pet in enumerate(active_pets, 1):
|
||||
display_name = pet.get("nickname") or pet.get("species_name", "Unknown")
|
||||
level = pet.get("level", 1)
|
||||
hp = pet.get("hp", 0)
|
||||
max_hp = pet.get("max_hp", 100)
|
||||
|
||||
# Health status indicator
|
||||
health_pct = (hp / max_hp * 100) if max_hp > 0 else 0
|
||||
if health_pct >= 75:
|
||||
health_icon = "💚"
|
||||
elif health_pct >= 50:
|
||||
health_icon = "💛"
|
||||
elif health_pct >= 25:
|
||||
health_icon = "🧡"
|
||||
else:
|
||||
health_icon = "❤️"
|
||||
|
||||
team_lines.append(f" {i}. {display_name} (Lv.{level}) {health_icon} {hp}/{max_hp}")
|
||||
|
||||
team_lines.append("")
|
||||
team_lines.append(f"Use !heal to restore health (1hr cooldown)")
|
||||
team_lines.append(f"Manage teams: http://petz.rdx4.com/teambuilder/{nickname}")
|
||||
|
||||
# Send team info
|
||||
for line in team_lines:
|
||||
self.send_message(channel, line)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in activeteam command: {e}")
|
||||
self.send_message(channel, f"❌ {nickname}: Error loading active team")
|
||||
|
||||
async def cmd_verifyteamswap(self, channel, nickname, args):
|
||||
"""Verify PIN and execute team swap"""
|
||||
if not args:
|
||||
self.send_message(channel, f"{nickname}: Usage: !verifyteamswap <pin>")
|
||||
return
|
||||
|
||||
player = await self.require_player(channel, nickname)
|
||||
if not player:
|
||||
return
|
||||
|
||||
pin_code = args[0].strip()
|
||||
|
||||
try:
|
||||
# Get team management service
|
||||
from src.team_management import TeamManagementService
|
||||
from src.pin_authentication import PinAuthenticationService
|
||||
|
||||
pin_service = PinAuthenticationService(self.database, self.bot)
|
||||
team_service = TeamManagementService(self.database, pin_service)
|
||||
|
||||
# Verify PIN and execute team swap
|
||||
result = await team_service.verify_team_swap(player["id"], pin_code)
|
||||
|
||||
if result["success"]:
|
||||
self.send_message(channel, f"✅ {nickname}: {result['message']}")
|
||||
if 'pets_applied' in result:
|
||||
self.send_message(channel, f"🔄 {result['pets_applied']} pets are now active for battle!")
|
||||
else:
|
||||
self.send_message(channel, f"❌ {nickname}: {result.get('error', 'Team swap failed')}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in verifyteamswap command: {e}")
|
||||
self.send_message(channel, f"❌ {nickname}: Error processing team swap verification")
|
||||
|
|
@ -13,9 +13,10 @@ class TeamBuilder(BaseModule):
|
|||
# No direct commands handled by this module
|
||||
pass
|
||||
|
||||
async def send_team_builder_pin(self, nickname, pin_code):
|
||||
async def send_team_builder_pin(self, nickname, pin_code, team_name=None):
|
||||
"""Send PIN to player via private message"""
|
||||
message = f"""🔐 Team Builder Verification PIN: {pin_code}
|
||||
team_text = f" for {team_name}" if team_name else ""
|
||||
message = f"""🔐 Team Builder Verification PIN{team_text}: {pin_code}
|
||||
|
||||
This PIN will expire in 10 minutes.
|
||||
Enter this PIN on the team builder web page to confirm your team changes.
|
||||
|
|
@ -23,13 +24,13 @@ Enter this PIN on the team builder web page to confirm your team changes.
|
|||
⚠️ Keep this PIN private! Do not share it with anyone."""
|
||||
|
||||
self.send_pm(nickname, message)
|
||||
print(f"🔐 Sent team builder PIN to {nickname}: {pin_code}")
|
||||
self.logger.info(f"🔐 Sent team builder PIN to {nickname}: {pin_code}{team_text}")
|
||||
|
||||
async def cleanup_expired_data(self):
|
||||
"""Clean up expired PINs and pending requests"""
|
||||
try:
|
||||
result = await self.database.cleanup_expired_pins()
|
||||
if result["success"] and (result["pins_cleaned"] > 0 or result["changes_cleaned"] > 0):
|
||||
print(f"🧹 Cleaned up {result['pins_cleaned']} expired PINs and {result['changes_cleaned']} pending changes")
|
||||
self.logger.info(f"🧹 Cleaned up {result['pins_cleaned']} expired PINs and {result['changes_cleaned']} pending changes")
|
||||
except Exception as e:
|
||||
print(f"Error during cleanup: {e}")
|
||||
self.logger.error(f"Error during cleanup: {e}")
|
||||
140
src/database.py
140
src/database.py
|
|
@ -1,11 +1,13 @@
|
|||
import aiosqlite
|
||||
import json
|
||||
import logging
|
||||
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
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def init_database(self):
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
|
|
@ -180,7 +182,7 @@ class Database:
|
|||
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")
|
||||
self.logger.info("Added current_location_id column to players table")
|
||||
except:
|
||||
pass # Column already exists
|
||||
|
||||
|
|
@ -188,7 +190,7 @@ class Database:
|
|||
try:
|
||||
await db.execute("ALTER TABLE pets ADD COLUMN team_order INTEGER DEFAULT NULL")
|
||||
await db.commit()
|
||||
print("Added team_order column to pets table")
|
||||
self.logger.info("Added team_order column to pets table")
|
||||
except:
|
||||
pass # Column already exists
|
||||
|
||||
|
|
@ -203,7 +205,7 @@ class Database:
|
|||
pets_to_migrate = await cursor.fetchall()
|
||||
|
||||
if pets_to_migrate:
|
||||
print(f"Migrating {len(pets_to_migrate)} active pets to have team_order values...")
|
||||
self.logger.info(f"Migrating {len(pets_to_migrate)} active pets to have team_order values...")
|
||||
|
||||
# Group pets by player
|
||||
from collections import defaultdict
|
||||
|
|
@ -219,9 +221,9 @@ class Database:
|
|||
""", (i + 1, pet_id))
|
||||
|
||||
await db.commit()
|
||||
print("Migration completed successfully")
|
||||
self.logger.info("Migration completed successfully")
|
||||
except Exception as e:
|
||||
print(f"Migration warning: {e}")
|
||||
self.logger.warning(f"Migration warning: {e}")
|
||||
pass # Don't fail if migration has issues
|
||||
|
||||
# Add fainted_at column for tracking when pets faint
|
||||
|
|
@ -496,6 +498,18 @@ class Database:
|
|||
)
|
||||
""")
|
||||
|
||||
# Create active_teams table for tracking which team configuration is active
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS active_teams (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id INTEGER NOT NULL UNIQUE,
|
||||
active_slot INTEGER NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (player_id) REFERENCES players (id),
|
||||
FOREIGN KEY (active_slot) REFERENCES team_configurations (slot_number)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create species_moves table for move learning system
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS species_moves (
|
||||
|
|
@ -541,6 +555,7 @@ class Database:
|
|||
await db.execute("CREATE INDEX IF NOT EXISTS idx_location_spawns_location ON location_spawns (location_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_species_moves_species ON species_moves (species_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_species_moves_level ON species_moves (learn_level)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_active_teams_player ON active_teams (player_id)")
|
||||
|
||||
# Add team size validation trigger
|
||||
await db.execute("""
|
||||
|
|
@ -594,7 +609,7 @@ class Database:
|
|||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating player admin data: {e}")
|
||||
self.logger.error(f"Error updating player admin data: {e}")
|
||||
return False
|
||||
|
||||
async def create_player(self, nickname: str) -> int:
|
||||
|
|
@ -633,6 +648,19 @@ class Database:
|
|||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
async def get_pet_by_id(self, pet_id: int) -> Optional[Dict]:
|
||||
"""Get a specific pet by ID with full details"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("""
|
||||
SELECT p.*, ps.name as species_name, ps.type1, ps.type2, ps.emoji
|
||||
FROM pets p
|
||||
JOIN pet_species ps ON p.species_id = ps.id
|
||||
WHERE p.id = ?
|
||||
""", (pet_id,))
|
||||
row = await cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
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
|
||||
|
|
@ -1356,7 +1384,7 @@ class Database:
|
|||
except Exception as e:
|
||||
# Rollback on any error
|
||||
await db.execute("ROLLBACK")
|
||||
print(f"Error in award_experience: {e}")
|
||||
self.logger.error(f"Error in award_experience: {e}")
|
||||
return {"success": False, "error": f"Database error: {str(e)}"}
|
||||
|
||||
# NOTE: _handle_level_up function removed - now handled atomically in award_experience()
|
||||
|
|
@ -1768,7 +1796,7 @@ class Database:
|
|||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error recording encounter: {e}")
|
||||
self.logger.error(f"Error recording encounter: {e}")
|
||||
return False
|
||||
|
||||
async def get_player_encounters(self, player_id: int) -> List[Dict]:
|
||||
|
|
@ -2119,7 +2147,7 @@ class Database:
|
|||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
print(f"Database error in apply_team_change: {e}")
|
||||
self.logger.error(f"Database error in apply_team_change: {e}")
|
||||
return {"success": False, "error": f"Database error: {str(e)}"}
|
||||
|
||||
async def apply_individual_team_change(self, player_id: int, pin_code: str) -> Dict:
|
||||
|
|
@ -2196,7 +2224,7 @@ class Database:
|
|||
|
||||
except Exception as e:
|
||||
await db.execute("ROLLBACK")
|
||||
print(f"Database error in apply_individual_team_change: {e}")
|
||||
self.logger.error(f"Database error in apply_individual_team_change: {e}")
|
||||
return {"success": False, "error": f"Database error: {str(e)}"}
|
||||
|
||||
except json.JSONDecodeError:
|
||||
|
|
@ -2323,8 +2351,8 @@ class Database:
|
|||
|
||||
return {"valid": True, "active_count": active_count}
|
||||
|
||||
async def get_active_team(self, player_id: int) -> Dict:
|
||||
"""Get active team pets with their positions using new active_teams table design"""
|
||||
async def get_active_team(self, player_id: int) -> List:
|
||||
"""Get active team pets as a list using new active_teams table design"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
|
||||
|
|
@ -2335,8 +2363,8 @@ class Database:
|
|||
active_row = await cursor.fetchone()
|
||||
|
||||
if not active_row:
|
||||
# No active team set, return empty
|
||||
return {}
|
||||
# No active team set, return empty list
|
||||
return []
|
||||
|
||||
active_slot = active_row['active_slot']
|
||||
|
||||
|
|
@ -2348,37 +2376,40 @@ class Database:
|
|||
config_row = await cursor.fetchone()
|
||||
|
||||
if not config_row:
|
||||
# No configuration for active slot, return empty
|
||||
return {}
|
||||
# No configuration for active slot, return empty list
|
||||
return []
|
||||
|
||||
# Parse the team data and convert to the expected format
|
||||
# Parse the team data and convert to list format
|
||||
import json
|
||||
try:
|
||||
team_pets = json.loads(config_row['team_data'])
|
||||
team_dict = {}
|
||||
|
||||
# Convert to list format expected by webserver and team_management
|
||||
team_list = []
|
||||
for pet in team_pets:
|
||||
team_order = pet.get('team_order')
|
||||
if team_order:
|
||||
team_dict[str(team_order)] = {
|
||||
'id': pet['id'],
|
||||
'name': pet['nickname'] or pet['species_name'],
|
||||
'species_name': pet['species_name'],
|
||||
'level': pet['level'],
|
||||
'hp': pet['hp'],
|
||||
'max_hp': pet['max_hp'],
|
||||
'type_primary': pet['type1'],
|
||||
'type_secondary': pet['type2'],
|
||||
'attack': pet['attack'],
|
||||
'defense': pet['defense'],
|
||||
'speed': pet['speed']
|
||||
}
|
||||
team_list.append({
|
||||
'id': pet['id'],
|
||||
'nickname': pet['nickname'],
|
||||
'species_name': pet['species_name'],
|
||||
'level': pet['level'],
|
||||
'hp': pet['hp'],
|
||||
'max_hp': pet['max_hp'],
|
||||
'type1': pet['type1'],
|
||||
'type2': pet['type2'],
|
||||
'attack': pet['attack'],
|
||||
'defense': pet['defense'],
|
||||
'speed': pet['speed'],
|
||||
'moves': pet.get('moves', []),
|
||||
'team_order': pet.get('team_order')
|
||||
})
|
||||
|
||||
return team_dict
|
||||
# Sort by team_order to maintain consistent positioning
|
||||
team_list.sort(key=lambda x: x.get('team_order', 0))
|
||||
return team_list
|
||||
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
print(f"Error parsing active team data: {e}")
|
||||
return {}
|
||||
return []
|
||||
|
||||
async def set_active_team_slot(self, player_id: int, slot_number: int) -> Dict:
|
||||
"""Set which team slot is currently active"""
|
||||
|
|
@ -2697,18 +2728,33 @@ class Database:
|
|||
WHERE player_id = ?
|
||||
""", (player_id,))
|
||||
|
||||
# Apply the saved team configuration
|
||||
for position, pet_info in team_data.items():
|
||||
if pet_info and 'id' in pet_info:
|
||||
pet_id = pet_info['id']
|
||||
team_order = int(position) # position should be 1-6
|
||||
|
||||
# Activate the pet and set its team position
|
||||
await db.execute("""
|
||||
UPDATE pets
|
||||
SET is_active = TRUE, team_order = ?
|
||||
WHERE id = ? AND player_id = ?
|
||||
""", (team_order, pet_id, player_id))
|
||||
# Apply the saved team configuration - handle both formats
|
||||
if isinstance(team_data, list):
|
||||
# New format: list of pet objects with team_order
|
||||
for pet_info in team_data:
|
||||
if pet_info and 'id' in pet_info and 'team_order' in pet_info:
|
||||
pet_id = pet_info['id']
|
||||
team_order = pet_info['team_order']
|
||||
|
||||
# Activate the pet and set its team position
|
||||
await db.execute("""
|
||||
UPDATE pets
|
||||
SET is_active = TRUE, team_order = ?
|
||||
WHERE id = ? AND player_id = ?
|
||||
""", (team_order, pet_id, player_id))
|
||||
else:
|
||||
# Old format: dict with positions as keys
|
||||
for position, pet_info in team_data.items():
|
||||
if pet_info and 'id' in pet_info:
|
||||
pet_id = pet_info['id']
|
||||
team_order = int(position) # position should be 1-6
|
||||
|
||||
# Activate the pet and set its team position
|
||||
await db.execute("""
|
||||
UPDATE pets
|
||||
SET is_active = TRUE, team_order = ?
|
||||
WHERE id = ? AND player_id = ?
|
||||
""", (team_order, pet_id, player_id))
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import json
|
|||
import random
|
||||
import aiosqlite
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
from .database import Database
|
||||
from .battle_engine import BattleEngine
|
||||
|
|
@ -18,6 +19,7 @@ class GameEngine:
|
|||
self.weather_task = None
|
||||
self.pet_recovery_task = None
|
||||
self.shutdown_event = asyncio.Event()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def load_game_data(self):
|
||||
await self.load_pet_species()
|
||||
|
|
@ -56,9 +58,9 @@ class GameEngine:
|
|||
species.get("rarity", 1), species.get("emoji", "🐾")
|
||||
))
|
||||
await db.commit()
|
||||
print(f"✅ Loaded {len(species_data)} pet species into database")
|
||||
self.logger.info(f"✅ Loaded {len(species_data)} pet species into database")
|
||||
else:
|
||||
print(f"✅ Found {existing_count} existing pet species - skipping reload to preserve IDs")
|
||||
self.logger.info(f"✅ Found {existing_count} existing pet species - skipping reload to preserve IDs")
|
||||
|
||||
except FileNotFoundError:
|
||||
await self.create_default_species()
|
||||
|
|
@ -207,30 +209,44 @@ class GameEngine:
|
|||
|
||||
cursor = await db.execute("""
|
||||
INSERT INTO pets (player_id, species_id, level, experience, hp, max_hp,
|
||||
attack, defense, speed, is_active, team_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
attack, defense, speed, is_active, team_order,
|
||||
iv_hp, iv_attack, iv_defense, iv_speed, original_trainer_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (player_id, species["id"], pet_data["level"], 0,
|
||||
pet_data["hp"], pet_data["hp"], pet_data["attack"],
|
||||
pet_data["defense"], pet_data["speed"], True, 1))
|
||||
pet_data["hp"], pet_data["max_hp"], pet_data["attack"],
|
||||
pet_data["defense"], pet_data["speed"], True, 1,
|
||||
pet_data["iv_hp"], pet_data["iv_attack"], pet_data["iv_defense"],
|
||||
pet_data["iv_speed"], player_id))
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"species_name": chosen_starter, **pet_data}
|
||||
|
||||
def generate_pet_stats(self, species: Dict, level: int = 1) -> Dict:
|
||||
iv_bonus = random.randint(0, 31)
|
||||
# Generate individual IVs for each stat (0-31)
|
||||
iv_hp = random.randint(0, 31)
|
||||
iv_attack = random.randint(0, 31)
|
||||
iv_defense = random.randint(0, 31)
|
||||
iv_speed = random.randint(0, 31)
|
||||
|
||||
hp = int((2 * species["base_hp"] + iv_bonus) * level / 100) + level + 10
|
||||
attack = int((2 * species["base_attack"] + iv_bonus) * level / 100) + 5
|
||||
defense = int((2 * species["base_defense"] + iv_bonus) * level / 100) + 5
|
||||
speed = int((2 * species["base_speed"] + iv_bonus) * level / 100) + 5
|
||||
# Calculate stats using individual IVs (Pokemon-style formula)
|
||||
hp = int((2 * species["base_hp"] + iv_hp) * level / 100) + level + 10
|
||||
attack = int((2 * species["base_attack"] + iv_attack) * level / 100) + 5
|
||||
defense = int((2 * species["base_defense"] + iv_defense) * level / 100) + 5
|
||||
speed = int((2 * species["base_speed"] + iv_speed) * level / 100) + 5
|
||||
|
||||
return {
|
||||
"level": level,
|
||||
"hp": hp,
|
||||
"max_hp": hp, # Initial HP is max HP
|
||||
"attack": attack,
|
||||
"defense": defense,
|
||||
"speed": speed
|
||||
"speed": speed,
|
||||
# Include IVs in the returned data for storage
|
||||
"iv_hp": iv_hp,
|
||||
"iv_attack": iv_attack,
|
||||
"iv_defense": iv_defense,
|
||||
"iv_speed": iv_speed
|
||||
}
|
||||
|
||||
async def attempt_catch(self, player_id: int, location_name: str) -> str:
|
||||
|
|
@ -270,11 +286,14 @@ class GameEngine:
|
|||
if random.random() < catch_rate:
|
||||
cursor = await db.execute("""
|
||||
INSERT INTO pets (player_id, species_id, level, experience, hp, max_hp,
|
||||
attack, defense, speed, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
attack, defense, speed, is_active,
|
||||
iv_hp, iv_attack, iv_defense, iv_speed, original_trainer_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (player_id, chosen_spawn["species_id"], pet_level, 0,
|
||||
pet_stats["hp"], pet_stats["hp"], pet_stats["attack"],
|
||||
pet_stats["defense"], pet_stats["speed"], False))
|
||||
pet_stats["hp"], pet_stats["max_hp"], pet_stats["attack"],
|
||||
pet_stats["defense"], pet_stats["speed"], False,
|
||||
pet_stats["iv_hp"], pet_stats["iv_attack"], pet_stats["iv_defense"],
|
||||
pet_stats["iv_speed"], player_id))
|
||||
|
||||
await db.commit()
|
||||
return f"Caught a level {pet_level} {chosen_spawn['species_name']}!"
|
||||
|
|
@ -293,10 +312,11 @@ class GameEngine:
|
|||
return []
|
||||
|
||||
cursor = await db.execute("""
|
||||
SELECT ps.name, ps.type1, ps.type2, ls.spawn_rate
|
||||
SELECT DISTINCT ps.name, ps.type1, ps.type2, MIN(ls.spawn_rate) as spawn_rate
|
||||
FROM location_spawns ls
|
||||
JOIN pet_species ps ON ls.species_id = ps.id
|
||||
WHERE ls.location_id = ?
|
||||
GROUP BY ps.id, ps.name, ps.type1, ps.type2
|
||||
""", (location["id"],))
|
||||
spawns = await cursor.fetchall()
|
||||
|
||||
|
|
@ -437,12 +457,15 @@ class GameEngine:
|
|||
async with aiosqlite.connect(self.database.db_path) as db:
|
||||
cursor = await db.execute("""
|
||||
INSERT INTO pets (player_id, species_id, level, experience, hp, max_hp,
|
||||
attack, defense, speed, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
attack, defense, speed, is_active,
|
||||
iv_hp, iv_attack, iv_defense, iv_speed, original_trainer_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (player_id, target_pet["species_id"], target_pet["level"], 0,
|
||||
target_pet["stats"]["hp"], target_pet["stats"]["hp"],
|
||||
target_pet["stats"]["hp"], target_pet["stats"]["max_hp"],
|
||||
target_pet["stats"]["attack"], target_pet["stats"]["defense"],
|
||||
target_pet["stats"]["speed"], False))
|
||||
target_pet["stats"]["speed"], False,
|
||||
target_pet["stats"]["iv_hp"], target_pet["stats"]["iv_attack"],
|
||||
target_pet["stats"]["iv_defense"], target_pet["stats"]["iv_speed"], player_id))
|
||||
|
||||
await db.commit()
|
||||
return f"Success! You caught the Level {target_pet['level']} {target_pet['species_name']}!"
|
||||
|
|
@ -482,7 +505,7 @@ class GameEngine:
|
|||
await db.commit()
|
||||
|
||||
except FileNotFoundError:
|
||||
print("No achievements.json found, skipping achievement loading")
|
||||
self.logger.warning("No achievements.json found, skipping achievement loading")
|
||||
|
||||
async def init_weather_system(self):
|
||||
"""Initialize random weather for all locations"""
|
||||
|
|
@ -497,7 +520,7 @@ class GameEngine:
|
|||
await self.start_weather_system()
|
||||
|
||||
except FileNotFoundError:
|
||||
print("No weather_patterns.json found, skipping weather system")
|
||||
self.logger.warning("No weather_patterns.json found, skipping weather system")
|
||||
self.weather_patterns = {"weather_types": {}, "location_weather_chances": {}}
|
||||
|
||||
async def update_all_weather(self):
|
||||
|
|
@ -579,12 +602,12 @@ class GameEngine:
|
|||
async def start_weather_system(self):
|
||||
"""Start the background weather update task"""
|
||||
if self.weather_task is None or self.weather_task.done():
|
||||
print("🌤️ Starting weather update background task...")
|
||||
self.logger.info("🌤️ Starting weather update background task...")
|
||||
self.weather_task = asyncio.create_task(self._weather_update_loop())
|
||||
|
||||
async def stop_weather_system(self):
|
||||
"""Stop the background weather update task"""
|
||||
print("🌤️ Stopping weather update background task...")
|
||||
self.logger.info("🌤️ Stopping weather update background task...")
|
||||
self.shutdown_event.set()
|
||||
if self.weather_task and not self.weather_task.done():
|
||||
self.weather_task.cancel()
|
||||
|
|
@ -610,37 +633,37 @@ class GameEngine:
|
|||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error in weather update loop: {e}")
|
||||
self.logger.error(f"Error in weather update loop: {e}")
|
||||
# Continue the loop even if there's an error
|
||||
await asyncio.sleep(60) # Wait a minute before retrying
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print("Weather update task cancelled")
|
||||
self.logger.info("Weather update task cancelled")
|
||||
|
||||
async def _check_and_update_expired_weather(self):
|
||||
"""Check for expired weather and update it"""
|
||||
"""Check for expired weather and update it with announcements"""
|
||||
try:
|
||||
async with aiosqlite.connect(self.database.db_path) as db:
|
||||
# Find locations with expired weather
|
||||
# Find locations with expired weather and get their current weather
|
||||
cursor = await db.execute("""
|
||||
SELECT l.id, l.name
|
||||
SELECT l.id, l.name, lw.weather_type as current_weather
|
||||
FROM locations l
|
||||
WHERE l.id NOT IN (
|
||||
SELECT location_id FROM location_weather
|
||||
WHERE active_until > datetime('now')
|
||||
)
|
||||
LEFT JOIN location_weather lw ON l.id = lw.location_id
|
||||
AND lw.active_until > datetime('now')
|
||||
WHERE lw.location_id IS NULL OR lw.active_until <= datetime('now')
|
||||
""")
|
||||
expired_locations = await cursor.fetchall()
|
||||
|
||||
if expired_locations:
|
||||
print(f"🌤️ Updating weather for {len(expired_locations)} locations with expired weather")
|
||||
self.logger.info(f"🌤️ Updating weather for {len(expired_locations)} locations with expired weather")
|
||||
|
||||
for location in expired_locations:
|
||||
location_id = location[0]
|
||||
location_name = location[1]
|
||||
previous_weather = location[2] if location[2] else "calm"
|
||||
|
||||
# Get possible weather for this location
|
||||
possible_weather = self.weather_patterns.get("location_weather_chances", {}).get(location_name, ["Calm"])
|
||||
possible_weather = self.weather_patterns.get("location_weather_chances", {}).get(location_name, ["calm"])
|
||||
|
||||
# Choose random weather
|
||||
weather_type = random.choice(possible_weather)
|
||||
|
|
@ -668,12 +691,61 @@ class GameEngine:
|
|||
",".join(weather_config.get("affected_types", []))
|
||||
))
|
||||
|
||||
print(f" 🌤️ {location_name}: New {weather_type} weather for {duration_minutes} minutes")
|
||||
self.logger.info(f" 🌤️ {location_name}: Weather changed from {previous_weather} to {weather_type} for {duration_minutes} minutes")
|
||||
|
||||
# Announce weather change to IRC
|
||||
await self.announce_weather_change(location_name, previous_weather, weather_type, "auto")
|
||||
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking expired weather: {e}")
|
||||
self.logger.error(f"Error checking expired weather: {e}")
|
||||
|
||||
async def announce_weather_change(self, location_name: str, previous_weather: str, new_weather: str, source: str = "auto"):
|
||||
"""Announce weather changes to IRC channel"""
|
||||
try:
|
||||
# Get weather emojis
|
||||
weather_emojis = {
|
||||
"sunny": "☀️",
|
||||
"rainy": "🌧️",
|
||||
"storm": "⛈️",
|
||||
"blizzard": "❄️",
|
||||
"earthquake": "🌍",
|
||||
"calm": "🌤️"
|
||||
}
|
||||
|
||||
prev_emoji = weather_emojis.get(previous_weather, "🌤️")
|
||||
new_emoji = weather_emojis.get(new_weather, "🌤️")
|
||||
|
||||
# Create announcement message
|
||||
if previous_weather == new_weather:
|
||||
return # No change, no announcement
|
||||
|
||||
if source == "admin":
|
||||
message = f"🌤️ Weather Update: {location_name} weather has been changed from {prev_emoji} {previous_weather} to {new_emoji} {new_weather} by admin command!"
|
||||
elif source == "web":
|
||||
message = f"🌤️ Weather Update: {location_name} weather has been changed from {prev_emoji} {previous_weather} to {new_emoji} {new_weather} via web interface!"
|
||||
else:
|
||||
message = f"🌤️ Weather Update: {location_name} weather has naturally changed from {prev_emoji} {previous_weather} to {new_emoji} {new_weather}!"
|
||||
|
||||
# Send to IRC channel via bot instance
|
||||
if hasattr(self, 'bot') and self.bot:
|
||||
from config import IRC_CONFIG
|
||||
channel = IRC_CONFIG.get("channel", "#petz")
|
||||
if hasattr(self.bot, 'send_message_sync'):
|
||||
self.bot.send_message_sync(channel, message)
|
||||
elif hasattr(self.bot, 'send_message'):
|
||||
import asyncio
|
||||
if hasattr(self.bot, 'main_loop') and self.bot.main_loop:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.bot.send_message(channel, message),
|
||||
self.bot.main_loop
|
||||
)
|
||||
else:
|
||||
asyncio.create_task(self.bot.send_message(channel, message))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error announcing weather change: {e}")
|
||||
|
||||
async def get_pet_emoji(self, species_name: str) -> str:
|
||||
"""Get emoji for a pet species"""
|
||||
|
|
@ -710,12 +782,12 @@ class GameEngine:
|
|||
async def start_pet_recovery_system(self):
|
||||
"""Start the background pet recovery task"""
|
||||
if self.pet_recovery_task is None or self.pet_recovery_task.done():
|
||||
print("🏥 Starting pet recovery background task...")
|
||||
self.logger.info("🏥 Starting pet recovery background task...")
|
||||
self.pet_recovery_task = asyncio.create_task(self._pet_recovery_loop())
|
||||
|
||||
async def stop_pet_recovery_system(self):
|
||||
"""Stop the background pet recovery task"""
|
||||
print("🏥 Stopping pet recovery background task...")
|
||||
self.logger.info("🏥 Stopping pet recovery background task...")
|
||||
if self.pet_recovery_task and not self.pet_recovery_task.done():
|
||||
self.pet_recovery_task.cancel()
|
||||
try:
|
||||
|
|
@ -738,27 +810,27 @@ class GameEngine:
|
|||
eligible_pets = await self.database.get_pets_for_auto_recovery()
|
||||
|
||||
if eligible_pets:
|
||||
print(f"🏥 Auto-recovering {len(eligible_pets)} pet(s) after 30 minutes...")
|
||||
self.logger.info(f"🏥 Auto-recovering {len(eligible_pets)} pet(s) after 30 minutes...")
|
||||
|
||||
for pet in eligible_pets:
|
||||
success = await self.database.auto_recover_pet(pet["id"])
|
||||
if success:
|
||||
print(f" 🏥 Auto-recovered {pet['nickname'] or pet['species_name']} (ID: {pet['id']}) to 1 HP")
|
||||
self.logger.info(f" 🏥 Auto-recovered {pet['nickname'] or pet['species_name']} (ID: {pet['id']}) to 1 HP")
|
||||
else:
|
||||
print(f" ❌ Failed to auto-recover pet ID: {pet['id']}")
|
||||
self.logger.error(f" ❌ Failed to auto-recover pet ID: {pet['id']}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error in pet recovery loop: {e}")
|
||||
self.logger.error(f"Error in pet recovery loop: {e}")
|
||||
# Continue the loop even if there's an error
|
||||
await asyncio.sleep(60) # Wait a minute before retrying
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print("Pet recovery task cancelled")
|
||||
self.logger.info("Pet recovery task cancelled")
|
||||
|
||||
async def shutdown(self):
|
||||
"""Gracefully shutdown the game engine"""
|
||||
print("🔄 Shutting down game engine...")
|
||||
self.logger.info("🔄 Shutting down game engine...")
|
||||
await self.stop_weather_system()
|
||||
await self.stop_pet_recovery_system()
|
||||
448
src/pin_authentication.py
Normal file
448
src/pin_authentication.py
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
PIN Authentication Service for PetBot
|
||||
A standalone, reusable module for secure PIN-based verification of sensitive operations.
|
||||
|
||||
Usage:
|
||||
from src.pin_authentication import PinAuthenticationService
|
||||
|
||||
pin_service = PinAuthenticationService(database, irc_bot)
|
||||
|
||||
# Generate and send PIN
|
||||
result = await pin_service.request_verification(
|
||||
player_id=123,
|
||||
nickname="user",
|
||||
action_type="team_change",
|
||||
action_data={"team": "data"},
|
||||
message_template="Custom PIN message for {pin_code}"
|
||||
)
|
||||
|
||||
# Verify PIN and execute action
|
||||
result = await pin_service.verify_and_execute(
|
||||
player_id=123,
|
||||
pin_code="123456",
|
||||
action_type="team_change",
|
||||
action_callback=my_callback_function
|
||||
)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
import string
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional, Callable, Any
|
||||
|
||||
|
||||
class PinAuthenticationService:
|
||||
"""
|
||||
Standalone PIN authentication service that can be used by any component
|
||||
requiring secure verification of user actions.
|
||||
|
||||
Features:
|
||||
- Secure 6-digit PIN generation
|
||||
- Configurable expiration times
|
||||
- IRC delivery integration
|
||||
- Multiple request type support
|
||||
- Automatic cleanup of expired PINs
|
||||
- Callback-based action execution
|
||||
"""
|
||||
|
||||
def __init__(self, database, irc_bot=None):
|
||||
"""
|
||||
Initialize PIN authentication service.
|
||||
|
||||
Args:
|
||||
database: Database instance for PIN storage
|
||||
irc_bot: Optional IRC bot instance for PIN delivery
|
||||
"""
|
||||
self.database = database
|
||||
self.irc_bot = irc_bot
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Default PIN settings
|
||||
self.default_expiration_minutes = 10
|
||||
self.pin_length = 6
|
||||
|
||||
# Default message templates for different action types
|
||||
self.message_templates = {
|
||||
"team_change": """🔐 Team Change Verification PIN: {pin_code}
|
||||
|
||||
This PIN will expire in {expiration_minutes} minutes.
|
||||
Enter this PIN on the team builder web page to confirm your team changes.
|
||||
|
||||
⚠️ Keep this PIN private! Do not share it with anyone.""",
|
||||
|
||||
"pet_rename": """🔐 Pet Rename Verification PIN: {pin_code}
|
||||
|
||||
This PIN will expire in {expiration_minutes} minutes.
|
||||
Enter this PIN to confirm renaming your pet.
|
||||
|
||||
⚠️ Keep this PIN private! Do not share it with anyone.""",
|
||||
|
||||
"team_rename": """🔐 Team Rename Verification PIN: {pin_code}
|
||||
|
||||
This PIN will expire in {expiration_minutes} minutes.
|
||||
Enter this PIN to confirm renaming your team.
|
||||
|
||||
⚠️ Keep this PIN private! Do not share it with anyone.""",
|
||||
|
||||
"default": """🔐 Verification PIN: {pin_code}
|
||||
|
||||
This PIN will expire in {expiration_minutes} minutes.
|
||||
Enter this PIN to confirm your action.
|
||||
|
||||
⚠️ Keep this PIN private! Do not share it with anyone."""
|
||||
}
|
||||
|
||||
async def request_verification(
|
||||
self,
|
||||
player_id: int,
|
||||
nickname: str,
|
||||
action_type: str,
|
||||
action_data: Any = None,
|
||||
expiration_minutes: Optional[int] = None,
|
||||
message_template: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Request PIN verification for a specific action.
|
||||
|
||||
Args:
|
||||
player_id: Player's database ID
|
||||
nickname: Player's nickname for IRC delivery
|
||||
action_type: Type of action (e.g., "team_change", "pet_rename")
|
||||
action_data: Data associated with the action (will be JSON serialized)
|
||||
expiration_minutes: Custom expiration time (default: 10 minutes)
|
||||
message_template: Custom message template (default: uses action_type template)
|
||||
|
||||
Returns:
|
||||
Dict with success status, PIN code, and expiration info
|
||||
"""
|
||||
try:
|
||||
# Use custom expiration or default
|
||||
exp_minutes = expiration_minutes or self.default_expiration_minutes
|
||||
|
||||
# Serialize action data if provided
|
||||
action_data_str = json.dumps(action_data) if action_data is not None else None
|
||||
|
||||
# Generate PIN
|
||||
pin_result = await self.generate_verification_pin(
|
||||
player_id=player_id,
|
||||
request_type=action_type,
|
||||
request_data=action_data_str,
|
||||
expiration_minutes=exp_minutes
|
||||
)
|
||||
|
||||
if not pin_result["success"]:
|
||||
return pin_result
|
||||
|
||||
# Send PIN via IRC if bot is available
|
||||
if self.irc_bot and nickname:
|
||||
await self.send_pin_via_irc(
|
||||
nickname=nickname,
|
||||
pin_code=pin_result["pin_code"],
|
||||
action_type=action_type,
|
||||
expiration_minutes=exp_minutes,
|
||||
message_template=message_template
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pin_code": pin_result["pin_code"],
|
||||
"expires_at": pin_result["expires_at"],
|
||||
"expires_in_minutes": exp_minutes,
|
||||
"action_type": action_type
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Failed to request verification: {str(e)}"}
|
||||
|
||||
async def verify_and_execute(
|
||||
self,
|
||||
player_id: int,
|
||||
pin_code: str,
|
||||
action_type: str,
|
||||
action_callback: Optional[Callable] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Verify PIN and optionally execute the associated action.
|
||||
|
||||
Args:
|
||||
player_id: Player's database ID
|
||||
pin_code: PIN code to verify
|
||||
action_type: Expected action type
|
||||
action_callback: Optional callback function to execute if PIN is valid
|
||||
Callback receives (player_id, action_data) as arguments
|
||||
|
||||
Returns:
|
||||
Dict with verification result and callback execution status
|
||||
"""
|
||||
try:
|
||||
# Verify PIN
|
||||
pin_result = await self.verify_pin(player_id, pin_code, action_type)
|
||||
|
||||
if not pin_result["success"]:
|
||||
return pin_result
|
||||
|
||||
# Parse action data if available
|
||||
action_data = None
|
||||
if pin_result.get("request_data"):
|
||||
try:
|
||||
action_data = json.loads(pin_result["request_data"])
|
||||
except json.JSONDecodeError:
|
||||
action_data = pin_result["request_data"] # Keep as string if not JSON
|
||||
|
||||
# Execute callback if provided
|
||||
callback_result = None
|
||||
if action_callback:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(action_callback):
|
||||
callback_result = await action_callback(player_id, action_data)
|
||||
else:
|
||||
callback_result = action_callback(player_id, action_data)
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Action callback failed: {str(e)}",
|
||||
"pin_verified": True
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pin_verified": True,
|
||||
"action_data": action_data,
|
||||
"callback_result": callback_result,
|
||||
"action_type": action_type
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Verification failed: {str(e)}"}
|
||||
|
||||
async def generate_verification_pin(
|
||||
self,
|
||||
player_id: int,
|
||||
request_type: str,
|
||||
request_data: str = None,
|
||||
expiration_minutes: int = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Generate a secure PIN for verification.
|
||||
|
||||
Args:
|
||||
player_id: Player's database ID
|
||||
request_type: Type of request (e.g., "team_change", "pet_rename")
|
||||
request_data: Optional data associated with the request
|
||||
expiration_minutes: PIN expiration time (default: 10 minutes)
|
||||
|
||||
Returns:
|
||||
Dict with PIN code and expiration information
|
||||
"""
|
||||
try:
|
||||
# Use default expiration if not specified
|
||||
exp_minutes = expiration_minutes or self.default_expiration_minutes
|
||||
|
||||
# Generate cryptographically secure PIN
|
||||
pin_code = ''.join(secrets.choice(string.digits) for _ in range(self.pin_length))
|
||||
|
||||
# Calculate expiration
|
||||
expires_at = datetime.now() + timedelta(minutes=exp_minutes)
|
||||
|
||||
import aiosqlite
|
||||
async with aiosqlite.connect(self.database.db_path) as db:
|
||||
# Clear any existing unused PINs for this player and request type
|
||||
await db.execute("""
|
||||
UPDATE verification_pins
|
||||
SET is_used = TRUE, used_at = CURRENT_TIMESTAMP
|
||||
WHERE player_id = ? AND request_type = ? AND is_used = FALSE
|
||||
""", (player_id, request_type))
|
||||
|
||||
# Insert new PIN
|
||||
cursor = await db.execute("""
|
||||
INSERT INTO verification_pins
|
||||
(player_id, pin_code, request_type, request_data, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (player_id, pin_code, request_type, request_data, expires_at.isoformat()))
|
||||
|
||||
await db.commit()
|
||||
pin_id = cursor.lastrowid
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pin_id": pin_id,
|
||||
"pin_code": pin_code,
|
||||
"expires_at": expires_at,
|
||||
"expires_in_minutes": exp_minutes
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Failed to generate PIN: {str(e)}"}
|
||||
|
||||
async def verify_pin(self, player_id: int, pin_code: str, request_type: str) -> Dict:
|
||||
"""
|
||||
Verify a PIN code and mark it as used.
|
||||
|
||||
Args:
|
||||
player_id: Player's database ID
|
||||
pin_code: PIN code to verify
|
||||
request_type: Expected request type
|
||||
|
||||
Returns:
|
||||
Dict with verification result and request data
|
||||
"""
|
||||
try:
|
||||
import aiosqlite
|
||||
async with aiosqlite.connect(self.database.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
|
||||
# Find valid PIN
|
||||
cursor = await db.execute("""
|
||||
SELECT * FROM verification_pins
|
||||
WHERE player_id = ? AND pin_code = ? AND request_type = ?
|
||||
AND is_used = FALSE AND expires_at > datetime('now')
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""", (player_id, pin_code, request_type))
|
||||
|
||||
pin_record = await cursor.fetchone()
|
||||
|
||||
if not pin_record:
|
||||
return {"success": False, "error": "Invalid or expired PIN"}
|
||||
|
||||
# Mark PIN as used
|
||||
await db.execute("""
|
||||
UPDATE verification_pins
|
||||
SET is_used = TRUE, used_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (pin_record["id"],))
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"request_data": pin_record["request_data"],
|
||||
"request_type": pin_record["request_type"],
|
||||
"pin_id": pin_record["id"]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"PIN verification failed: {str(e)}"}
|
||||
|
||||
async def send_pin_via_irc(
|
||||
self,
|
||||
nickname: str,
|
||||
pin_code: str,
|
||||
action_type: str,
|
||||
expiration_minutes: int,
|
||||
message_template: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Send PIN to player via IRC private message.
|
||||
|
||||
Args:
|
||||
nickname: Player's IRC nickname
|
||||
pin_code: PIN code to send
|
||||
action_type: Type of action for message template selection
|
||||
expiration_minutes: PIN expiration time for message
|
||||
message_template: Custom message template (optional)
|
||||
"""
|
||||
if not self.irc_bot:
|
||||
self.logger.warning(f"No IRC bot available to send PIN to {nickname}")
|
||||
return
|
||||
|
||||
try:
|
||||
# Use custom template or select based on action type
|
||||
if message_template:
|
||||
message = message_template.format(
|
||||
pin_code=pin_code,
|
||||
expiration_minutes=expiration_minutes
|
||||
)
|
||||
else:
|
||||
template = self.message_templates.get(action_type, self.message_templates["default"])
|
||||
message = template.format(
|
||||
pin_code=pin_code,
|
||||
expiration_minutes=expiration_minutes
|
||||
)
|
||||
|
||||
# Send via IRC bot
|
||||
if hasattr(self.irc_bot, 'send_message_sync'):
|
||||
# Use the sync wrapper method available in PetBot
|
||||
self.irc_bot.send_message_sync(nickname, message)
|
||||
elif hasattr(self.irc_bot, 'send_private_message'):
|
||||
self.irc_bot.send_private_message(nickname, message)
|
||||
elif hasattr(self.irc_bot, 'send_pm'):
|
||||
await self.irc_bot.send_pm(nickname, message)
|
||||
else:
|
||||
self.logger.warning(f"IRC bot doesn't have a known method to send private messages")
|
||||
|
||||
self.logger.info(f"🔐 Sent {action_type} PIN to {nickname}: {pin_code}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error sending PIN via IRC to {nickname}: {e}")
|
||||
|
||||
async def cleanup_expired_pins(self) -> Dict:
|
||||
"""
|
||||
Clean up expired and used PINs from the database.
|
||||
|
||||
Returns:
|
||||
Dict with cleanup statistics
|
||||
"""
|
||||
try:
|
||||
import aiosqlite
|
||||
async with aiosqlite.connect(self.database.db_path) as db:
|
||||
# Clean expired verification pins
|
||||
cursor = await db.execute("""
|
||||
DELETE FROM verification_pins
|
||||
WHERE expires_at < datetime('now') OR is_used = TRUE
|
||||
""")
|
||||
pins_cleaned = cursor.rowcount
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pins_cleaned": pins_cleaned
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Cleanup failed: {str(e)}"}
|
||||
|
||||
def add_message_template(self, action_type: str, template: str):
|
||||
"""
|
||||
Add or update a message template for a specific action type.
|
||||
|
||||
Args:
|
||||
action_type: Action type identifier
|
||||
template: Message template with {pin_code} and {expiration_minutes} placeholders
|
||||
"""
|
||||
self.message_templates[action_type] = template
|
||||
|
||||
async def cancel_pending_verification(self, player_id: int, action_type: str) -> Dict:
|
||||
"""
|
||||
Cancel any pending verification requests for a player and action type.
|
||||
|
||||
Args:
|
||||
player_id: Player's database ID
|
||||
action_type: Action type to cancel
|
||||
|
||||
Returns:
|
||||
Dict with cancellation result
|
||||
"""
|
||||
try:
|
||||
import aiosqlite
|
||||
async with aiosqlite.connect(self.database.db_path) as db:
|
||||
cursor = await db.execute("""
|
||||
UPDATE verification_pins
|
||||
SET is_used = TRUE, used_at = CURRENT_TIMESTAMP
|
||||
WHERE player_id = ? AND request_type = ? AND is_used = FALSE
|
||||
""", (player_id, action_type))
|
||||
|
||||
cancelled_count = cursor.rowcount
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"cancelled_count": cancelled_count
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Cancellation failed: {str(e)}"}
|
||||
1330
webserver.py
1330
webserver.py
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue