From 47f160a295d82f93036c02ac25ffb7427daa3ac7 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Sun, 13 Jul 2025 23:57:39 +0100 Subject: [PATCH] Initial commit: Complete PetBot IRC Game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit šŸŽ® 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 --- .claude/settings.local.json | 16 + .gitignore | 78 ++ README.md | 184 +++++ config/achievements.json | 51 ++ config/locations.json | 66 ++ config/moves.json | 56 ++ config/pets.json | 79 ++ config/settings.example.json | 10 + config/types.json | 46 ++ config/weather_patterns.json | 48 ++ help.html | 525 +++++++++++++ modules/__init__.py | 18 + modules/achievements.py | 32 + modules/admin.py | 30 + modules/base_module.py | 43 + modules/battle_system.py | 192 +++++ modules/core_commands.py | 44 ++ modules/exploration.py | 225 ++++++ modules/pet_management.py | 149 ++++ plugins/__init__.py | 0 requirements.txt | 4 + reset_players.py | 85 ++ run_bot.py | 254 ++++++ run_bot_debug.py | 283 +++++++ run_bot_original.py | 633 +++++++++++++++ src/__init__.py | 0 src/battle_engine.py | 350 +++++++++ src/bot.py | 191 +++++ src/database.py | 522 +++++++++++++ src/game_engine.py | 596 ++++++++++++++ webserver.py | 1425 ++++++++++++++++++++++++++++++++++ 31 files changed, 6235 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/achievements.json create mode 100644 config/locations.json create mode 100644 config/moves.json create mode 100644 config/pets.json create mode 100644 config/settings.example.json create mode 100644 config/types.json create mode 100644 config/weather_patterns.json create mode 100644 help.html create mode 100644 modules/__init__.py create mode 100644 modules/achievements.py create mode 100644 modules/admin.py create mode 100644 modules/base_module.py create mode 100644 modules/battle_system.py create mode 100644 modules/core_commands.py create mode 100644 modules/exploration.py create mode 100644 modules/pet_management.py create mode 100644 plugins/__init__.py create mode 100644 requirements.txt create mode 100755 reset_players.py create mode 100644 run_bot.py create mode 100644 run_bot_debug.py create mode 100644 run_bot_original.py create mode 100644 src/__init__.py create mode 100644 src/battle_engine.py create mode 100644 src/bot.py create mode 100644 src/database.py create mode 100644 src/game_engine.py create mode 100644 webserver.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..80804dc --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir:*)", + "Bash(python3:*)", + "Bash(timeout:*)", + "Bash(ls:*)", + "Bash(kill:*)", + "Bash(cat:*)", + "Bash(pip3 install:*)", + "Bash(apt list:*)", + "Bash(curl:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81d0570 --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Database files +*.db +*.sqlite +*.sqlite3 +data/petbot.db + +# Logs +*.log +logs/ +webserver.log + +# Configuration (might contain sensitive data) +config/secrets.json +config/private.json +config/settings.json + +# Test files +test_*.py +*_test.py +test_output/ + +# Temporary files +tmp/ +temp/ +*.tmp +*.bak +*.backup + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IRC bot specific +*.pid +*.lock \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac45f17 --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# PetBot - IRC Pokemon-Style Pet Game Bot + +A feature-rich IRC bot that brings Pokemon-style pet collecting and battling to your IRC channel! Players can catch pets, explore locations, battle wild creatures, earn achievements, and more. + +## šŸŽ® Features + +### Core Gameplay +- **Pet Collection**: Catch and collect different species of pets +- **Exploration**: Travel between various themed locations +- **Battle System**: Engage in turn-based battles with wild pets +- **Team Management**: Activate/deactivate pets, swap team members +- **Achievement System**: Unlock new areas by completing challenges + +### Advanced Systems +- **Dynamic Weather**: Real-time weather system affecting spawn rates +- **Web Interface**: Modern web dashboard for player stats and pet collections +- **Location-Based Spawns**: Different pets spawn in different locations +- **Level Progression**: Pets gain experience and level up +- **Type Effectiveness**: Strategic battle system with type advantages + +### Technical Features +- **Modular Architecture**: Clean, extensible codebase +- **Async Database**: SQLite with async operations +- **Background Tasks**: Automated weather updates +- **PM Flood Prevention**: Web-based responses for large data sets +- **Persistent Data**: Player progress survives bot restarts + +## šŸš€ Quick Start + +### Prerequisites +- Python 3.8+ +- SQLite3 +- Network access for IRC and web server + +### Installation +1. Clone the repository: + ```bash + git clone https://github.com/yourusername/petbot.git + cd petbot + ``` + +2. Install dependencies: + ```bash + pip install aiosqlite + ``` + +3. Configure the bot: + ```bash + cp config/settings.example.json config/settings.json + # Edit config/settings.json with your IRC details + ``` + +4. Run the bot: + ```bash + python3 run_bot_debug.py + ``` + +## šŸ“‹ Commands + +### Player Commands +- `!start` - Begin your pet collecting journey +- `!explore` - Search for wild pets in your current location +- `!catch ` - Attempt to catch a wild pet +- `!team` - View your active team +- `!pets` - View your complete collection (web link) +- `!travel ` - Move to a different location +- `!weather` - Check current weather effects +- `!achievements` - View your progress and unlocked achievements + +### Battle Commands +- `!battle` - Start a battle with a wild pet +- `!attack ` - Use a move in battle +- `!flee` - Attempt to escape from battle +- `!moves` - View your active pet's moves + +### Pet Management +- `!activate ` - Activate a pet for battle +- `!deactivate ` - Move a pet to storage +- `!swap ` - Swap two pets' active status + +## šŸŒ Locations + +### Available Areas +- **Starter Town**: Peaceful starting area (Fire/Water/Grass pets) +- **Whispering Woods**: Ancient forest (Grass/Nature pets) +- **Electric Canyon**: Charged valley (Electric/Rock pets) +- **Crystal Caves**: Underground caverns (Rock/Crystal pets) +- **Frozen Tundra**: Icy wasteland (Ice/Water pets) +- **Dragon's Peak**: Ultimate challenge (Fire/Rock/Ice pets) + +### Unlocking Locations +Locations are unlocked by completing achievements: +- **Nature Explorer**: Catch 3 different Grass-type pets → Whispering Woods +- **Spark Collector**: Catch 2 different Electric-type pets → Electric Canyon +- **Rock Hound**: Catch 3 different Rock-type pets → Crystal Caves +- **Ice Breaker**: Catch 5 different Water/Ice-type pets → Frozen Tundra +- **Dragon Tamer**: Catch 15 pets total + 3 Fire-types → Dragon's Peak + +## šŸŒ¤ļø Weather System + +### Weather Types & Effects +- **Sunny**: 1.5x Fire/Grass spawns (1-2 hours) +- **Rainy**: 2.0x Water spawns (45-90 minutes) +- **Thunderstorm**: 2.0x Electric spawns (30-60 minutes) +- **Blizzard**: 1.7x Ice/Water spawns (1-2 hours) +- **Earthquake**: 1.8x Rock spawns (30-90 minutes) +- **Calm**: Normal spawns (1.5-3 hours) + +### Background System +- Weather updates automatically every 5 minutes +- Dynamic durations from 30 minutes to 3 hours +- Location-specific weather patterns +- Real-time spawn rate modifications + +## 🌐 Web Interface + +Access the web dashboard at `http://localhost:8080/`: +- **Player Profiles**: Complete stats and pet collections +- **Leaderboard**: Top players by level and achievements +- **Locations Guide**: All areas with spawn information +- **Help System**: Complete command reference + +## šŸ”§ Architecture + +### Core Components +- **Database Layer**: Async SQLite with comprehensive schema +- **Game Engine**: Core logic for pets, battles, weather +- **Module System**: Pluggable command handlers +- **Web Server**: Built-in HTTP server for dashboard +- **Battle Engine**: Turn-based combat system + +### Key Files +- `src/database.py` - Database operations and schema +- `src/game_engine.py` - Core game logic and weather system +- `src/battle_engine.py` - Combat mechanics +- `modules/` - Command handler modules +- `webserver.py` - Web interface server +- `config/` - Configuration files + +## šŸ› Recent Updates + +### Weather System Enhancement +- āœ… Added background task for automatic weather updates +- āœ… Changed weather durations from 2-6 hours to 30min-3hours +- āœ… Implemented continuous weather coverage +- āœ… Added graceful shutdown handling + +### Bug Fixes +- āœ… Fixed database persistence on bot restart +- āœ… Resolved individual player pages showing 'not found' +- āœ… Corrected achievement count displays +- āœ… Added specific travel requirement messages +- āœ… Fixed locations page display +- āœ… Implemented colored battle moves by type + +## šŸ¤ Contributing + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature-name` +3. Make your changes +4. Add tests if applicable +5. Commit with descriptive messages +6. Push and create a pull request + +## šŸ“ License + +This project is open source. Feel free to use, modify, and distribute. + +## šŸŽÆ Roadmap + +- [ ] PvP battles between players +- [ ] Pet evolution system +- [ ] Trading between players +- [ ] Seasonal events +- [ ] More pet types and locations +- [ ] Mobile-responsive web interface + +## 🐾 Support + +For questions, bug reports, or feature requests, please open an issue on GitHub. + +--- + +*Built with ā¤ļø for IRC communities who love Pokemon-style games!* \ No newline at end of file diff --git a/config/achievements.json b/config/achievements.json new file mode 100644 index 0000000..301de16 --- /dev/null +++ b/config/achievements.json @@ -0,0 +1,51 @@ +[ + { + "name": "Nature Explorer", + "description": "Catch 3 different Grass-type pets", + "requirement_type": "catch_type", + "requirement_data": "3:Grass", + "unlock_location": "Whispering Woods" + }, + { + "name": "Spark Collector", + "description": "Catch 2 different Electric-type pets", + "requirement_type": "catch_type", + "requirement_data": "2:Electric", + "unlock_location": "Electric Canyon" + }, + { + "name": "Rock Hound", + "description": "Catch 3 different Rock-type pets", + "requirement_type": "catch_type", + "requirement_data": "3:Rock", + "unlock_location": "Crystal Caves" + }, + { + "name": "Ice Breaker", + "description": "Catch 5 different Water or Ice-type pets", + "requirement_type": "catch_type", + "requirement_data": "5:Water", + "unlock_location": "Frozen Tundra" + }, + { + "name": "Dragon Tamer", + "description": "Catch 15 pets total and have 3 Fire-type pets", + "requirement_type": "catch_total", + "requirement_data": "15", + "unlock_location": "Dragon's Peak" + }, + { + "name": "Pet Collector", + "description": "Catch your first 5 pets", + "requirement_type": "catch_total", + "requirement_data": "5", + "unlock_location": null + }, + { + "name": "Advanced Trainer", + "description": "Catch 10 pets total", + "requirement_type": "catch_total", + "requirement_data": "10", + "unlock_location": null + } +] \ No newline at end of file diff --git a/config/locations.json b/config/locations.json new file mode 100644 index 0000000..168904a --- /dev/null +++ b/config/locations.json @@ -0,0 +1,66 @@ +[ + { + "name": "Starter Town", + "description": "A peaceful town where all trainers begin their journey", + "level_min": 1, + "level_max": 3, + "spawns": [ + {"species": "Leafy", "spawn_rate": 0.35, "min_level": 1, "max_level": 2}, + {"species": "Flamey", "spawn_rate": 0.35, "min_level": 1, "max_level": 2}, + {"species": "Aqua", "spawn_rate": 0.3, "min_level": 1, "max_level": 2} + ] + }, + { + "name": "Whispering Woods", + "description": "Ancient woods filled with grass and nature pets", + "level_min": 2, + "level_max": 6, + "spawns": [ + {"species": "Leafy", "spawn_rate": 0.5, "min_level": 2, "max_level": 5}, + {"species": "Flamey", "spawn_rate": 0.2, "min_level": 3, "max_level": 4}, + {"species": "Aqua", "spawn_rate": 0.3, "min_level": 2, "max_level": 4} + ] + }, + { + "name": "Electric Canyon", + "description": "A charged valley crackling with energy", + "level_min": 4, + "level_max": 9, + "spawns": [ + {"species": "Sparky", "spawn_rate": 0.6, "min_level": 4, "max_level": 7}, + {"species": "Rocky", "spawn_rate": 0.4, "min_level": 5, "max_level": 8} + ] + }, + { + "name": "Crystal Caves", + "description": "Deep underground caverns with rock and crystal pets", + "level_min": 6, + "level_max": 12, + "spawns": [ + {"species": "Rocky", "spawn_rate": 0.7, "min_level": 6, "max_level": 10}, + {"species": "Sparky", "spawn_rate": 0.3, "min_level": 7, "max_level": 9} + ] + }, + { + "name": "Frozen Tundra", + "description": "An icy wasteland where only the strongest survive", + "level_min": 10, + "level_max": 16, + "spawns": [ + {"species": "Hydrox", "spawn_rate": 0.4, "min_level": 10, "max_level": 14}, + {"species": "Rocky", "spawn_rate": 0.3, "min_level": 11, "max_level": 15}, + {"species": "Sparky", "spawn_rate": 0.3, "min_level": 12, "max_level": 14} + ] + }, + { + "name": "Dragon's Peak", + "description": "The ultimate challenge - legendary pets roam these heights", + "level_min": 15, + "level_max": 25, + "spawns": [ + {"species": "Blazeon", "spawn_rate": 0.5, "min_level": 15, "max_level": 20}, + {"species": "Hydrox", "spawn_rate": 0.3, "min_level": 16, "max_level": 22}, + {"species": "Rocky", "spawn_rate": 0.2, "min_level": 18, "max_level": 25} + ] + } +] \ No newline at end of file diff --git a/config/moves.json b/config/moves.json new file mode 100644 index 0000000..8051232 --- /dev/null +++ b/config/moves.json @@ -0,0 +1,56 @@ +[ + { + "name": "Tackle", + "type": "Normal", + "category": "Physical", + "power": 40, + "accuracy": 100, + "pp": 35, + "description": "A physical attack in which the user charges and slams into the target." + }, + { + "name": "Ember", + "type": "Fire", + "category": "Special", + "power": 40, + "accuracy": 100, + "pp": 25, + "description": "The target is attacked with small flames." + }, + { + "name": "Water Gun", + "type": "Water", + "category": "Special", + "power": 40, + "accuracy": 100, + "pp": 25, + "description": "The target is blasted with a forceful shot of water." + }, + { + "name": "Vine Whip", + "type": "Grass", + "category": "Physical", + "power": 45, + "accuracy": 100, + "pp": 25, + "description": "The target is struck with slender, whiplike vines." + }, + { + "name": "Thunder Shock", + "type": "Electric", + "category": "Special", + "power": 40, + "accuracy": 100, + "pp": 30, + "description": "A jolt of electricity crashes down on the target." + }, + { + "name": "Rock Throw", + "type": "Rock", + "category": "Physical", + "power": 50, + "accuracy": 90, + "pp": 15, + "description": "The user picks up and hurls a rock at the target." + } +] \ No newline at end of file diff --git a/config/pets.json b/config/pets.json new file mode 100644 index 0000000..adebb27 --- /dev/null +++ b/config/pets.json @@ -0,0 +1,79 @@ +[ + { + "name": "Flamey", + "type1": "Fire", + "type2": null, + "base_hp": 45, + "base_attack": 52, + "base_defense": 43, + "base_speed": 65, + "evolution_level": null, + "rarity": 1 + }, + { + "name": "Aqua", + "type1": "Water", + "type2": null, + "base_hp": 44, + "base_attack": 48, + "base_defense": 65, + "base_speed": 43, + "evolution_level": null, + "rarity": 1 + }, + { + "name": "Leafy", + "type1": "Grass", + "type2": null, + "base_hp": 45, + "base_attack": 49, + "base_defense": 49, + "base_speed": 45, + "evolution_level": null, + "rarity": 1 + }, + { + "name": "Sparky", + "type1": "Electric", + "type2": null, + "base_hp": 35, + "base_attack": 55, + "base_defense": 40, + "base_speed": 90, + "evolution_level": null, + "rarity": 2 + }, + { + "name": "Rocky", + "type1": "Rock", + "type2": null, + "base_hp": 40, + "base_attack": 80, + "base_defense": 100, + "base_speed": 25, + "evolution_level": null, + "rarity": 2 + }, + { + "name": "Blazeon", + "type1": "Fire", + "type2": null, + "base_hp": 65, + "base_attack": 80, + "base_defense": 60, + "base_speed": 95, + "evolution_level": null, + "rarity": 3 + }, + { + "name": "Hydrox", + "type1": "Water", + "type2": "Ice", + "base_hp": 70, + "base_attack": 65, + "base_defense": 90, + "base_speed": 60, + "evolution_level": null, + "rarity": 3 + } +] \ No newline at end of file diff --git a/config/settings.example.json b/config/settings.example.json new file mode 100644 index 0000000..32bea6d --- /dev/null +++ b/config/settings.example.json @@ -0,0 +1,10 @@ +{ + "server": "irc.libera.chat", + "port": 6667, + "nickname": "PetBot", + "channel": "#petz", + "command_prefix": "!", + "admin_users": ["your_nickname_here"], + "web_port": 8080, + "database_path": "data/petbot.db" +} \ No newline at end of file diff --git a/config/types.json b/config/types.json new file mode 100644 index 0000000..6ce12a8 --- /dev/null +++ b/config/types.json @@ -0,0 +1,46 @@ +{ + "effectiveness": { + "Fire": { + "strong_against": ["Grass", "Ice"], + "weak_against": ["Water", "Rock"], + "immune_to": [], + "resists": ["Fire", "Grass"] + }, + "Water": { + "strong_against": ["Fire", "Rock"], + "weak_against": ["Electric", "Grass"], + "immune_to": [], + "resists": ["Water", "Fire", "Ice"] + }, + "Grass": { + "strong_against": ["Water", "Rock"], + "weak_against": ["Fire", "Ice"], + "immune_to": [], + "resists": ["Water", "Electric", "Grass"] + }, + "Electric": { + "strong_against": ["Water"], + "weak_against": ["Rock"], + "immune_to": [], + "resists": ["Electric"] + }, + "Rock": { + "strong_against": ["Fire", "Electric"], + "weak_against": ["Water", "Grass"], + "immune_to": [], + "resists": ["Fire", "Normal"] + }, + "Ice": { + "strong_against": ["Grass"], + "weak_against": ["Fire", "Rock"], + "immune_to": [], + "resists": ["Ice"] + }, + "Normal": { + "strong_against": [], + "weak_against": ["Rock"], + "immune_to": [], + "resists": [] + } + } +} \ No newline at end of file diff --git a/config/weather_patterns.json b/config/weather_patterns.json new file mode 100644 index 0000000..2713076 --- /dev/null +++ b/config/weather_patterns.json @@ -0,0 +1,48 @@ +{ + "weather_types": { + "Sunny": { + "description": "Bright sunshine increases Fire and Grass-type spawns", + "spawn_modifier": 1.5, + "affected_types": ["Fire", "Grass"], + "duration_minutes": [60, 120] + }, + "Rainy": { + "description": "Heavy rain boosts Water-type spawns significantly", + "spawn_modifier": 2.0, + "affected_types": ["Water"], + "duration_minutes": [45, 90] + }, + "Thunderstorm": { + "description": "Electric storms double Electric-type spawn rates", + "spawn_modifier": 2.0, + "affected_types": ["Electric"], + "duration_minutes": [30, 60] + }, + "Blizzard": { + "description": "Harsh snowstorm increases Ice and Water-type spawns", + "spawn_modifier": 1.7, + "affected_types": ["Ice", "Water"], + "duration_minutes": [60, 120] + }, + "Earthquake": { + "description": "Ground tremors bring Rock-type pets to the surface", + "spawn_modifier": 1.8, + "affected_types": ["Rock"], + "duration_minutes": [30, 90] + }, + "Calm": { + "description": "Perfect weather with normal spawn rates", + "spawn_modifier": 1.0, + "affected_types": [], + "duration_minutes": [90, 180] + } + }, + "location_weather_chances": { + "Starter Town": ["Sunny", "Calm", "Rainy"], + "Whispering Woods": ["Sunny", "Rainy", "Calm"], + "Electric Canyon": ["Thunderstorm", "Sunny", "Calm"], + "Crystal Caves": ["Earthquake", "Calm"], + "Frozen Tundra": ["Blizzard", "Calm"], + "Dragon's Peak": ["Thunderstorm", "Sunny", "Calm"] + } +} \ No newline at end of file diff --git a/help.html b/help.html new file mode 100644 index 0000000..00626fb --- /dev/null +++ b/help.html @@ -0,0 +1,525 @@ + + + + + + PetBot IRC Commands Reference + + + +
+

🐾 PetBot Command Reference

+

Complete guide to IRC pet collection and battle commands

+

Connect to irc.libera.chat #petz to play!

+
+ +
+
šŸŽ® Getting Started
+
+
+
!start
+
Begin your pet journey! Creates your trainer account and gives you a starter pet in Starter Town.
+
Example: !start
+
+
+
!help
+
Display a quick list of available commands in the IRC channel.
+
Example: !help
+
+
+
+ +
+
šŸ” Exploration & Travel
+
+
+
!explore
+
Explore your current location to find wild pets. Weather affects what types of pets you'll encounter!
+
Example: !explore
+
+
+
!location (or !where)
+
See where you currently are, including the location description.
+
Example: !location
+
+
+
!travel <location>
+
Travel to a different location. Some locations require achievements to unlock!
+
Example: !travel Whispering Woods
+
+
+
!wild <location>
+
Check what types of pets can be found in a specific location.
+
Example: !wild Electric Canyon
+
+
+ +
+

Available Locations:

+
    +
  • Starter Town - Where all trainers begin (always accessible)
  • +
  • Whispering Woods - Unlocked by catching 3 different Grass-type pets
  • +
  • Electric Canyon - Unlocked by catching 2 different Electric-type pets
  • +
  • Crystal Caves - Unlocked by catching 3 different Rock-type pets
  • +
  • Frozen Tundra - Unlocked by catching 5 different Water/Ice-type pets
  • +
  • Dragon's Peak - Unlocked by catching 15 pets total and having 3 Fire-type pets
  • +
+
+
+ +
+
āš”ļø Battle System
+
+
+
!battle
+
Start a battle with a wild pet you encountered during exploration. Strategic combat with type advantages!
+
Example: !battle
+
+
+
!attack <move>
+
Use a specific move during battle. Each pet has different moves based on their type.
+
Example: !attack Ember
+
+
+
!flee
+
Attempt to escape from battle. Success depends on your pet's speed vs the wild pet's speed.
+
Example: !flee
+
+
+
!moves
+
View your active pet's available moves (up to 4 moves). Shows move type and power for battle planning.
+
Example: !moves
+
+
+
!catch (or !capture)
+
Try to catch a pet during exploration OR during battle. During battle, weaker pets (lower HP) have significantly higher catch rates - up to 90% for nearly defeated pets! Both !catch and !capture work identically.
+
Example: !catch or !capture
+
+
+ +
+ Battle Strategy: Use type advantages! Water beats Fire, Fire beats Grass, Grass beats Water, Electric beats Water, Rock beats Fire and Electric. Weaken wild pets in battle to increase catch rate! +
+
+ +
+
🐾 Pet Management
+
+
+
!team
+
View all your pets with active pets marked by ⭐. Shows levels, HP, and storage status.
+
Example: !team
+
+
+
!stats
+
View your player statistics including level, experience, and money.
+
Example: !stats
+
+
+
!activate <pet> PM ONLY
+
Activate a pet for battle by nickname or species name. Only inactive pets can be activated. This command only works in private messages to prevent channel spam.
+
Example: /msg PetBot !activate Sparky
Example: /msg PetBot !activate Pikachu
+
+
+
!deactivate <pet> PM ONLY
+
Deactivate an active pet, removing it from battle readiness. This command only works in private messages to prevent channel spam.
+
Example: /msg PetBot !deactivate Sparky
Example: /msg PetBot !deactivate Pikachu
+
+
+
!swap <pet1> <pet2> PM ONLY
+
Swap activation status between two pets. The first pet becomes inactive, the second becomes active. This command only works in private messages to prevent channel spam.
+
Example: /msg PetBot !swap Sparky Flame
Example: /msg PetBot !swap Pikachu Charmander
+
+
+ +
+ Pet Management Tips: You can only have a limited number of active pets at once. Use !team to see which pets are active (⭐). Pet management commands (!activate, !deactivate, !swap) must be sent as private messages to the bot to prevent channel spam. Use /msg PetBot <command> format. +
+
+ +
+
šŸ† Achievements & Progress
+
+
+
!achievements
+
View your earned achievements and progress. Achievements unlock new locations!
+
Example: !achievements
+
+
+ +
+

Key Achievements:

+
    +
  • Pet Collector - Catch your first 5 pets
  • +
  • Advanced Trainer - Catch 10 pets total
  • +
  • Nature Explorer - Catch 3 different Grass-type pets (unlocks Whispering Woods)
  • +
  • Spark Collector - Catch 2 different Electric-type pets (unlocks Electric Canyon)
  • +
  • Rock Hound - Catch 3 different Rock-type pets (unlocks Crystal Caves)
  • +
  • Ice Breaker - Catch 5 different Water/Ice-type pets (unlocks Frozen Tundra)
  • +
  • Dragon Tamer - Catch 15 pets total + 3 Fire types (unlocks Dragon's Peak)
  • +
+
+
+ +
+
šŸŒ¤ļø Weather System
+
+
+
!weather
+
Check the current weather in your location and its effects on pet spawns.
+
Example: !weather
+
+
+ +
+

Weather Effects:

+
    +
  • Sunny - 1.5x Fire and Grass-type spawns
  • +
  • Rainy - 2.0x Water-type spawns
  • +
  • Thunderstorm - 2.0x Electric-type spawns
  • +
  • Blizzard - 1.7x Ice and Water-type spawns
  • +
  • Earthquake - 1.8x Rock-type spawns
  • +
  • Calm - Normal spawn rates for all types
  • +
+
+
+ +
+
šŸ“š Game Mechanics
+
+
+
+ How to Play: +
    +
  1. Use !start to create your trainer and get a starter pet
  2. +
  3. Use !explore to find wild pets in your current location
  4. +
  5. Choose to !battle the wild pet (recommended) or !catch directly
  6. +
  7. In battle, use !attack <move> to weaken the wild pet
  8. +
  9. Use !catch (or !capture) during battle for much higher success rates on damaged pets
  10. +
  11. Battle-catch rates: 30% base + up to 50% bonus for low HP (90% max for nearly defeated pets)
  12. +
  13. Collect different types of pets to unlock achievements and new locations
  14. +
  15. Use !travel to explore new areas as you unlock them
  16. +
  17. Check !weather for optimal catching conditions
  18. +
+
+
+
+
+ +
+
šŸ”§ Type Effectiveness Chart
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Attacking TypeStrong Against (2x)Weak Against (0.5x)
FireGrass, IceWater, Rock
WaterFire, RockElectric, Grass
GrassWater, RockFire, Ice
ElectricWaterRock
RockFire, ElectricWater, Grass
NormalNoneRock
+
+
+
+ + + + \ No newline at end of file diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..bab8c2c --- /dev/null +++ b/modules/__init__.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""PetBot modules package""" + +from .core_commands import CoreCommands +from .exploration import Exploration +from .battle_system import BattleSystem +from .pet_management import PetManagement +from .achievements import Achievements +from .admin import Admin + +__all__ = [ + 'CoreCommands', + 'Exploration', + 'BattleSystem', + 'PetManagement', + 'Achievements', + 'Admin' +] \ No newline at end of file diff --git a/modules/achievements.py b/modules/achievements.py new file mode 100644 index 0000000..7b4f805 --- /dev/null +++ b/modules/achievements.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Achievements commands module for PetBot""" + +from .base_module import BaseModule + +class Achievements(BaseModule): + """Handles achievements display and tracking""" + + def get_commands(self): + return ["achievements"] + + async def handle_command(self, channel, nickname, command, args): + if command == "achievements": + await self.cmd_achievements(channel, nickname) + + async def cmd_achievements(self, channel, nickname): + """Show player achievements""" + player = await self.require_player(channel, nickname) + if not player: + return + + achievements = await self.database.get_player_achievements(player["id"]) + + if achievements: + self.send_message(channel, f"šŸ† {nickname}'s Achievements:") + for achievement in achievements[:5]: # Show last 5 achievements + self.send_message(channel, f"• {achievement['name']}: {achievement['description']}") + + if len(achievements) > 5: + self.send_message(channel, f"... and {len(achievements) - 5} more!") + else: + self.send_message(channel, f"{nickname}: No achievements yet! Keep exploring and catching pets to unlock new areas!") \ No newline at end of file diff --git a/modules/admin.py b/modules/admin.py new file mode 100644 index 0000000..8b7b40d --- /dev/null +++ b/modules/admin.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Admin commands module for PetBot""" + +from .base_module import BaseModule + +class Admin(BaseModule): + """Handles admin-only commands like reload""" + + def get_commands(self): + return ["reload"] + + async def handle_command(self, channel, nickname, command, args): + if command == "reload": + await self.cmd_reload(channel, nickname) + + async def cmd_reload(self, channel, nickname): + """Reload bot modules (megasconed only)""" + if nickname.lower() != "megasconed": + self.send_message(channel, f"{nickname}: Access denied. Admin command.") + return + + try: + # Trigger module reload in main bot + success = await self.bot.reload_modules() + if success: + self.send_message(channel, f"{nickname}: āœ… Modules reloaded successfully!") + else: + self.send_message(channel, f"{nickname}: āŒ Module reload failed!") + except Exception as e: + self.send_message(channel, f"{nickname}: āŒ Reload error: {str(e)}") \ No newline at end of file diff --git a/modules/base_module.py b/modules/base_module.py new file mode 100644 index 0000000..8a4854d --- /dev/null +++ b/modules/base_module.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Base module class for PetBot command modules""" + +import asyncio +from abc import ABC, abstractmethod + +class BaseModule(ABC): + """Base class for all PetBot modules""" + + def __init__(self, bot, database, game_engine): + self.bot = bot + self.database = database + self.game_engine = game_engine + + @abstractmethod + def get_commands(self): + """Return list of commands this module handles""" + pass + + @abstractmethod + async def handle_command(self, channel, nickname, command, args): + """Handle a command for this module""" + pass + + def send_message(self, target, message): + """Send message through the bot""" + self.bot.send_message(target, message) + + def send_pm(self, nickname, message): + """Send private message to user""" + self.bot.send_message(nickname, message) + + async def get_player(self, nickname): + """Get player from database""" + return await self.database.get_player(nickname) + + async def require_player(self, channel, nickname): + """Get player or send start message if not found""" + player = await self.get_player(nickname) + if not player: + self.send_message(channel, f"{nickname}: Use !start to begin your journey first!") + return None + return player \ No newline at end of file diff --git a/modules/battle_system.py b/modules/battle_system.py new file mode 100644 index 0000000..7e98d5b --- /dev/null +++ b/modules/battle_system.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Battle system commands module for PetBot""" + +from .base_module import BaseModule + +class BattleSystem(BaseModule): + """Handles battle, attack, flee, and moves commands""" + + # IRC color codes for different move types + MOVE_TYPE_COLORS = { + 'Fire': '\x0304', # Red + 'Water': '\x0312', # Light Blue + 'Grass': '\x0303', # Green + 'Electric': '\x0308', # Yellow + 'Rock': '\x0307', # Orange/Brown + 'Normal': '\x0314', # Gray + 'Physical': '\x0313', # Pink/Magenta (fallback for category) + 'Special': '\x0310' # Cyan (fallback for category) + } + + def get_commands(self): + return ["battle", "attack", "flee", "moves"] + + def get_move_color(self, move_type): + """Get IRC color code for a move type""" + return self.MOVE_TYPE_COLORS.get(move_type, '\x0312') # Default to light blue + + async def handle_command(self, channel, nickname, command, args): + if command == "battle": + await self.cmd_battle(channel, nickname) + elif command == "attack": + await self.cmd_attack(channel, nickname, args) + elif command == "flee": + await self.cmd_flee(channel, nickname) + elif command == "moves": + await self.cmd_moves(channel, nickname) + + async def cmd_battle(self, channel, nickname): + """Start a battle with encountered wild pet""" + player = await self.require_player(channel, nickname) + if not player: + return + + # Check if player has an active encounter + if player["id"] not in self.bot.active_encounters: + self.send_message(channel, f"{nickname}: You need to !explore first to find a pet to battle!") + return + + # Check if already in battle + active_battle = await self.game_engine.battle_engine.get_active_battle(player["id"]) + if active_battle: + self.send_message(channel, f"{nickname}: You're already in battle! Use !attack or !flee.") + return + + # Get player's active pet + pets = await self.database.get_player_pets(player["id"], active_only=True) + if not pets: + self.send_message(channel, f"{nickname}: You need an active pet to battle! Use !team to check your pets.") + return + + player_pet = pets[0] # Use first active pet + wild_pet = self.bot.active_encounters[player["id"]] + + # Start battle + battle = await self.game_engine.battle_engine.start_battle(player["id"], player_pet, wild_pet) + + # Condensed battle start message with all info + moves_colored = " | ".join([ + f"{self.get_move_color(move['type'])}{move['name']}\x0F" + for move in battle["available_moves"] + ]) + + battle_start_msg = (f"āš”ļø {nickname}: Battle! Your {player_pet['species_name']} (Lv.{player_pet['level']}, " + f"{battle['player_hp']}/{player_pet['max_hp']} HP) vs Wild {wild_pet['species_name']} " + f"(Lv.{wild_pet['level']}, {battle['wild_hp']}/{wild_pet['max_hp']} HP)") + + self.send_message(channel, battle_start_msg) + self.send_message(channel, f"šŸŽÆ Moves: {moves_colored} | Use !attack ") + + async def cmd_attack(self, channel, nickname, args): + """Use a move in battle""" + if not args: + self.send_message(channel, f"{nickname}: Specify a move to use! Example: !attack Tackle") + return + + player = await self.require_player(channel, nickname) + if not player: + return + + move_name = " ".join(args).title() # Normalize to Title Case + result = await self.game_engine.battle_engine.execute_battle_turn(player["id"], move_name) + + if "error" in result: + self.send_message(channel, f"{nickname}: {result['error']}") + return + + # Display battle results - condensed with player names for clarity + for action in result["results"]: + # Determine attacker and target with player context + if "Wild" in action['attacker']: + attacker = f"Wild {action['attacker']}" + target_context = f"{nickname}" + else: + attacker = f"{nickname}'s {action['attacker']}" + target_context = "wild pet" + + # Build condensed message with all info on one line + if action["damage"] > 0: + effectiveness_msgs = { + "super_effective": "⚔ Super effective!", + "not_very_effective": "šŸ’« Not very effective...", + "no_effect": "āŒ No effect!", + "super_effective_critical": "šŸ’„ CRIT! Super effective!", + "normal_critical": "šŸ’„ Critical hit!", + "not_very_effective_critical": "šŸ’„ Crit, not very effective..." + } + + effectiveness = effectiveness_msgs.get(action["effectiveness"], "") + battle_msg = f"āš”ļø {attacker} used {action['move']} → {action['damage']} damage to {target_context}! {effectiveness} (HP: {action['target_hp']})" + else: + # Status move or no damage + battle_msg = f"āš”ļø {attacker} used {action['move']} on {target_context}! (HP: {action['target_hp']})" + + self.send_message(channel, battle_msg) + + if result["battle_over"]: + if result["winner"] == "player": + self.send_message(channel, f"šŸŽ‰ {nickname}: You won the battle!") + # Remove encounter since battle is over + if player["id"] in self.bot.active_encounters: + del self.bot.active_encounters[player["id"]] + else: + self.send_message(channel, f"šŸ’€ {nickname}: Your pet fainted! You lost the battle...") + # Remove encounter + if player["id"] in self.bot.active_encounters: + del self.bot.active_encounters[player["id"]] + else: + # Battle continues - show available moves with type-based colors + moves_colored = " | ".join([ + f"{self.get_move_color(move['type'])}{move['name']}\x0F" + for move in result["available_moves"] + ]) + self.send_message(channel, f"šŸŽÆ {nickname}'s turn! Moves: {moves_colored}") + + async def cmd_flee(self, channel, nickname): + """Attempt to flee from battle""" + player = await self.require_player(channel, nickname) + if not player: + return + + success = await self.game_engine.battle_engine.flee_battle(player["id"]) + + if success: + self.send_message(channel, f"šŸ’Ø {nickname} successfully fled from battle!") + # Remove encounter + if player["id"] in self.bot.active_encounters: + del self.bot.active_encounters[player["id"]] + else: + self.send_message(channel, f"āŒ {nickname} couldn't escape! Battle continues!") + + async def cmd_moves(self, channel, nickname): + """Show active pet's available moves""" + player = await self.require_player(channel, nickname) + if not player: + return + + # Get player's active pets + pets = await self.database.get_player_pets(player["id"], active_only=True) + if not pets: + self.send_message(channel, f"{nickname}: You don't have any active pets! Use !team to check your pets.") + return + + active_pet = pets[0] # Use first active pet + available_moves = self.game_engine.battle_engine.get_available_moves(active_pet) + + if not available_moves: + self.send_message(channel, f"{nickname}: Your {active_pet['species_name']} has no available moves!") + return + + # Limit to 4 moves max and format compactly + moves_to_show = available_moves[:4] + + move_info = [] + for move in moves_to_show: + power = move.get('power', 'Status') + power_str = str(power) if power != 'Status' else 'Stat' + move_colored = f"{self.get_move_color(move['type'])}{move['name']}\x0F" + move_info.append(f"{move_colored} ({move['type']}, {power_str})") + + pet_name = active_pet["nickname"] or active_pet["species_name"] + moves_line = " | ".join(move_info) + self.send_message(channel, f"šŸŽÆ {nickname}'s {pet_name}: {moves_line}") \ No newline at end of file diff --git a/modules/core_commands.py b/modules/core_commands.py new file mode 100644 index 0000000..3f2d73b --- /dev/null +++ b/modules/core_commands.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Core commands module for PetBot""" + +from .base_module import BaseModule + +class CoreCommands(BaseModule): + """Handles basic bot commands like start, help, stats""" + + def get_commands(self): + return ["help", "start", "stats"] + + async def handle_command(self, channel, nickname, command, args): + if command == "help": + await self.cmd_help(channel, nickname) + elif command == "start": + await self.cmd_start(channel, nickname) + elif command == "stats": + await self.cmd_stats(channel, nickname, args) + + async def cmd_help(self, channel, nickname): + """Send help URL to prevent rate limiting""" + self.send_message(channel, f"{nickname}: Complete command reference available at: http://localhost:8080/help") + + async def cmd_start(self, channel, nickname): + """Start a new player""" + player = await self.get_player(nickname) + if player: + self.send_message(channel, f"{nickname}: You already have an account! Use !team to see your pets.") + return + + player_id = await self.database.create_player(nickname) + starter_pet = await self.game_engine.give_starter_pet(player_id) + + self.send_message(channel, + f"šŸŽ‰ {nickname}: Welcome to the world of pets! You received a Level {starter_pet['level']} {starter_pet['species_name']}! You are now in Starter Town.") + + async def cmd_stats(self, channel, nickname, args): + """Show player statistics""" + player = await self.require_player(channel, nickname) + if not player: + return + + self.send_message(channel, + f"šŸ“Š {nickname}: Level {player['level']} | {player['experience']} XP | ${player['money']}") \ No newline at end of file diff --git a/modules/exploration.py b/modules/exploration.py new file mode 100644 index 0000000..c9ad543 --- /dev/null +++ b/modules/exploration.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Exploration commands module for PetBot""" + +from .base_module import BaseModule + +class Exploration(BaseModule): + """Handles exploration, travel, location, weather, and wild commands""" + + def get_commands(self): + return ["explore", "travel", "location", "where", "weather", "wild", "catch", "capture"] + + async def handle_command(self, channel, nickname, command, args): + if command == "explore": + await self.cmd_explore(channel, nickname) + elif command == "travel": + await self.cmd_travel(channel, nickname, args) + elif command in ["location", "where"]: + await self.cmd_location(channel, nickname) + elif command == "weather": + await self.cmd_weather(channel, nickname) + elif command == "wild": + await self.cmd_wild(channel, nickname, args) + elif command in ["catch", "capture"]: + await self.cmd_catch(channel, nickname) + + async def cmd_explore(self, channel, nickname): + """Explore current location""" + player = await self.require_player(channel, nickname) + if not player: + return + + encounter = await self.game_engine.explore_location(player["id"]) + + if encounter["type"] == "error": + self.send_message(channel, f"{nickname}: {encounter['message']}") + elif encounter["type"] == "empty": + self.send_message(channel, f"šŸ” {nickname}: {encounter['message']}") + elif encounter["type"] == "encounter": + # Store the encounter for potential catching + self.bot.active_encounters[player["id"]] = encounter["pet"] + + pet = encounter["pet"] + type_str = pet["type1"] + if pet["type2"]: + type_str += f"/{pet['type2']}" + + self.send_message(channel, + f"🐾 {nickname}: A wild Level {pet['level']} {pet['species_name']} ({type_str}) appeared in {encounter['location']}!") + self.send_message(channel, f"Choose your action: !battle to fight it, or !catch to try catching it directly!") + + async def cmd_travel(self, channel, nickname, args): + """Travel to a different location""" + if not args: + self.send_message(channel, f"{nickname}: Specify where to travel! Available: Starter Town, Whispering Woods, Electric Canyon, Crystal Caves, Frozen Tundra, Dragon's Peak") + return + + player = await self.require_player(channel, nickname) + if not player: + return + + destination = " ".join(args).title() # Normalize to Title Case + location = await self.database.get_location_by_name(destination) + + if not location: + self.send_message(channel, f"{nickname}: '{destination}' is not a valid location!") + return + + # Check if player can access this location + missing_requirements = await self.database.get_missing_location_requirements(player["id"], location["id"]) + if missing_requirements: + # Build specific message about required achievements + if len(missing_requirements) == 1: + achievement = missing_requirements[0] + self.send_message(channel, f"{nickname}: You cannot access {destination} yet! Required achievement: '{achievement['name']}' - {achievement['description']}") + else: + achievement_names = [f"'{req['name']}'" for req in missing_requirements] + self.send_message(channel, f"{nickname}: You cannot access {destination} yet! Required achievements: {', '.join(achievement_names)}. Use !achievements to see progress.") + return + + # Clear any active encounters when traveling + if player["id"] in self.bot.active_encounters: + del self.bot.active_encounters[player["id"]] + + await self.database.update_player_location(player["id"], location["id"]) + + # Show weather info + weather = await self.database.get_location_weather(location["id"]) + weather_msg = "" + if weather: + weather_patterns = getattr(self.game_engine, 'weather_patterns', {}) + weather_info = weather_patterns.get("weather_types", {}).get(weather["weather_type"], {}) + weather_desc = weather_info.get("description", f"{weather['weather_type']} weather") + weather_msg = f" Weather: {weather['weather_type']} - {weather_desc}" + + self.send_message(channel, f"šŸ—ŗļø {nickname}: You traveled to {destination}. {location['description']}{weather_msg}") + + async def cmd_location(self, channel, nickname): + """Show current location""" + player = await self.require_player(channel, nickname) + if not player: + return + + location = await self.database.get_player_location(player["id"]) + if location: + self.send_message(channel, f"šŸ“ {nickname}: You are currently in {location['name']}. {location['description']}") + else: + self.send_message(channel, f"{nickname}: You seem to be lost! Contact an admin.") + + async def cmd_weather(self, channel, nickname): + """Show current weather""" + player = await self.require_player(channel, nickname) + if not player: + return + + location = await self.database.get_player_location(player["id"]) + if not location: + self.send_message(channel, f"{nickname}: You seem to be lost!") + return + + weather = await self.database.get_location_weather(location["id"]) + if weather: + weather_patterns = getattr(self.game_engine, 'weather_patterns', {}) + weather_info = weather_patterns.get("weather_types", {}).get(weather["weather_type"], {}) + weather_desc = weather_info.get("description", f"{weather['weather_type']} weather") + + self.send_message(channel, f"šŸŒ¤ļø {nickname}: Current weather in {location['name']}: {weather['weather_type']}") + self.send_message(channel, f"Effect: {weather_desc}") + else: + self.send_message(channel, f"šŸŒ¤ļø {nickname}: The weather in {location['name']} is calm with no special effects.") + + async def cmd_wild(self, channel, nickname, args): + """Show wild pets in location (defaults to current)""" + player = await self.require_player(channel, nickname) + if not player: + return + + if args: + # Specific location requested + location_name = " ".join(args).title() + else: + # Default to current location + current_location = await self.database.get_player_location(player["id"]) + if not current_location: + self.send_message(channel, f"{nickname}: You seem to be lost!") + return + location_name = current_location["name"] + + wild_pets = await self.game_engine.get_location_spawns(location_name) + + if wild_pets: + pet_list = ", ".join([pet["name"] for pet in wild_pets]) + self.send_message(channel, f"🌿 Wild pets in {location_name}: {pet_list}") + else: + self.send_message(channel, f"{nickname}: No location found called '{location_name}'") + + async def cmd_catch(self, channel, nickname): + """Catch a pet during exploration or battle""" + player = await self.require_player(channel, nickname) + if not player: + return + + # Check if player is in an active battle + active_battle = await self.game_engine.battle_engine.get_active_battle(player["id"]) + if active_battle: + # Catching during battle + wild_pet = active_battle["wild_pet"] + current_hp = active_battle["wild_hp"] + max_hp = wild_pet["max_hp"] + + # Calculate catch rate based on remaining HP (lower HP = higher catch rate) + hp_percentage = current_hp / max_hp + base_catch_rate = 0.3 # Lower base rate than direct catch + hp_bonus = (1.0 - hp_percentage) * 0.5 # Up to 50% bonus for low HP + final_catch_rate = min(0.9, base_catch_rate + hp_bonus) # Cap at 90% + + import random + if random.random() < final_catch_rate: + # Successful catch during battle + result = await self.game_engine.attempt_catch_current_location(player["id"], wild_pet) + + # End the battle + await_result = await self.game_engine.battle_engine.end_battle(player["id"], "caught") + + # Check for achievements + type_achievements = await self.game_engine.check_and_award_achievements(player["id"], "catch_type", "") + total_achievements = await self.game_engine.check_and_award_achievements(player["id"], "catch_total", "") + + all_achievements = type_achievements + total_achievements + if all_achievements: + for achievement in all_achievements: + self.send_message(channel, f"šŸ† {nickname}: Achievement unlocked: {achievement['name']}! {achievement['description']}") + + # Remove encounter + if player["id"] in self.bot.active_encounters: + del self.bot.active_encounters[player["id"]] + + self.send_message(channel, f"šŸŽÆ {nickname}: Caught the {wild_pet['species_name']} during battle! (HP: {current_hp}/{max_hp})") + else: + # Failed catch - battle continues + catch_percentage = int(final_catch_rate * 100) + self.send_message(channel, f"šŸŽÆ {nickname}: The {wild_pet['species_name']} broke free! ({catch_percentage}% catch rate) Battle continues!") + return + + # Regular exploration catch (not in battle) + if player["id"] not in self.bot.active_encounters: + self.send_message(channel, f"{nickname}: You need to !explore first to find a pet, or be in battle to catch!") + return + + target_pet = self.bot.active_encounters[player["id"]] + result = await self.game_engine.attempt_catch_current_location(player["id"], target_pet) + + # Check for achievements after successful catch + if "Success!" in result: + type_achievements = await self.game_engine.check_and_award_achievements(player["id"], "catch_type", "") + total_achievements = await self.game_engine.check_and_award_achievements(player["id"], "catch_total", "") + + all_achievements = type_achievements + total_achievements + if all_achievements: + for achievement in all_achievements: + self.send_message(channel, f"šŸ† {nickname}: Achievement unlocked: {achievement['name']}! {achievement['description']}") + + # Remove the encounter regardless of success + del self.bot.active_encounters[player["id"]] + + self.send_message(channel, f"šŸŽÆ {nickname}: {result}") \ No newline at end of file diff --git a/modules/pet_management.py b/modules/pet_management.py new file mode 100644 index 0000000..0d68b4d --- /dev/null +++ b/modules/pet_management.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Pet management commands module for PetBot""" + +from .base_module import BaseModule + +class PetManagement(BaseModule): + """Handles team, pets, and future pet management commands""" + + def get_commands(self): + return ["team", "pets", "activate", "deactivate", "swap"] + + async def handle_command(self, channel, nickname, command, args): + if command == "team": + await self.cmd_team(channel, nickname) + elif command == "pets": + await self.cmd_pets(channel, nickname) + elif command == "activate": + await self.cmd_activate(channel, nickname, args) + elif command == "deactivate": + await self.cmd_deactivate(channel, nickname, args) + elif command == "swap": + await self.cmd_swap(channel, nickname, args) + + async def cmd_team(self, channel, nickname): + """Show active pets (channel display)""" + player = await self.require_player(channel, nickname) + if not player: + return + + pets = await self.database.get_player_pets(player["id"], active_only=False) + if not pets: + self.send_message(channel, f"{nickname}: You don't have any pets! Use !catch to find some.") + return + + # Show active pets first, then others + active_pets = [pet for pet in pets if pet.get("is_active")] + inactive_pets = [pet for pet in pets if not pet.get("is_active")] + + team_info = [] + + # Active pets with star + for pet in active_pets: + name = pet["nickname"] or pet["species_name"] + team_info.append(f"⭐{name} (Lv.{pet['level']}) - {pet['hp']}/{pet['max_hp']} HP") + + # Inactive pets + for pet in inactive_pets[:5]: # Show max 5 inactive + name = pet["nickname"] or pet["species_name"] + team_info.append(f"{name} (Lv.{pet['level']}) - {pet['hp']}/{pet['max_hp']} HP") + + if len(inactive_pets) > 5: + team_info.append(f"... and {len(inactive_pets) - 5} more in storage") + + self.send_message(channel, f"🐾 {nickname}'s team: " + " | ".join(team_info)) + + async def cmd_pets(self, channel, nickname): + """Show link to pet collection web page""" + player = await self.require_player(channel, nickname) + if not player: + return + + # Send URL to player's profile page instead of PM spam + self.send_message(channel, f"{nickname}: View your complete pet collection at: http://localhost:8080/player/{nickname}") + + async def cmd_activate(self, channel, nickname, args): + """Activate a pet for battle (PM only)""" + # Redirect to PM for privacy + if not args: + self.send_pm(nickname, "Usage: !activate ") + self.send_message(channel, f"{nickname}: Pet activation instructions sent via PM!") + return + + player = await self.require_player(channel, nickname) + if not player: + return + + pet_name = " ".join(args) + result = await self.database.activate_pet(player["id"], pet_name) + + if result["success"]: + pet = result["pet"] + display_name = pet["nickname"] or pet["species_name"] + self.send_pm(nickname, f"āœ… {display_name} is now active for battle!") + self.send_message(channel, f"{nickname}: Pet activated successfully!") + else: + self.send_pm(nickname, f"āŒ {result['error']}") + self.send_message(channel, f"{nickname}: Pet activation failed - check PM for details!") + + async def cmd_deactivate(self, channel, nickname, args): + """Deactivate a pet to storage (PM only)""" + # Redirect to PM for privacy + if not args: + self.send_pm(nickname, "Usage: !deactivate ") + self.send_message(channel, f"{nickname}: Pet deactivation instructions sent via PM!") + return + + player = await self.require_player(channel, nickname) + if not player: + return + + pet_name = " ".join(args) + result = await self.database.deactivate_pet(player["id"], pet_name) + + if result["success"]: + pet = result["pet"] + display_name = pet["nickname"] or pet["species_name"] + self.send_pm(nickname, f"šŸ“¦ {display_name} moved to storage!") + self.send_message(channel, f"{nickname}: Pet deactivated successfully!") + else: + self.send_pm(nickname, f"āŒ {result['error']}") + self.send_message(channel, f"{nickname}: Pet deactivation failed - check PM for details!") + + async def cmd_swap(self, channel, nickname, args): + """Swap active/storage status of two pets (PM only)""" + # Redirect to PM for privacy + if len(args) < 2: + self.send_pm(nickname, "Usage: !swap ") + self.send_pm(nickname, "Example: !swap Flamey Aqua") + self.send_message(channel, f"{nickname}: Pet swap instructions sent via PM!") + return + + player = await self.require_player(channel, nickname) + if not player: + return + + # Handle multi-word pet names by splitting on first space vs last space + if len(args) == 2: + pet1_name, pet2_name = args + else: + # For more complex parsing, assume equal split + mid_point = len(args) // 2 + pet1_name = " ".join(args[:mid_point]) + pet2_name = " ".join(args[mid_point:]) + + result = await self.database.swap_pets(player["id"], pet1_name, pet2_name) + + if result["success"]: + pet1 = result["pet1"] + pet2 = result["pet2"] + pet1_display = pet1["nickname"] or pet1["species_name"] + pet2_display = pet2["nickname"] or pet2["species_name"] + + self.send_pm(nickname, f"šŸ”„ Swap complete!") + self.send_pm(nickname, f" • {pet1_display} → {result['pet1_now']}") + self.send_pm(nickname, f" • {pet2_display} → {result['pet2_now']}") + self.send_message(channel, f"{nickname}: Pet swap completed!") + else: + self.send_pm(nickname, f"āŒ {result['error']}") + self.send_message(channel, f"{nickname}: Pet swap failed - check PM for details!") \ No newline at end of file diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cdaf3fc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +irc>=20.3.0 +aiosqlite>=0.19.0 +python-dotenv>=1.0.0 +asyncio \ No newline at end of file diff --git a/reset_players.py b/reset_players.py new file mode 100755 index 0000000..30c7ddc --- /dev/null +++ b/reset_players.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Reset Player Database Script +Clears all player data for a fresh start while preserving game data (species, locations, etc.) +""" + +import os +import sys +import asyncio + +# Add the project directory to the path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from src.database import Database + +async def reset_player_data(force=False): + """Reset all player-related data in the database""" + print("šŸ”„ Initializing database connection...") + database = Database() + await database.init_database() + + print("āš ļø WARNING: This will delete ALL player data!") + print(" - All player accounts") + print(" - All caught pets") + print(" - All player achievements") + print(" - All player progress") + print(" - All battle history") + print("") + print("Game data (species, locations, moves, etc.) will be preserved.") + print("") + + if not force: + confirm = input("Are you sure you want to reset all player data? (type 'yes' to confirm): ") + if confirm.lower() != 'yes': + print("āŒ Reset cancelled.") + return + else: + print("šŸš€ Force mode enabled - proceeding with reset...") + + print("") + print("šŸ—‘ļø Resetting player data...") + + try: + # Get database connection using aiosqlite directly + import aiosqlite + async with aiosqlite.connect(database.db_path) as db: + # Clear player-related tables in correct order (foreign key constraints) + tables_to_clear = [ + 'active_battles', # Battle data + 'player_achievements', # Player achievements + 'pets', # Player pets + 'players' # Player accounts + ] + + for table in tables_to_clear: + print(f" Clearing {table}...") + await db.execute(f"DELETE FROM {table}") + await db.commit() + + # Reset auto-increment counters + print(" Resetting ID counters...") + for table in tables_to_clear: + await db.execute(f"DELETE FROM sqlite_sequence WHERE name='{table}'") + await db.commit() + + print("āœ… Player data reset complete!") + print("") + print("šŸŽ® Players can now use !start to begin fresh journeys.") + + except Exception as e: + print(f"āŒ Error resetting player data: {e}") + import traceback + traceback.print_exc() + +async def main(): + """Main function""" + print("🐾 PetBot Player Data Reset Tool") + print("=" * 40) + + # Check for force flag + force = len(sys.argv) > 1 and sys.argv[1] == '--force' + await reset_player_data(force=force) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/run_bot.py b/run_bot.py new file mode 100644 index 0000000..b36568c --- /dev/null +++ b/run_bot.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +import socket +import time +import sys +import os +import asyncio +import importlib + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from src.database import Database +from src.game_engine import GameEngine +from modules import CoreCommands, Exploration, BattleSystem, PetManagement, Achievements, Admin + +class PetBot: + def __init__(self): + self.database = Database() + self.game_engine = GameEngine(self.database) + self.config = { + "server": "irc.libera.chat", + "port": 6667, + "nickname": "PetBot", + "channel": "#petz", + "command_prefix": "!" + } + self.socket = None + self.connected = False + self.running = True + self.active_encounters = {} # player_id -> encounter_data + self.modules = {} + self.command_map = {} + + def initialize_async_components(self): + """Initialize async components""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + print("Initializing database...") + loop.run_until_complete(self.database.init_database()) + print("Loading game data...") + loop.run_until_complete(self.game_engine.load_game_data()) + print("Loading modules...") + self.load_modules() + print("āœ“ Bot initialized successfully!") + + self.loop = loop + + def load_modules(self): + """Load all command modules""" + module_classes = [ + CoreCommands, + Exploration, + BattleSystem, + PetManagement, + Achievements, + Admin + ] + + self.modules = {} + self.command_map = {} + + for module_class in module_classes: + module_name = module_class.__name__ + module_instance = module_class(self, self.database, self.game_engine) + self.modules[module_name] = module_instance + + # Map commands to modules + for command in module_instance.get_commands(): + self.command_map[command] = module_instance + + print(f"āœ“ Loaded {len(self.modules)} modules with {len(self.command_map)} commands") + + async def reload_modules(self): + """Reload all modules (for admin use)""" + try: + # Reload module files + import modules + importlib.reload(modules.core_commands) + importlib.reload(modules.exploration) + importlib.reload(modules.battle_system) + importlib.reload(modules.pet_management) + importlib.reload(modules.achievements) + importlib.reload(modules.admin) + importlib.reload(modules) + + # Reinitialize modules + self.load_modules() + return True + except Exception as e: + print(f"Module reload failed: {e}") + return False + + def test_connection(self): + """Test if we can connect to IRC""" + print("Testing IRC connection...") + try: + test_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + test_sock.settimeout(5) + test_sock.connect((self.config["server"], self.config["port"])) + test_sock.close() + print("āœ“ IRC connection test successful") + return True + except Exception as e: + print(f"āœ— IRC connection failed: {e}") + return False + + def connect(self): + self.initialize_async_components() + + if not self.test_connection(): + print("\n=== IRC Connection Failed ===") + print("The bot's core functionality is working perfectly!") + print("Run these commands to test locally:") + print(" python3 test/test_commands.py - Test all functionality") + print(" python3 test/test_db.py - Test database operations") + print("\nTo connect to IRC:") + print("1. Ensure network access to irc.libera.chat:6667") + print("2. Or modify config in run_bot.py to use a different server") + return + + print(f"Connecting to {self.config['server']}:{self.config['port']}") + + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.settimeout(10) + + try: + self.socket.connect((self.config["server"], self.config["port"])) + print("āœ“ Connected to IRC server!") + except Exception as e: + print(f"āœ— Failed to connect: {e}") + return + + # Send IRC handshake + self.send(f"NICK {self.config['nickname']}") + self.send(f"USER {self.config['nickname']} 0 * :{self.config['nickname']}") + print("āœ“ Sent IRC handshake") + + # Start message processing + self.socket.settimeout(1) + self.main_loop() + + def main_loop(self): + print("āœ“ Bot is now running! Use Ctrl+C to stop.") + print(f"āœ“ Bot will join {self.config['channel']} when connected") + print(f"āœ“ Loaded modules: {', '.join(self.modules.keys())}") + + while self.running: + try: + data = self.socket.recv(4096).decode('utf-8', errors='ignore') + if not data: + print("Connection closed by server") + break + + lines = data.strip().split('\\n') + for line in lines: + if line.strip(): + self.handle_line(line.strip()) + + except socket.timeout: + continue + except KeyboardInterrupt: + print("\\nāœ“ Shutting down bot...") + self.running = False + break + except Exception as e: + print(f"Error in main loop: {e}") + time.sleep(1) + + if self.socket: + self.socket.close() + self.loop.close() + + def send(self, message): + if self.socket: + full_message = f"{message}\\r\\n" + print(f">> {message}") + self.socket.send(full_message.encode('utf-8')) + + def handle_line(self, line): + print(f"<< {line}") + + if line.startswith("PING"): + pong_response = line.replace("PING", "PONG") + self.send(pong_response) + return + + # Handle connection messages + if "376" in line or "422" in line: # End of MOTD + if not self.connected: + self.send(f"JOIN {self.config['channel']}") + self.connected = True + print(f"āœ“ Joined {self.config['channel']} - Bot is ready!") + return + + parts = line.split() + if len(parts) < 4: + return + + if parts[1] == "PRIVMSG": + channel = parts[2] + message = " ".join(parts[3:])[1:] + + hostmask = parts[0][1:] + nickname = hostmask.split('!')[0] + + if message.startswith(self.config["command_prefix"]): + print(f"Processing command from {nickname}: {message}") + self.handle_command(channel, nickname, message) + + def handle_command(self, channel, nickname, message): + command_parts = message[1:].split() + if not command_parts: + return + + command = command_parts[0].lower() + args = command_parts[1:] + + try: + if command in self.command_map: + module = self.command_map[command] + # Run async command handler + self.loop.run_until_complete( + module.handle_command(channel, nickname, command, args) + ) + else: + self.send_message(channel, f"{nickname}: Unknown command. Use !help for available commands.") + except Exception as e: + self.send_message(channel, f"{nickname}: Error processing command: {str(e)}") + print(f"Command error: {e}") + + def send_message(self, target, message): + self.send(f"PRIVMSG {target} :{message}") + time.sleep(0.5) + + def run_async_command(self, coro): + return self.loop.run_until_complete(coro) + +if __name__ == "__main__": + print("🐾 Starting Pet Bot for IRC (Modular Version)...") + bot = PetBot() + try: + bot.connect() + except KeyboardInterrupt: + print("\\nšŸ”„ Bot stopping...") + # Gracefully shutdown the game engine + try: + bot.loop.run_until_complete(bot.game_engine.shutdown()) + except Exception as e: + print(f"Error during shutdown: {e}") + print("āœ“ Bot stopped by user") + except Exception as e: + print(f"āœ— Bot crashed: {e}") + import traceback + traceback.print_exc() \ No newline at end of file diff --git a/run_bot_debug.py b/run_bot_debug.py new file mode 100644 index 0000000..8620e21 --- /dev/null +++ b/run_bot_debug.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +import socket +import time +import sys +import os +import asyncio +import importlib + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from src.database import Database +from src.game_engine import GameEngine +from modules import CoreCommands, Exploration, BattleSystem, PetManagement, Achievements, Admin +from webserver import PetBotWebServer + +class PetBotDebug: + def __init__(self): + print("šŸ¤– PetBot Debug Mode - Initializing...") + self.database = Database() + self.game_engine = GameEngine(self.database) + self.config = { + "server": "irc.libera.chat", + "port": 6667, + "nickname": "PetBot", + "channel": "#petz", + "command_prefix": "!" + } + self.socket = None + self.connected = False + self.running = True + self.active_encounters = {} + self.modules = {} + self.command_map = {} + self.web_server = None + print("āœ… Basic initialization complete") + + def initialize_async_components(self): + """Initialize async components""" + print("šŸ”„ Creating event loop...") + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + print("āœ… Event loop created") + + print("šŸ”„ Initializing database...") + loop.run_until_complete(self.database.init_database()) + print("āœ… Database initialized") + + print("šŸ”„ Loading game data...") + loop.run_until_complete(self.game_engine.load_game_data()) + print("āœ… Game data loaded") + + print("šŸ”„ Loading modules...") + self.load_modules() + print("āœ… Modules loaded") + + print("šŸ”„ Starting web server...") + self.web_server = PetBotWebServer(self.database, port=8080) + self.web_server.start_in_thread() + print("āœ… Web server started") + + self.loop = loop + print("āœ… Async components ready") + + def load_modules(self): + """Load all command modules""" + module_classes = [ + CoreCommands, + Exploration, + BattleSystem, + PetManagement, + Achievements, + Admin + ] + + self.modules = {} + self.command_map = {} + + for module_class in module_classes: + module_name = module_class.__name__ + print(f" Loading {module_name}...") + module_instance = module_class(self, self.database, self.game_engine) + self.modules[module_name] = module_instance + + # Map commands to modules + commands = module_instance.get_commands() + for command in commands: + self.command_map[command] = module_instance + print(f" āœ… {module_name}: {len(commands)} commands") + + print(f"āœ… Loaded {len(self.modules)} modules with {len(self.command_map)} commands") + + async def reload_modules(self): + """Reload all modules (for admin use)""" + try: + # Reload module files + import modules + importlib.reload(modules.core_commands) + importlib.reload(modules.exploration) + importlib.reload(modules.battle_system) + importlib.reload(modules.pet_management) + importlib.reload(modules.achievements) + importlib.reload(modules.admin) + importlib.reload(modules) + + # Reinitialize modules + print("šŸ”„ Reloading modules...") + self.load_modules() + print("āœ… Modules reloaded successfully") + return True + except Exception as e: + print(f"āŒ Module reload failed: {e}") + return False + + def test_connection(self): + """Test if we can connect to IRC""" + print("šŸ”„ Testing IRC connection...") + try: + test_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + test_sock.settimeout(5) + test_sock.connect((self.config["server"], self.config["port"])) + test_sock.close() + print("āœ… IRC connection test successful") + return True + except Exception as e: + print(f"āŒ IRC connection failed: {e}") + return False + + def connect(self): + print("šŸš€ Starting bot connection process...") + + print("šŸ”„ Step 1: Initialize async components...") + self.initialize_async_components() + print("āœ… Step 1 complete") + + print("šŸ”„ Step 2: Test IRC connectivity...") + if not self.test_connection(): + print("āŒ IRC connection test failed - would run offline mode") + print("āœ… Bot core systems are working - IRC connection unavailable") + return + print("āœ… Step 2 complete") + + print("šŸ”„ Step 3: Create IRC socket...") + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.settimeout(10) + print("āœ… Socket created") + + print("šŸ”„ Step 4: Connect to IRC server...") + try: + self.socket.connect((self.config["server"], self.config["port"])) + print("āœ… Connected to IRC server!") + except Exception as e: + print(f"āŒ Failed to connect: {e}") + return + + print("šŸ”„ Step 5: Send IRC handshake...") + self.send(f"NICK {self.config['nickname']}") + self.send(f"USER {self.config['nickname']} 0 * :{self.config['nickname']}") + print("āœ… Handshake sent") + + print("šŸ”„ Step 6: Start main loop...") + self.socket.settimeout(1) + print("āœ… Bot is now running!") + print(f"āœ… Will join {self.config['channel']} when connected") + print(f"āœ… Loaded commands: {', '.join(sorted(self.command_map.keys()))}") + print("šŸŽ® Ready for testing! Press Ctrl+C to stop.") + + self.main_loop() + + def main_loop(self): + message_count = 0 + while self.running: + try: + data = self.socket.recv(4096).decode('utf-8', errors='ignore') + if not data: + print("šŸ’€ Connection closed by server") + break + + lines = data.strip().split('\n') + for line in lines: + if line.strip(): + message_count += 1 + if message_count <= 10: # Show first 10 messages for debugging + print(f"šŸ“Ø {message_count}: {line.strip()}") + self.handle_line(line.strip()) + + except socket.timeout: + continue + except KeyboardInterrupt: + print("\nšŸ›‘ Shutting down bot...") + self.running = False + break + except Exception as e: + print(f"āŒ Error in main loop: {e}") + time.sleep(1) + + if self.socket: + self.socket.close() + self.loop.close() + print("āœ… Bot shutdown complete") + + def send(self, message): + if self.socket: + full_message = f"{message}\r\n" + print(f"šŸ“¤ >> {message}") + self.socket.send(full_message.encode('utf-8')) + + def handle_line(self, line): + if line.startswith("PING"): + pong_response = line.replace("PING", "PONG") + self.send(pong_response) + return + + # Handle connection messages + if "376" in line or "422" in line: # End of MOTD + if not self.connected: + self.send(f"JOIN {self.config['channel']}") + self.connected = True + print(f"šŸŽ‰ Joined {self.config['channel']} - Bot is ready for commands!") + return + + parts = line.split() + if len(parts) < 4: + return + + if parts[1] == "PRIVMSG": + channel = parts[2] + message = " ".join(parts[3:])[1:] + + hostmask = parts[0][1:] + nickname = hostmask.split('!')[0] + + if message.startswith(self.config["command_prefix"]): + print(f"šŸŽ® Command from {nickname}: {message}") + self.handle_command(channel, nickname, message) + + def handle_command(self, channel, nickname, message): + command_parts = message[1:].split() + if not command_parts: + return + + command = command_parts[0].lower() + args = command_parts[1:] + + try: + if command in self.command_map: + module = self.command_map[command] + print(f"šŸ”§ Executing {command} via {module.__class__.__name__}") + # Run async command handler + self.loop.run_until_complete( + module.handle_command(channel, nickname, command, args) + ) + else: + self.send_message(channel, f"{nickname}: Unknown command. Use !help for available commands.") + except Exception as e: + self.send_message(channel, f"{nickname}: Error processing command: {str(e)}") + print(f"āŒ Command error: {e}") + import traceback + traceback.print_exc() + + def send_message(self, target, message): + self.send(f"PRIVMSG {target} :{message}") + time.sleep(0.5) + + def run_async_command(self, coro): + return self.loop.run_until_complete(coro) + +if __name__ == "__main__": + print("🐾 Starting Pet Bot for IRC (Debug Mode)...") + bot = PetBotDebug() + try: + bot.connect() + except KeyboardInterrupt: + print("\nšŸ”„ Bot stopping...") + # Gracefully shutdown the game engine + try: + bot.loop.run_until_complete(bot.game_engine.shutdown()) + except Exception as e: + print(f"Error during shutdown: {e}") + print("āœ… Bot stopped by user") + except Exception as e: + print(f"āŒ Bot crashed: {e}") + import traceback + traceback.print_exc() \ No newline at end of file diff --git a/run_bot_original.py b/run_bot_original.py new file mode 100644 index 0000000..482f181 --- /dev/null +++ b/run_bot_original.py @@ -0,0 +1,633 @@ +#!/usr/bin/env python3 +import socket +import threading +import time +import json +import sys +import os +import asyncio + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from src.database import Database +from src.game_engine import GameEngine + +class PetBot: + def __init__(self): + self.database = Database() + self.game_engine = GameEngine(self.database) + self.config = { + "server": "irc.libera.chat", + "port": 6667, + "nickname": "PetBot", + "channel": "#petz", + "command_prefix": "!" + } + self.socket = None + self.connected = False + self.running = True + self.active_encounters = {} # player_id -> encounter_data + + def initialize_async_components(self): + """Initialize async components""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + print("Initializing database...") + loop.run_until_complete(self.database.init_database()) + print("Loading game data...") + loop.run_until_complete(self.game_engine.load_game_data()) + print("āœ“ Bot initialized successfully!") + + self.loop = loop + + def test_connection(self): + """Test if we can connect to IRC""" + print("Testing IRC connection...") + try: + test_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + test_sock.settimeout(5) + test_sock.connect((self.config["server"], self.config["port"])) + test_sock.close() + print("āœ“ IRC connection test successful") + return True + except Exception as e: + print(f"āœ— IRC connection failed: {e}") + return False + + def connect(self): + self.initialize_async_components() + + if not self.test_connection(): + print("\n=== IRC Connection Failed ===") + print("The bot's core functionality is working perfectly!") + print("Run these commands to test locally:") + print(" python3 test_commands.py - Test all functionality") + print(" python3 test_db.py - Test database operations") + print("\nTo connect to IRC:") + print("1. Ensure network access to irc.libera.chat:6667") + print("2. Or modify config in run_bot.py to use a different server") + return + + print(f"Connecting to {self.config['server']}:{self.config['port']}") + + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.settimeout(10) + + try: + self.socket.connect((self.config["server"], self.config["port"])) + print("āœ“ Connected to IRC server!") + except Exception as e: + print(f"āœ— Failed to connect: {e}") + return + + # Send IRC handshake + self.send(f"NICK {self.config['nickname']}") + self.send(f"USER {self.config['nickname']} 0 * :{self.config['nickname']}") + print("āœ“ Sent IRC handshake") + + # Start message processing + self.socket.settimeout(1) + self.main_loop() + + def main_loop(self): + print("āœ“ Bot is now running! Use Ctrl+C to stop.") + print(f"āœ“ Bot will join {self.config['channel']} when connected") + print("āœ“ Available commands: !help !start !catch !team !wild !stats") + + while self.running: + try: + data = self.socket.recv(4096).decode('utf-8', errors='ignore') + if not data: + print("Connection closed by server") + break + + lines = data.strip().split('\n') + for line in lines: + if line.strip(): + self.handle_line(line.strip()) + + except socket.timeout: + continue + except KeyboardInterrupt: + print("\nāœ“ Shutting down bot...") + self.running = False + break + except Exception as e: + print(f"Error in main loop: {e}") + time.sleep(1) + + if self.socket: + self.socket.close() + self.loop.close() + + def send(self, message): + if self.socket: + full_message = f"{message}\r\n" + print(f">> {message}") + self.socket.send(full_message.encode('utf-8')) + + def handle_line(self, line): + print(f"<< {line}") + + if line.startswith("PING"): + pong_response = line.replace("PING", "PONG") + self.send(pong_response) + return + + # Handle connection messages + if "376" in line or "422" in line: # End of MOTD + if not self.connected: + self.send(f"JOIN {self.config['channel']}") + self.connected = True + print(f"āœ“ Joined {self.config['channel']} - Bot is ready!") + return + + parts = line.split() + if len(parts) < 4: + return + + if parts[1] == "PRIVMSG": + channel = parts[2] + message = " ".join(parts[3:])[1:] + + hostmask = parts[0][1:] + nickname = hostmask.split('!')[0] + + if message.startswith(self.config["command_prefix"]): + print(f"Processing command from {nickname}: {message}") + self.handle_command(channel, nickname, message) + + def handle_command(self, channel, nickname, message): + command_parts = message[1:].split() + if not command_parts: + return + + command = command_parts[0].lower() + args = command_parts[1:] + + try: + if command == "help": + self.cmd_help(channel, nickname) + elif command == "start": + self.cmd_start(channel, nickname) + elif command == "explore": + self.cmd_explore(channel, nickname) + elif command == "catch" or command == "capture": + self.cmd_catch(channel, nickname) + elif command == "battle": + self.cmd_battle(channel, nickname) + elif command == "attack": + self.cmd_attack(channel, nickname, args) + elif command == "flee": + self.cmd_flee(channel, nickname) + elif command == "moves": + self.cmd_moves(channel, nickname) + elif command == "travel": + self.cmd_travel(channel, nickname, args) + elif command == "location" or command == "where": + self.cmd_location(channel, nickname) + elif command == "achievements": + self.cmd_achievements(channel, nickname) + elif command == "weather": + self.cmd_weather(channel, nickname) + elif command == "team": + self.cmd_team(channel, nickname) + elif command == "wild": + self.cmd_wild(channel, nickname, args) + elif command == "stats": + self.cmd_stats(channel, nickname, args) + else: + self.send_message(channel, f"{nickname}: Unknown command. Use !help for available commands.") + except Exception as e: + self.send_message(channel, f"{nickname}: Error processing command: {str(e)}") + print(f"Command error: {e}") + + def send_message(self, target, message): + self.send(f"PRIVMSG {target} :{message}") + time.sleep(0.5) + + def run_async_command(self, coro): + return self.loop.run_until_complete(coro) + + def cmd_help(self, channel, nickname): + help_text = [ + "🐾 Pet Bot Commands:", + "!start - Begin your pet journey in Starter Town", + "!explore - Explore your current location for wild pets", + "!catch (or !capture) - Try to catch a pet during exploration or battle", + "!battle - Start a battle with an encountered wild pet", + "!attack - Use a move in battle", + "!flee - Try to escape from battle", + "!moves - View your active pet's available moves", + "!travel - Travel to a different location", + "!location - See where you currently are", + "!achievements - View your achievements and progress", + "!weather - Check current weather effects", + "!team - View your active pets", + "!wild - See what pets spawn in a location", + "!stats - View your player stats", + "Locations: Starter Town, Whispering Woods, Electric Canyon, Crystal Caves, Frozen Tundra, Dragon's Peak" + ] + for line in help_text: + self.send_message(channel, line) + + def cmd_start(self, channel, nickname): + player = self.run_async_command(self.database.get_player(nickname)) + if player: + self.send_message(channel, f"{nickname}: You already have an account! Use !team to see your pets.") + return + + player_id = self.run_async_command(self.database.create_player(nickname)) + starter_pet = self.run_async_command(self.game_engine.give_starter_pet(player_id)) + + self.send_message(channel, + f"šŸŽ‰ {nickname}: Welcome to the world of pets! You received a Level {starter_pet['level']} {starter_pet['species_name']}! You are now in Starter Town.") + + def cmd_explore(self, channel, nickname): + player = self.run_async_command(self.database.get_player(nickname)) + if not player: + self.send_message(channel, f"{nickname}: Use !start to begin your journey first!") + return + + encounter = self.run_async_command(self.game_engine.explore_location(player["id"])) + + if encounter["type"] == "error": + self.send_message(channel, f"{nickname}: {encounter['message']}") + elif encounter["type"] == "empty": + self.send_message(channel, f"šŸ” {nickname}: {encounter['message']}") + elif encounter["type"] == "encounter": + # Store the encounter for potential catching + self.active_encounters[player["id"]] = encounter["pet"] + + pet = encounter["pet"] + type_str = pet["type1"] + if pet["type2"]: + type_str += f"/{pet['type2']}" + + self.send_message(channel, + f"🐾 {nickname}: A wild Level {pet['level']} {pet['species_name']} ({type_str}) appeared in {encounter['location']}!") + self.send_message(channel, f"Choose your action: !battle to fight it, or !catch to try catching it directly!") + + def cmd_battle(self, channel, nickname): + player = self.run_async_command(self.database.get_player(nickname)) + if not player: + self.send_message(channel, f"{nickname}: Use !start to begin your journey first!") + return + + # Check if player has an active encounter + if player["id"] not in self.active_encounters: + self.send_message(channel, f"{nickname}: You need to !explore first to find a pet to battle!") + return + + # Check if already in battle + active_battle = self.run_async_command(self.game_engine.battle_engine.get_active_battle(player["id"])) + if active_battle: + self.send_message(channel, f"{nickname}: You're already in battle! Use !attack or !flee.") + return + + # Get player's active pet + pets = self.run_async_command(self.database.get_player_pets(player["id"], active_only=True)) + if not pets: + self.send_message(channel, f"{nickname}: You need an active pet to battle! Use !team to check your pets.") + return + + player_pet = pets[0] # Use first active pet + wild_pet = self.active_encounters[player["id"]] + + # Start battle + battle = self.run_async_command(self.game_engine.battle_engine.start_battle(player["id"], player_pet, wild_pet)) + + self.send_message(channel, f"āš”ļø {nickname}: Battle started!") + self.send_message(channel, f"Your {player_pet['species_name']} (Lv.{player_pet['level']}, {battle['player_hp']}/{player_pet['max_hp']} HP) vs Wild {wild_pet['species_name']} (Lv.{wild_pet['level']}, {battle['wild_hp']}/{wild_pet['max_hp']} HP)") + + move_list = [move["name"] for move in battle["available_moves"]] + self.send_message(channel, f"Available moves: {', '.join(move_list)}") + self.send_message(channel, f"Use !attack to attack!") + + def cmd_attack(self, channel, nickname, args): + if not args: + self.send_message(channel, f"{nickname}: Specify a move to use! Example: !attack Tackle") + return + + player = self.run_async_command(self.database.get_player(nickname)) + if not player: + self.send_message(channel, f"{nickname}: Use !start to begin your journey first!") + return + + move_name = " ".join(args).title() # Normalize to Title Case + result = self.run_async_command(self.game_engine.battle_engine.execute_battle_turn(player["id"], move_name)) + + if "error" in result: + self.send_message(channel, f"{nickname}: {result['error']}") + return + + # Display battle results + for action in result["results"]: + damage_msg = f"{action['attacker']} used {action['move']}!" + self.send_message(channel, damage_msg) + + if action["damage"] > 0: + effectiveness_msgs = { + "super_effective": "It's super effective!", + "not_very_effective": "It's not very effective...", + "no_effect": "It had no effect!", + "super_effective_critical": "Critical hit! It's super effective!", + "normal_critical": "Critical hit!", + "not_very_effective_critical": "Critical hit! It's not very effective..." + } + + damage_text = f"Dealt {action['damage']} damage!" + if action["effectiveness"] in effectiveness_msgs: + damage_text += f" {effectiveness_msgs[action['effectiveness']]}" + + self.send_message(channel, damage_text) + self.send_message(channel, f"Target HP: {action['target_hp']}") + + if result["battle_over"]: + if result["winner"] == "player": + self.send_message(channel, f"šŸŽ‰ {nickname}: You won the battle!") + # Remove encounter since battle is over + if player["id"] in self.active_encounters: + del self.active_encounters[player["id"]] + else: + self.send_message(channel, f"šŸ’€ {nickname}: Your pet fainted! You lost the battle...") + # Remove encounter + if player["id"] in self.active_encounters: + del self.active_encounters[player["id"]] + else: + # Battle continues + move_list = [move["name"] for move in result["available_moves"]] + self.send_message(channel, f"Your turn! Available moves: {', '.join(move_list)}") + + def cmd_flee(self, channel, nickname): + player = self.run_async_command(self.database.get_player(nickname)) + if not player: + self.send_message(channel, f"{nickname}: Use !start to begin your journey first!") + return + + success = self.run_async_command(self.game_engine.battle_engine.flee_battle(player["id"])) + + if success: + self.send_message(channel, f"šŸ’Ø {nickname}: You successfully escaped from battle!") + # Remove encounter + if player["id"] in self.active_encounters: + del self.active_encounters[player["id"]] + else: + self.send_message(channel, f"āŒ {nickname}: Couldn't escape! The battle continues!") + + def cmd_moves(self, channel, nickname): + player = self.run_async_command(self.database.get_player(nickname)) + if not player: + self.send_message(channel, f"{nickname}: Use !start to begin your journey first!") + return + + # Get player's active pets + pets = self.run_async_command(self.database.get_player_pets(player["id"], active_only=True)) + if not pets: + self.send_message(channel, f"{nickname}: You don't have any active pets! Use !team to check your pets.") + return + + active_pet = pets[0] # Use first active pet + available_moves = self.game_engine.battle_engine.get_available_moves(active_pet) + + if not available_moves: + self.send_message(channel, f"{nickname}: Your {active_pet['species_name']} has no available moves!") + return + + # Limit to 4 moves max + moves_to_show = available_moves[:4] + + move_info = [] + for move in moves_to_show: + power_info = f"Power: {move.get('power', 'N/A')}" if move.get('power') else "Status move" + move_info.append(f"{move['name']} ({move['type']}) - {power_info}") + + pet_name = active_pet["nickname"] or active_pet["species_name"] + self.send_message(channel, f"šŸŽÆ {pet_name}'s moves:") + for move_line in move_info: + self.send_message(channel, f" • {move_line}") + + def cmd_catch(self, channel, nickname): + player = self.run_async_command(self.database.get_player(nickname)) + if not player: + self.send_message(channel, f"{nickname}: Use !start to begin your journey first!") + return + + # Check if player is in an active battle + active_battle = self.run_async_command(self.game_engine.battle_engine.get_active_battle(player["id"])) + if active_battle: + # Catching during battle + wild_pet = active_battle["wild_pet"] + current_hp = active_battle["wild_hp"] + max_hp = wild_pet["max_hp"] + + # Calculate catch rate based on remaining HP (lower HP = higher catch rate) + hp_percentage = current_hp / max_hp + base_catch_rate = 0.3 # Lower base rate than direct catch + hp_bonus = (1.0 - hp_percentage) * 0.5 # Up to 50% bonus for low HP + final_catch_rate = min(0.9, base_catch_rate + hp_bonus) # Cap at 90% + + import random + if random.random() < final_catch_rate: + # Successful catch during battle + result = self.run_async_command(self.game_engine.attempt_catch_current_location(player["id"], wild_pet)) + + # End the battle + await_result = self.run_async_command(self.game_engine.battle_engine.end_battle(player["id"], "caught")) + + # Check for achievements + achievements = self.run_async_command(self.game_engine.check_and_award_achievements(player["id"], "catch_type", "")) + if achievements: + for achievement in achievements: + self.send_message(channel, f"šŸ† {nickname}: Achievement unlocked: {achievement['name']}! {achievement['description']}") + + # Remove encounter + if player["id"] in self.active_encounters: + del self.active_encounters[player["id"]] + + self.send_message(channel, f"šŸŽÆ {nickname}: Caught the {wild_pet['species_name']} during battle! (HP: {current_hp}/{max_hp})") + else: + # Failed catch - battle continues + catch_percentage = int(final_catch_rate * 100) + self.send_message(channel, f"šŸŽÆ {nickname}: The {wild_pet['species_name']} broke free! ({catch_percentage}% catch rate) Battle continues!") + return + + # Regular exploration catch (not in battle) + if player["id"] not in self.active_encounters: + self.send_message(channel, f"{nickname}: You need to !explore first to find a pet, or be in battle to catch!") + return + + target_pet = self.active_encounters[player["id"]] + result = self.run_async_command(self.game_engine.attempt_catch_current_location(player["id"], target_pet)) + + # Check for achievements after successful catch + if "Success!" in result: + achievements = self.run_async_command(self.game_engine.check_and_award_achievements(player["id"], "catch_type", "")) + if achievements: + for achievement in achievements: + self.send_message(channel, f"šŸ† {nickname}: Achievement unlocked: {achievement['name']}! {achievement['description']}") + + # Remove the encounter regardless of success + del self.active_encounters[player["id"]] + + self.send_message(channel, f"šŸŽÆ {nickname}: {result}") + + def cmd_travel(self, channel, nickname, args): + if not args: + self.send_message(channel, f"{nickname}: Specify where to travel! Available: Starter Town, Whispering Woods, Electric Canyon, Crystal Caves, Frozen Tundra, Dragon's Peak") + return + + player = self.run_async_command(self.database.get_player(nickname)) + if not player: + self.send_message(channel, f"{nickname}: Use !start to begin your journey first!") + return + + destination = " ".join(args).title() # Normalize to Title Case + location = self.run_async_command(self.database.get_location_by_name(destination)) + + if not location: + self.send_message(channel, f"{nickname}: '{destination}' is not a valid location!") + return + + # Check if player can access this location + can_access = self.run_async_command(self.database.can_access_location(player["id"], location["id"])) + if not can_access: + self.send_message(channel, f"{nickname}: You cannot access {destination} yet! Complete achievements to unlock new areas. Use !achievements to see progress.") + return + + # Clear any active encounters when traveling + if player["id"] in self.active_encounters: + del self.active_encounters[player["id"]] + + self.run_async_command(self.database.update_player_location(player["id"], location["id"])) + + # Show weather info + weather = self.run_async_command(self.database.get_location_weather(location["id"])) + weather_msg = "" + if weather: + weather_patterns = getattr(self.game_engine, 'weather_patterns', {}) + weather_info = weather_patterns.get("weather_types", {}).get(weather["weather_type"], {}) + weather_desc = weather_info.get("description", f"{weather['weather_type']} weather") + weather_msg = f" Weather: {weather['weather_type']} - {weather_desc}" + + self.send_message(channel, f"šŸ—ŗļø {nickname}: You traveled to {destination}. {location['description']}{weather_msg}") + + def cmd_location(self, channel, nickname): + player = self.run_async_command(self.database.get_player(nickname)) + if not player: + self.send_message(channel, f"{nickname}: Use !start to begin your journey first!") + return + + location = self.run_async_command(self.database.get_player_location(player["id"])) + if location: + self.send_message(channel, f"šŸ“ {nickname}: You are currently in {location['name']}. {location['description']}") + else: + self.send_message(channel, f"{nickname}: You seem to be lost! Contact an admin.") + + def cmd_achievements(self, channel, nickname): + player = self.run_async_command(self.database.get_player(nickname)) + if not player: + self.send_message(channel, f"{nickname}: Use !start to begin your journey first!") + return + + achievements = self.run_async_command(self.database.get_player_achievements(player["id"])) + + if achievements: + self.send_message(channel, f"šŸ† {nickname}'s Achievements:") + for achievement in achievements[:5]: # Show last 5 achievements + self.send_message(channel, f"• {achievement['name']}: {achievement['description']}") + + if len(achievements) > 5: + self.send_message(channel, f"... and {len(achievements) - 5} more!") + else: + self.send_message(channel, f"{nickname}: No achievements yet! Keep exploring and catching pets to unlock new areas!") + + def cmd_weather(self, channel, nickname): + player = self.run_async_command(self.database.get_player(nickname)) + if not player: + self.send_message(channel, f"{nickname}: Use !start to begin your journey first!") + return + + location = self.run_async_command(self.database.get_player_location(player["id"])) + if not location: + self.send_message(channel, f"{nickname}: You seem to be lost!") + return + + weather = self.run_async_command(self.database.get_location_weather(location["id"])) + if weather: + weather_patterns = getattr(self.game_engine, 'weather_patterns', {}) + weather_info = weather_patterns.get("weather_types", {}).get(weather["weather_type"], {}) + weather_desc = weather_info.get("description", f"{weather['weather_type']} weather") + + self.send_message(channel, f"šŸŒ¤ļø {nickname}: Current weather in {location['name']}: {weather['weather_type']}") + self.send_message(channel, f"Effect: {weather_desc}") + else: + self.send_message(channel, f"šŸŒ¤ļø {nickname}: The weather in {location['name']} is calm with no special effects.") + + def cmd_team(self, channel, nickname): + player = self.run_async_command(self.database.get_player(nickname)) + if not player: + self.send_message(channel, f"{nickname}: Use !start to begin your journey first!") + return + + pets = self.run_async_command(self.database.get_player_pets(player["id"], active_only=False)) + if not pets: + self.send_message(channel, f"{nickname}: You don't have any pets! Use !catch to find some.") + return + + # Show active pets first, then others + active_pets = [pet for pet in pets if pet.get("is_active")] + inactive_pets = [pet for pet in pets if not pet.get("is_active")] + + team_info = [] + + # Active pets with star + for pet in active_pets: + name = pet["nickname"] or pet["species_name"] + team_info.append(f"⭐{name} (Lv.{pet['level']}) - {pet['hp']}/{pet['max_hp']} HP") + + # Inactive pets + for pet in inactive_pets[:5]: # Show max 5 inactive + name = pet["nickname"] or pet["species_name"] + team_info.append(f"{name} (Lv.{pet['level']}) - {pet['hp']}/{pet['max_hp']} HP") + + if len(inactive_pets) > 5: + team_info.append(f"... and {len(inactive_pets) - 5} more in storage") + + self.send_message(channel, f"🐾 {nickname}'s team: " + " | ".join(team_info)) + + def cmd_wild(self, channel, nickname, args): + if not args: + self.send_message(channel, f"{nickname}: Specify a location! Available: Starter Town, Whispering Woods, Electric Canyon, Crystal Caves, Frozen Tundra, Dragon's Peak") + return + + location_name = " ".join(args).title() # Normalize to Title Case + wild_pets = self.run_async_command(self.game_engine.get_location_spawns(location_name)) + + if wild_pets: + pet_list = ", ".join([pet["name"] for pet in wild_pets]) + self.send_message(channel, f"🌿 Wild pets in {location_name}: {pet_list}") + else: + self.send_message(channel, f"{nickname}: No location found called '{location_name}'") + + def cmd_stats(self, channel, nickname, args): + player = self.run_async_command(self.database.get_player(nickname)) + if not player: + self.send_message(channel, f"{nickname}: Use !start to begin your journey first!") + return + + self.send_message(channel, + f"šŸ“Š {nickname}: Level {player['level']} | {player['experience']} XP | ${player['money']}") + +if __name__ == "__main__": + print("🐾 Starting Pet Bot for IRC...") + bot = PetBot() + try: + bot.connect() + except KeyboardInterrupt: + print("\nāœ“ Bot stopped by user") + except Exception as e: + print(f"āœ— Bot crashed: {e}") + import traceback + traceback.print_exc() \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/battle_engine.py b/src/battle_engine.py new file mode 100644 index 0000000..630fcd3 --- /dev/null +++ b/src/battle_engine.py @@ -0,0 +1,350 @@ +import json +import random +import aiosqlite +from typing import Dict, List, Optional, Tuple +from .database import Database + +class BattleEngine: + def __init__(self, database: Database): + self.database = database + self.type_effectiveness = {} + self.moves_data = {} + + async def load_battle_data(self): + """Load moves and type effectiveness data""" + await self.load_moves() + await self.load_type_effectiveness() + + async def load_moves(self): + """Load moves from config""" + try: + with open("config/moves.json", "r") as f: + moves_data = json.load(f) + + # Store moves by name for quick lookup + for move in moves_data: + self.moves_data[move["name"]] = move + + # Load into database + async with aiosqlite.connect(self.database.db_path) as db: + for move in moves_data: + await db.execute(""" + INSERT OR REPLACE INTO moves + (name, type, category, power, accuracy, pp, description) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + move["name"], move["type"], move["category"], + move.get("power"), move.get("accuracy", 100), + move.get("pp", 10), move.get("description", "") + )) + await db.commit() + + except FileNotFoundError: + print("No moves.json found, using basic moves") + self.moves_data = { + "Tackle": {"name": "Tackle", "type": "Normal", "category": "Physical", "power": 40, "accuracy": 100} + } + + async def load_type_effectiveness(self): + """Load type effectiveness chart""" + try: + with open("config/types.json", "r") as f: + types_data = json.load(f) + self.type_effectiveness = types_data.get("effectiveness", {}) + except FileNotFoundError: + # Basic type chart + self.type_effectiveness = { + "Fire": {"strong_against": ["Grass"], "weak_against": ["Water", "Rock"]}, + "Water": {"strong_against": ["Fire", "Rock"], "weak_against": ["Electric", "Grass"]}, + "Grass": {"strong_against": ["Water", "Rock"], "weak_against": ["Fire"]}, + "Electric": {"strong_against": ["Water"], "weak_against": ["Rock"]}, + "Rock": {"strong_against": ["Fire", "Electric"], "weak_against": ["Water", "Grass"]}, + "Normal": {"strong_against": [], "weak_against": ["Rock"]} + } + + def get_type_multiplier(self, attack_type: str, defending_types: List[str]) -> float: + """Calculate type effectiveness multiplier""" + multiplier = 1.0 + + for defending_type in defending_types: + if attack_type in self.type_effectiveness: + effectiveness = self.type_effectiveness[attack_type] + + if defending_type in effectiveness.get("strong_against", []): + multiplier *= 2.0 # Super effective + elif defending_type in effectiveness.get("weak_against", []): + multiplier *= 0.5 # Not very effective + elif defending_type in effectiveness.get("immune_to", []): + multiplier *= 0.0 # No effect + + return multiplier + + def calculate_damage(self, attacker: Dict, defender: Dict, move: Dict) -> Tuple[int, float, str]: + """Calculate damage from an attack""" + if move.get("category") == "Status": + return 0, 1.0, "status" + + # Base damage calculation (simplified PokĆ©mon formula) + level = attacker.get("level", 1) + attack_stat = attacker.get("attack", 50) + defense_stat = defender.get("defense", 50) + move_power = move.get("power", 40) + + # Get defending types + defending_types = [defender.get("type1")] + if defender.get("type2"): + defending_types.append(defender["type2"]) + + # Type effectiveness + type_multiplier = self.get_type_multiplier(move["type"], defending_types) + + # Critical hit chance (6.25%) + critical = random.random() < 0.0625 + crit_multiplier = 1.5 if critical else 1.0 + + # Random factor (85-100%) + random_factor = random.uniform(0.85, 1.0) + + # STAB (Same Type Attack Bonus) + stab = 1.5 if move["type"] in [attacker.get("type1"), attacker.get("type2")] else 1.0 + + # Calculate final damage + damage = ((((2 * level + 10) / 250) * (attack_stat / defense_stat) * move_power + 2) + * type_multiplier * stab * crit_multiplier * random_factor) + + damage = max(1, int(damage)) # Minimum 1 damage + + # Determine effectiveness message + effectiveness_msg = "" + if type_multiplier > 1.0: + effectiveness_msg = "super_effective" + elif type_multiplier < 1.0: + effectiveness_msg = "not_very_effective" + elif type_multiplier == 0.0: + effectiveness_msg = "no_effect" + else: + effectiveness_msg = "normal" + + if critical: + effectiveness_msg += "_critical" + + return damage, type_multiplier, effectiveness_msg + + def get_available_moves(self, pet: Dict) -> List[Dict]: + """Get available moves for a pet (simplified)""" + # For now, all pets have basic moves based on their type + pet_type = pet.get("type1", "Normal") + + basic_moves = { + "Fire": ["Tackle", "Ember"], + "Water": ["Tackle", "Water Gun"], + "Grass": ["Tackle", "Vine Whip"], + "Electric": ["Tackle", "Thunder Shock"], + "Rock": ["Tackle", "Rock Throw"], + "Normal": ["Tackle"], + "Ice": ["Tackle", "Water Gun"] # Add Ice type support + } + + move_names = basic_moves.get(pet_type, ["Tackle"]) + available_moves = [] + for name in move_names: + if name in self.moves_data: + available_moves.append(self.moves_data[name]) + else: + # Fallback to Tackle if move not found + available_moves.append(self.moves_data.get("Tackle", {"name": "Tackle", "type": "Normal", "category": "Physical", "power": 40})) + + return available_moves + + async def start_battle(self, player_id: int, player_pet: Dict, wild_pet: Dict) -> Dict: + """Start a new battle""" + wild_pet_json = json.dumps(wild_pet) + + async with aiosqlite.connect(self.database.db_path) as db: + cursor = await db.execute(""" + INSERT INTO active_battles + (player_id, wild_pet_data, player_pet_id, player_hp, wild_hp) + VALUES (?, ?, ?, ?, ?) + """, ( + player_id, wild_pet_json, player_pet["id"], + player_pet["hp"], wild_pet["stats"]["hp"] + )) + + battle_id = cursor.lastrowid + await db.commit() + + return { + "battle_id": battle_id, + "player_pet": player_pet, + "wild_pet": wild_pet, + "player_hp": player_pet["hp"], + "wild_hp": wild_pet["stats"]["hp"], + "turn": "player", + "available_moves": self.get_available_moves(player_pet) + } + + async def get_active_battle(self, player_id: int) -> Optional[Dict]: + """Get player's active battle""" + async with aiosqlite.connect(self.database.db_path) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute(""" + SELECT * FROM active_battles + WHERE player_id = ? AND battle_status = 'active' + ORDER BY id DESC LIMIT 1 + """, (player_id,)) + + battle = await cursor.fetchone() + if not battle: + return None + + # Get player pet data + cursor = await db.execute(""" + 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.id = ? + """, (battle["player_pet_id"],)) + + player_pet = await cursor.fetchone() + if not player_pet: + return None + + return { + "battle_id": battle["id"], + "player_pet": dict(player_pet), + "wild_pet": json.loads(battle["wild_pet_data"]), + "player_hp": battle["player_hp"], + "wild_hp": battle["wild_hp"], + "turn": battle["current_turn"], + "turn_count": battle["turn_count"], + "available_moves": self.get_available_moves(dict(player_pet)) + } + + async def execute_battle_turn(self, player_id: int, player_move: str) -> Dict: + """Execute a battle turn""" + battle = await self.get_active_battle(player_id) + if not battle: + return {"error": "No active battle"} + + if battle["turn"] != "player": + return {"error": "Not your turn"} + + # Get move data + move = self.moves_data.get(player_move) + if not move: + return {"error": "Invalid move"} + + results = [] + battle_over = False + winner = None + + # Player's turn + damage, multiplier, effectiveness = self.calculate_damage( + battle["player_pet"], battle["wild_pet"], move + ) + + new_wild_hp = max(0, battle["wild_hp"] - damage) + + results.append({ + "attacker": battle["player_pet"]["species_name"], + "move": move["name"], + "damage": damage, + "effectiveness": effectiveness, + "target_hp": new_wild_hp + }) + + # Check if wild pet fainted + if new_wild_hp <= 0: + battle_over = True + winner = "player" + else: + # Wild pet's turn (AI) + wild_moves = self.get_available_moves(battle["wild_pet"]) + wild_move = random.choice(wild_moves) + + wild_damage, wild_multiplier, wild_effectiveness = self.calculate_damage( + battle["wild_pet"], battle["player_pet"], wild_move + ) + + new_player_hp = max(0, battle["player_hp"] - wild_damage) + + results.append({ + "attacker": battle["wild_pet"]["species_name"], + "move": wild_move["name"], + "damage": wild_damage, + "effectiveness": wild_effectiveness, + "target_hp": new_player_hp + }) + + # Check if player pet fainted + if new_player_hp <= 0: + battle_over = True + winner = "wild" + + battle["player_hp"] = new_player_hp + + battle["wild_hp"] = new_wild_hp + battle["turn_count"] += 1 + + # Update battle in database + async with aiosqlite.connect(self.database.db_path) as db: + if battle_over: + await db.execute(""" + UPDATE active_battles + SET player_hp = ?, wild_hp = ?, turn_count = ?, battle_status = 'finished' + WHERE id = ? + """, (battle["player_hp"], battle["wild_hp"], battle["turn_count"], battle["battle_id"])) + else: + await db.execute(""" + UPDATE active_battles + SET player_hp = ?, wild_hp = ?, turn_count = ? + WHERE id = ? + """, (battle["player_hp"], battle["wild_hp"], battle["turn_count"], battle["battle_id"])) + + await db.commit() + + return { + "results": results, + "battle_over": battle_over, + "winner": winner, + "player_hp": battle["player_hp"], + "wild_hp": battle["wild_hp"], + "available_moves": battle["available_moves"] if not battle_over else [] + } + + async def flee_battle(self, player_id: int) -> bool: + """Attempt to flee from battle""" + battle = await self.get_active_battle(player_id) + if not battle: + return False + + # 50% base flee chance, higher if player pet is faster + player_speed = battle["player_pet"].get("speed", 50) + wild_speed = battle["wild_pet"]["stats"].get("speed", 50) + + flee_chance = 0.5 + (player_speed - wild_speed) * 0.01 + flee_chance = max(0.1, min(0.9, flee_chance)) # Clamp between 10% and 90% + + if random.random() < flee_chance: + # Successful flee + async with aiosqlite.connect(self.database.db_path) as db: + await db.execute(""" + UPDATE active_battles + SET battle_status = 'fled' + WHERE id = ? + """, (battle["battle_id"],)) + await db.commit() + return True + + return False + + async def end_battle(self, player_id: int, reason: str = "manual"): + """End an active battle""" + async with aiosqlite.connect(self.database.db_path) as db: + await db.execute(""" + UPDATE active_battles + SET battle_status = ? + WHERE player_id = ? AND battle_status = 'active' + """, (reason, player_id)) + await db.commit() + return True \ No newline at end of file diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..f69e25e --- /dev/null +++ b/src/bot.py @@ -0,0 +1,191 @@ +import asyncio +import irc.client_aio +import irc.connection +import threading +from typing import Dict, List, Optional +import json +import random +from datetime import datetime +from .database import Database +from .game_engine import GameEngine + +class PetBot: + def __init__(self, config_path: str = "config/settings.json"): + self.config = self.load_config(config_path) + self.database = Database() + self.game_engine = GameEngine(self.database) + self.client = None + self.connection = None + + def load_config(self, config_path: str) -> Dict: + try: + with open(config_path, 'r') as f: + return json.load(f) + except FileNotFoundError: + return { + "server": "irc.libera.chat", + "port": 6667, + "nickname": "PetBot", + "channel": "#petz", + "command_prefix": "!" + } + + async def start(self): + await self.database.init_database() + await self.game_engine.load_game_data() + + self.client = irc.client_aio.AioReactor() + + try: + self.connection = await self.client.server().connect( + self.config["server"], + self.config["port"], + self.config["nickname"] + ) + except irc.client.ServerConnectionError: + print(f"Could not connect to {self.config['server']}:{self.config['port']}") + return + + self.connection.add_global_handler("welcome", self.on_connect) + self.connection.add_global_handler("pubmsg", self.on_message) + self.connection.add_global_handler("privmsg", self.on_private_message) + + await self.client.process_forever(timeout=0.2) + + def on_connect(self, connection, event): + connection.join(self.config["channel"]) + print(f"Connected to {self.config['channel']}") + + async def on_message(self, connection, event): + message = event.arguments[0] + nickname = event.source.nick + channel = event.target + + if message.startswith(self.config["command_prefix"]): + await self.handle_command(connection, channel, nickname, message) + + async def on_private_message(self, connection, event): + message = event.arguments[0] + nickname = event.source.nick + + if message.startswith(self.config["command_prefix"]): + await self.handle_command(connection, nickname, nickname, message) + + async def handle_command(self, connection, target, nickname, message): + command_parts = message[1:].split() + if not command_parts: + return + + command = command_parts[0].lower() + args = command_parts[1:] + + try: + if command == "help": + await self.cmd_help(connection, target, nickname) + elif command == "start": + await self.cmd_start(connection, target, nickname) + elif command == "catch": + await self.cmd_catch(connection, target, nickname, args) + elif command == "team": + await self.cmd_team(connection, target, nickname) + elif command == "wild": + await self.cmd_wild(connection, target, nickname, args) + elif command == "battle": + await self.cmd_battle(connection, target, nickname, args) + elif command == "stats": + await self.cmd_stats(connection, target, nickname, args) + else: + await self.send_message(connection, target, f"{nickname}: Unknown command. Use !help for available commands.") + except Exception as e: + await self.send_message(connection, target, f"{nickname}: Error processing command: {str(e)}") + + async def send_message(self, connection, target, message): + connection.privmsg(target, message) + await asyncio.sleep(0.5) + + async def cmd_help(self, connection, target, nickname): + help_text = [ + "Available commands:", + "!start - Begin your pet journey", + "!catch - Try to catch a pet in a location", + "!team - View your active pets", + "!wild - See what pets are in an area", + "!battle - Challenge another player", + "!stats [pet_name] - View pet or player stats" + ] + for line in help_text: + await self.send_message(connection, target, line) + + async def cmd_start(self, connection, target, nickname): + player = await self.database.get_player(nickname) + if player: + await self.send_message(connection, target, f"{nickname}: You already have an account! Use !team to see your pets.") + return + + player_id = await self.database.create_player(nickname) + starter_pet = await self.game_engine.give_starter_pet(player_id) + + await self.send_message(connection, target, + f"{nickname}: Welcome to the world of pets! You received a {starter_pet['species_name']}!") + + async def cmd_catch(self, connection, target, nickname, args): + if not args: + await self.send_message(connection, target, f"{nickname}: Specify a location to catch pets in!") + return + + location_name = " ".join(args) + player = await self.database.get_player(nickname) + if not player: + await self.send_message(connection, target, f"{nickname}: Use !start to begin your journey first!") + return + + result = await self.game_engine.attempt_catch(player["id"], location_name) + await self.send_message(connection, target, f"{nickname}: {result}") + + async def cmd_team(self, connection, target, nickname): + player = await self.database.get_player(nickname) + if not player: + await self.send_message(connection, target, f"{nickname}: Use !start to begin your journey first!") + return + + pets = await self.database.get_player_pets(player["id"], active_only=True) + if not pets: + await self.send_message(connection, target, f"{nickname}: You don't have any active pets! Use !catch to find some.") + return + + team_info = [] + for pet in pets: + name = pet["nickname"] or pet["species_name"] + team_info.append(f"{name} (Lv.{pet['level']}) - {pet['hp']}/{pet['max_hp']} HP") + + await self.send_message(connection, target, f"{nickname}'s team: " + " | ".join(team_info)) + + async def cmd_wild(self, connection, target, nickname, args): + if not args: + await self.send_message(connection, target, f"{nickname}: Specify a location to explore!") + return + + location_name = " ".join(args) + wild_pets = await self.game_engine.get_location_spawns(location_name) + + if wild_pets: + pet_list = ", ".join([pet["name"] for pet in wild_pets]) + await self.send_message(connection, target, f"Wild pets in {location_name}: {pet_list}") + else: + await self.send_message(connection, target, f"{nickname}: No location found called '{location_name}'") + + async def cmd_battle(self, connection, target, nickname, args): + await self.send_message(connection, target, f"{nickname}: Battle system coming soon!") + + async def cmd_stats(self, connection, target, nickname, args): + player = await self.database.get_player(nickname) + if not player: + await self.send_message(connection, target, f"{nickname}: Use !start to begin your journey first!") + return + + await self.send_message(connection, target, + f"{nickname}: Level {player['level']} | {player['experience']} XP | ${player['money']}") + +if __name__ == "__main__": + bot = PetBot() + asyncio.run(bot.start()) \ No newline at end of file diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..e2d0023 --- /dev/null +++ b/src/database.py @@ -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" + } \ No newline at end of file diff --git a/src/game_engine.py b/src/game_engine.py new file mode 100644 index 0000000..8ec59ae --- /dev/null +++ b/src/game_engine.py @@ -0,0 +1,596 @@ +import json +import random +import aiosqlite +import asyncio +from typing import Dict, List, Optional +from .database import Database +from .battle_engine import BattleEngine + +class GameEngine: + def __init__(self, database: Database): + self.database = database + self.battle_engine = BattleEngine(database) + self.pet_species = {} + self.locations = {} + self.moves = {} + self.type_chart = {} + self.weather_patterns = {} + self.weather_task = None + self.shutdown_event = asyncio.Event() + + async def load_game_data(self): + await self.load_pet_species() + await self.load_locations() + await self.load_moves() + await self.load_type_chart() + await self.load_achievements() + await self.init_weather_system() + await self.battle_engine.load_battle_data() + + async def load_pet_species(self): + try: + with open("config/pets.json", "r") as f: + species_data = json.load(f) + + async with aiosqlite.connect(self.database.db_path) as db: + for species in species_data: + await db.execute(""" + INSERT OR IGNORE INTO pet_species + (name, type1, type2, base_hp, base_attack, base_defense, + base_speed, evolution_level, rarity) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + species["name"], species["type1"], species.get("type2"), + species["base_hp"], species["base_attack"], species["base_defense"], + species["base_speed"], species.get("evolution_level"), + species.get("rarity", 1) + )) + await db.commit() + + except FileNotFoundError: + await self.create_default_species() + + async def create_default_species(self): + default_species = [ + { + "name": "Flamey", + "type1": "Fire", + "base_hp": 45, + "base_attack": 52, + "base_defense": 43, + "base_speed": 65, + "rarity": 1 + }, + { + "name": "Aqua", + "type1": "Water", + "base_hp": 44, + "base_attack": 48, + "base_defense": 65, + "base_speed": 43, + "rarity": 1 + }, + { + "name": "Leafy", + "type1": "Grass", + "base_hp": 45, + "base_attack": 49, + "base_defense": 49, + "base_speed": 45, + "rarity": 1 + }, + { + "name": "Sparky", + "type1": "Electric", + "base_hp": 35, + "base_attack": 55, + "base_defense": 40, + "base_speed": 90, + "rarity": 2 + }, + { + "name": "Rocky", + "type1": "Rock", + "base_hp": 40, + "base_attack": 80, + "base_defense": 100, + "base_speed": 25, + "rarity": 2 + } + ] + + async with aiosqlite.connect(self.database.db_path) as db: + for species in default_species: + await db.execute(""" + INSERT OR IGNORE INTO pet_species + (name, type1, type2, base_hp, base_attack, base_defense, base_speed, rarity) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + species["name"], species["type1"], species.get("type2"), + species["base_hp"], species["base_attack"], species["base_defense"], + species["base_speed"], species["rarity"] + )) + await db.commit() + + async def load_locations(self): + try: + with open("config/locations.json", "r") as f: + locations_data = json.load(f) + except FileNotFoundError: + locations_data = self.get_default_locations() + + async with aiosqlite.connect(self.database.db_path) as db: + for location in locations_data: + await db.execute(""" + INSERT OR IGNORE INTO locations (name, description, level_min, level_max) + VALUES (?, ?, ?, ?) + """, (location["name"], location["description"], + location["level_min"], location["level_max"])) + + cursor = await db.execute( + "SELECT id FROM locations WHERE name = ?", (location["name"],) + ) + location_row = await cursor.fetchone() + location_id = location_row[0] + + for spawn in location["spawns"]: + species_cursor = await db.execute( + "SELECT id FROM pet_species WHERE name = ?", (spawn["species"],) + ) + species_id = await species_cursor.fetchone() + + if species_id: + await db.execute(""" + INSERT OR IGNORE INTO location_spawns + (location_id, species_id, spawn_rate, min_level, max_level) + VALUES (?, ?, ?, ?, ?) + """, (location_id, species_id[0], spawn["spawn_rate"], + spawn["min_level"], spawn["max_level"])) + await db.commit() + + def get_default_locations(self): + return [ + { + "name": "Starter Woods", + "description": "A peaceful forest perfect for beginners", + "level_min": 1, + "level_max": 5, + "spawns": [ + {"species": "Leafy", "spawn_rate": 0.4, "min_level": 1, "max_level": 3}, + {"species": "Flamey", "spawn_rate": 0.3, "min_level": 1, "max_level": 3}, + {"species": "Aqua", "spawn_rate": 0.3, "min_level": 1, "max_level": 3} + ] + }, + { + "name": "Electric Canyon", + "description": "A charged valley crackling with energy", + "level_min": 3, + "level_max": 8, + "spawns": [ + {"species": "Sparky", "spawn_rate": 0.6, "min_level": 3, "max_level": 6}, + {"species": "Rocky", "spawn_rate": 0.4, "min_level": 4, "max_level": 7} + ] + } + ] + + async def load_moves(self): + pass + + async def load_type_chart(self): + pass + + async def give_starter_pet(self, player_id: int) -> Dict: + starters = ["Flamey", "Aqua", "Leafy"] + chosen_starter = random.choice(starters) + + async with aiosqlite.connect(self.database.db_path) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM pet_species WHERE name = ?", (chosen_starter,) + ) + species = await cursor.fetchone() + + pet_data = self.generate_pet_stats(dict(species), level=5) + + cursor = await db.execute(""" + INSERT INTO pets (player_id, species_id, level, experience, hp, max_hp, + attack, defense, speed, is_active) + 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)) + + 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) + + 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 + + return { + "level": level, + "hp": hp, + "attack": attack, + "defense": defense, + "speed": speed + } + + async def attempt_catch(self, player_id: int, location_name: str) -> str: + async with aiosqlite.connect(self.database.db_path) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM locations WHERE name = ?", (location_name,) + ) + location = await cursor.fetchone() + + if not location: + return f"Location '{location_name}' not found!" + + cursor = await db.execute(""" + SELECT ls.*, ps.name as species_name, ps.* + FROM location_spawns ls + JOIN pet_species ps ON ls.species_id = ps.id + WHERE ls.location_id = ? + """, (location["id"],)) + spawns = await cursor.fetchall() + + if not spawns: + return f"No pets found in {location_name}!" + + if random.random() > 0.7: + return f"You searched {location_name} but found nothing..." + + chosen_spawn = random.choices( + spawns, + weights=[spawn["spawn_rate"] for spawn in spawns] + )[0] + + pet_level = random.randint(chosen_spawn["min_level"], chosen_spawn["max_level"]) + pet_stats = self.generate_pet_stats(dict(chosen_spawn), pet_level) + + catch_rate = 0.5 + (0.3 / chosen_spawn["rarity"]) + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (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)) + + await db.commit() + return f"Caught a level {pet_level} {chosen_spawn['species_name']}!" + else: + return f"A wild {chosen_spawn['species_name']} appeared but escaped!" + + async def get_location_spawns(self, location_name: str) -> List[Dict]: + async with aiosqlite.connect(self.database.db_path) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM locations WHERE name = ?", (location_name,) + ) + location = await cursor.fetchone() + + if not location: + return [] + + cursor = await db.execute(""" + SELECT ps.name, ps.type1, ps.type2, ls.spawn_rate + FROM location_spawns ls + JOIN pet_species ps ON ls.species_id = ps.id + WHERE ls.location_id = ? + """, (location["id"],)) + spawns = await cursor.fetchall() + + return [dict(spawn) for spawn in spawns] + + async def explore_location(self, player_id: int) -> Dict: + """Explore the player's current location and return encounter results""" + async with aiosqlite.connect(self.database.db_path) as db: + db.row_factory = aiosqlite.Row + + # Get player's current location + location = await self.database.get_player_location(player_id) + if not location: + return {"type": "error", "message": "You are not in a valid location!"} + + # Get spawns for current location + cursor = await db.execute(""" + SELECT ls.*, ps.name as species_name, ps.* + FROM location_spawns ls + JOIN pet_species ps ON ls.species_id = ps.id + WHERE ls.location_id = ? + """, (location["id"],)) + spawns = await cursor.fetchall() + + if not spawns: + return {"type": "empty", "message": f"You explore {location['name']} but find nothing interesting..."} + + # Apply weather modifiers to spawns + modified_spawns = await self.get_weather_modified_spawns(location["id"], spawns) + + # Random encounter chance (70% chance of finding something) + if random.random() > 0.7: + return {"type": "empty", "message": f"You explore {location['name']} but find nothing this time..."} + + # Choose random spawn with weather-modified rates + chosen_spawn = random.choices( + modified_spawns, + weights=[spawn["spawn_rate"] for spawn in modified_spawns] + )[0] + + pet_level = random.randint(chosen_spawn["min_level"], chosen_spawn["max_level"]) + + pet_stats = self.generate_pet_stats(dict(chosen_spawn), pet_level) + + return { + "type": "encounter", + "location": location["name"], + "pet": { + "species_id": chosen_spawn["species_id"], + "species_name": chosen_spawn["species_name"], + "level": pet_level, + "type1": chosen_spawn["type1"], + "type2": chosen_spawn["type2"], + "stats": pet_stats, + # Additional battle-ready stats + "hp": pet_stats["hp"], + "max_hp": pet_stats["hp"], + "attack": pet_stats["attack"], + "defense": pet_stats["defense"], + "speed": pet_stats["speed"] + } + } + + async def attempt_catch_current_location(self, player_id: int, target_pet: Dict) -> str: + """Attempt to catch a pet during exploration""" + catch_rate = 0.5 + (0.3 / target_pet.get("rarity", 1)) + + if random.random() < catch_rate: + # Successfully caught the pet + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (player_id, target_pet["species_id"], target_pet["level"], 0, + target_pet["stats"]["hp"], target_pet["stats"]["hp"], + target_pet["stats"]["attack"], target_pet["stats"]["defense"], + target_pet["stats"]["speed"], False)) + + await db.commit() + return f"Success! You caught the Level {target_pet['level']} {target_pet['species_name']}!" + else: + return f"The wild {target_pet['species_name']} escaped!" + + async def load_achievements(self): + """Load achievements from config and populate database""" + try: + with open("config/achievements.json", "r") as f: + achievements_data = json.load(f) + + async with aiosqlite.connect(self.database.db_path) as db: + for achievement in achievements_data: + # Get location ID if specified + location_id = None + if achievement.get("unlock_location"): + cursor = await db.execute( + "SELECT id FROM locations WHERE name = ?", + (achievement["unlock_location"],) + ) + location_row = await cursor.fetchone() + if location_row: + location_id = location_row[0] + + # Insert or update achievement + await db.execute(""" + INSERT OR IGNORE INTO achievements + (name, description, requirement_type, requirement_data, unlock_location_id) + VALUES (?, ?, ?, ?, ?) + """, ( + achievement["name"], achievement["description"], + achievement["requirement_type"], achievement["requirement_data"], + location_id + )) + + await db.commit() + + except FileNotFoundError: + print("No achievements.json found, skipping achievement loading") + + async def init_weather_system(self): + """Initialize random weather for all locations""" + try: + with open("config/weather_patterns.json", "r") as f: + self.weather_patterns = json.load(f) + + # Set initial weather for all locations + await self.update_all_weather() + + # Start background weather update task + await self.start_weather_system() + + except FileNotFoundError: + print("No weather_patterns.json found, skipping weather system") + self.weather_patterns = {"weather_types": {}, "location_weather_chances": {}} + + async def update_all_weather(self): + """Update weather for all locations""" + import datetime + + async with aiosqlite.connect(self.database.db_path) as db: + # Get all locations + cursor = await db.execute("SELECT * FROM locations") + locations = await cursor.fetchall() + + for location in locations: + location_name = location[1] # name is second column + + # Get possible weather for this location + possible_weather = self.weather_patterns.get("location_weather_chances", {}).get(location_name, ["Calm"]) + + # Choose random weather + weather_type = random.choice(possible_weather) + weather_config = self.weather_patterns.get("weather_types", {}).get(weather_type, { + "spawn_modifier": 1.0, + "affected_types": [], + "duration_minutes": [90, 180] + }) + + # Calculate end time with random duration + duration_range = weather_config.get("duration_minutes", [90, 180]) + duration_minutes = random.randint(duration_range[0], duration_range[1]) + end_time = datetime.datetime.now() + datetime.timedelta(minutes=duration_minutes) + + # Clear old weather and set new + await db.execute("DELETE FROM location_weather WHERE location_id = ?", (location[0],)) + await db.execute(""" + INSERT INTO location_weather + (location_id, weather_type, active_until, spawn_modifier, affected_types) + VALUES (?, ?, ?, ?, ?) + """, ( + location[0], weather_type, end_time.isoformat(), + weather_config.get("spawn_modifier", 1.0), + ",".join(weather_config.get("affected_types", [])) + )) + + await db.commit() + + async def check_and_award_achievements(self, player_id: int, action_type: str, data: str = ""): + """Check for new achievements after player actions""" + return await self.database.check_player_achievements(player_id, action_type, data) + + async def get_weather_modified_spawns(self, location_id: int, spawns: list) -> list: + """Apply weather modifiers to spawn rates""" + weather = await self.database.get_location_weather(location_id) + + if not weather or not weather.get("affected_types"): + return spawns # No weather effects + + affected_types = weather["affected_types"].split(",") if weather["affected_types"] else [] + modifier = weather.get("spawn_modifier", 1.0) + + # Apply weather modifier to matching pet types + modified_spawns = [] + for spawn in spawns: + spawn_dict = dict(spawn) + pet_types = [spawn_dict.get("type1")] + if spawn_dict.get("type2"): + pet_types.append(spawn_dict["type2"]) + + # Check if this pet type is affected by weather + if any(pet_type in affected_types for pet_type in pet_types): + spawn_dict["spawn_rate"] *= modifier + + modified_spawns.append(spawn_dict) + + return modified_spawns + + 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.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.shutdown_event.set() + if self.weather_task and not self.weather_task.done(): + self.weather_task.cancel() + try: + await self.weather_task + except asyncio.CancelledError: + pass + + async def _weather_update_loop(self): + """Background task that checks and updates expired weather""" + try: + while not self.shutdown_event.is_set(): + try: + # Check every 5 minutes for expired weather + await asyncio.sleep(300) # 5 minutes + + if self.shutdown_event.is_set(): + break + + # Check for locations with expired weather + await self._check_and_update_expired_weather() + + except asyncio.CancelledError: + break + except Exception as e: + print(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") + + async def _check_and_update_expired_weather(self): + """Check for expired weather and update it""" + try: + async with aiosqlite.connect(self.database.db_path) as db: + # Find locations with expired weather + cursor = await db.execute(""" + SELECT l.id, l.name + FROM locations l + WHERE l.id NOT IN ( + SELECT location_id FROM location_weather + WHERE active_until > datetime('now') + ) + """) + expired_locations = await cursor.fetchall() + + if expired_locations: + print(f"šŸŒ¤ļø Updating weather for {len(expired_locations)} locations with expired weather") + + for location in expired_locations: + location_id = location[0] + location_name = location[1] + + # Get possible weather for this location + possible_weather = self.weather_patterns.get("location_weather_chances", {}).get(location_name, ["Calm"]) + + # Choose random weather + weather_type = random.choice(possible_weather) + weather_config = self.weather_patterns.get("weather_types", {}).get(weather_type, { + "spawn_modifier": 1.0, + "affected_types": [], + "duration_minutes": [90, 180] + }) + + # Calculate end time with random duration + duration_range = weather_config.get("duration_minutes", [90, 180]) + duration_minutes = random.randint(duration_range[0], duration_range[1]) + import datetime + end_time = datetime.datetime.now() + datetime.timedelta(minutes=duration_minutes) + + # Clear old weather and set new + await db.execute("DELETE FROM location_weather WHERE location_id = ?", (location_id,)) + await db.execute(""" + INSERT INTO location_weather + (location_id, weather_type, active_until, spawn_modifier, affected_types) + VALUES (?, ?, ?, ?, ?) + """, ( + location_id, weather_type, end_time.isoformat(), + weather_config.get("spawn_modifier", 1.0), + ",".join(weather_config.get("affected_types", [])) + )) + + print(f" šŸŒ¤ļø {location_name}: New {weather_type} weather for {duration_minutes} minutes") + + await db.commit() + + except Exception as e: + print(f"Error checking expired weather: {e}") + + async def shutdown(self): + """Gracefully shutdown the game engine""" + print("šŸ”„ Shutting down game engine...") + await self.stop_weather_system() \ No newline at end of file diff --git a/webserver.py b/webserver.py new file mode 100644 index 0000000..649d917 --- /dev/null +++ b/webserver.py @@ -0,0 +1,1425 @@ +#!/usr/bin/env python3 +""" +PetBot Web Server +Provides web interface for bot data including help, player stats, and pet collections +""" + +import os +import sys +import asyncio +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs +from threading import Thread +import time + +# Add the project directory to the path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from src.database import Database + +class PetBotRequestHandler(BaseHTTPRequestHandler): + """HTTP request handler for PetBot web server""" + + def do_GET(self): + """Handle GET requests""" + parsed_path = urlparse(self.path) + path = parsed_path.path + + # Route handling + if path == '/': + self.serve_index() + elif path == '/help': + self.serve_help() + elif path == '/players': + self.serve_players() + elif path.startswith('/player/'): + nickname = path[8:] # Remove '/player/' prefix + self.serve_player_profile(nickname) + elif path == '/leaderboard': + self.serve_leaderboard() + elif path == '/locations': + self.serve_locations() + else: + self.send_error(404, "Page not found") + + def serve_index(self): + """Serve the main index page""" + html = """ + + + + + PetBot Game Hub + + + +
+

🐾 PetBot Game Hub

+

Welcome to the PetBot web interface!

+

Connect to irc.libera.chat #petz to play

+
+ + + +
+

šŸ¤– Bot Status: Online and ready for commands!

+

Use !help in #petz for quick command reference

+
+ +""" + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(html.encode()) + + def serve_help(self): + """Serve the help page""" + try: + with open('help.html', 'r') as f: + content = f.read() + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(content.encode()) + except FileNotFoundError: + self.send_error(404, "Help file not found") + + def serve_players(self): + """Serve the players page with real data""" + # Get database instance from the server class + database = self.server.database if hasattr(self.server, 'database') else None + + if not database: + self.serve_error_page("Players", "Database not available") + return + + # Fetch players data + try: + import asyncio + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + players_data = loop.run_until_complete(self.fetch_players_data(database)) + loop.close() + + self.serve_players_data(players_data) + + except Exception as e: + print(f"Error fetching players data: {e}") + self.serve_error_page("Players", f"Error loading players: {str(e)}") + + async def fetch_players_data(self, database): + """Fetch all players data from database""" + try: + import aiosqlite + async with aiosqlite.connect(database.db_path) as db: + # Get all players with basic stats + cursor = await db.execute(""" + SELECT p.nickname, p.level, p.experience, p.money, p.created_at, + l.name as location_name, + (SELECT COUNT(*) FROM pets WHERE player_id = p.id) as pet_count, + (SELECT COUNT(*) FROM pets WHERE player_id = p.id AND is_active = 1) as active_pets, + (SELECT COUNT(*) FROM player_achievements WHERE player_id = p.id) as achievement_count + FROM players p + LEFT JOIN locations l ON p.current_location_id = l.id + ORDER BY p.level DESC, p.experience DESC + """) + + rows = await cursor.fetchall() + # Convert SQLite rows to dictionaries properly + players = [] + for row in rows: + player_dict = { + 'nickname': row[0], + 'level': row[1], + 'experience': row[2], + 'money': row[3], + 'created_at': row[4], + 'location_name': row[5], + 'pet_count': row[6], + 'active_pets': row[7], + 'achievement_count': row[8] + } + players.append(player_dict) + return players + + except Exception as e: + print(f"Database error fetching players: {e}") + return [] + + def serve_players_data(self, players_data): + """Serve players page with real data""" + + # Build players table HTML + if players_data: + players_html = "" + for i, player in enumerate(players_data, 1): + rank_emoji = {"1": "šŸ„‡", "2": "🄈", "3": "šŸ„‰"}.get(str(i), f"{i}.") + + players_html += f""" + + {rank_emoji} + {player['nickname']} + {player['level']} + {player['experience']} + ${player['money']} + {player['pet_count']} + {player['active_pets']} + {player['achievement_count']} + {player.get('location_name', 'Unknown')} + """ + else: + players_html = """ + + + No players found. Be the first to use !start in #petz! + + """ + + html = f""" + + + + + PetBot - Players + + + + ← Back to Game Hub + +
+

šŸ‘„ Registered Players

+

All trainers on their pet collection journey

+
+ +
+
šŸ“Š Server Statistics
+
+
+
+
{len(players_data)}
+
Total Players
+
+
+
{sum(p['pet_count'] for p in players_data)}
+
Total Pets Caught
+
+
+
{sum(p['achievement_count'] for p in players_data)}
+
Total Achievements
+
+
+
{max((p['level'] for p in players_data), default=0)}
+
Highest Level
+
+
+
+
+ +
+
šŸ† Player Rankings
+
+ + + + + + + + + + + + + + + + {players_html} + +
RankPlayerLevelExperienceMoneyPetsActiveAchievementsLocation
+ +
+

šŸ’” Click on any player name to view their detailed profile

+
+
+
+ +""" + + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(html.encode()) + + def serve_error_page(self, page_name, error_msg): + """Serve a generic error page""" + html = f""" + + + + + PetBot - Error + + + + ← Back to Game Hub + +
+

āš ļø Error Loading {page_name}

+
+ +
+

Unable to load page

+

{error_msg}

+

Please try again later or contact an administrator.

+
+ +""" + + self.send_response(500) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(html.encode()) + + def serve_leaderboard(self): + """Serve the leaderboard page - redirect to players for now""" + # For now, leaderboard is the same as players page since they're ranked + # In the future, this could have different categories + self.send_response(302) # Temporary redirect + self.send_header('Location', '/players') + self.end_headers() + + def serve_locations(self): + """Serve the locations page with real data""" + # Get database instance from the server class + database = self.server.database if hasattr(self.server, 'database') else None + + if not database: + self.serve_error_page("Locations", "Database not available") + return + + # Fetch locations data + try: + import asyncio + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + locations_data = loop.run_until_complete(self.fetch_locations_data(database)) + loop.close() + + self.serve_locations_data(locations_data) + + except Exception as e: + print(f"Error fetching locations data: {e}") + self.serve_error_page("Locations", f"Error loading locations: {str(e)}") + + async def fetch_locations_data(self, database): + """Fetch all locations and their spawn data from database""" + try: + import aiosqlite + async with aiosqlite.connect(database.db_path) as db: + # Get all locations + cursor = await db.execute(""" + SELECT l.*, + GROUP_CONCAT(ps.name || ' (' || ps.type1 || + CASE WHEN ps.type2 IS NOT NULL THEN '/' || ps.type2 ELSE '' END || ')') as spawns + FROM locations l + LEFT JOIN location_spawns ls ON l.id = ls.location_id + LEFT JOIN pet_species ps ON ls.species_id = ps.id + GROUP BY l.id + ORDER BY l.id + """) + + rows = await cursor.fetchall() + # Convert SQLite rows to dictionaries properly + locations = [] + for row in rows: + location_dict = { + 'id': row[0], + 'name': row[1], + 'description': row[2], + 'level_min': row[3], + 'level_max': row[4], + 'spawns': row[5] if len(row) > 5 else None + } + locations.append(location_dict) + return locations + + except Exception as e: + print(f"Database error fetching locations: {e}") + return [] + + def serve_locations_data(self, locations_data): + """Serve locations page with real data""" + + # Build locations HTML + locations_html = "" + if locations_data: + for location in locations_data: + spawns = location.get('spawns', 'No pets found') + if not spawns or spawns == 'None': + spawns = "No pets spawn here yet" + + # Split spawns into a readable list + spawn_list = spawns.split(',') if spawns != "No pets spawn here yet" else [] + spawn_badges = "" + for spawn in spawn_list[:6]: # Limit to first 6 for display + spawn_badges += f'{spawn.strip()}' + if len(spawn_list) > 6: + spawn_badges += f'+{len(spawn_list) - 6} more' + + if not spawn_badges: + spawn_badges = 'No pets spawn here yet' + + locations_html += f""" +
+
+

šŸ—ŗļø {location['name']}

+
ID: {location['id']}
+
+
+ {location['description']} +
+
+ Level Range: {location['level_min']}-{location['level_max']} +
+
+ Wild Pets:
+ {spawn_badges} +
+
""" + else: + locations_html = """ +
+
+

No Locations Found

+
+
+ No game locations are configured yet. +
+
""" + + html = f""" + + + + + PetBot - Locations + + + + ← Back to Game Hub + +
+

šŸ—ŗļø Game Locations

+

Explore all areas and discover what pets await you!

+
+ +
+

šŸŽÆ How Locations Work

+

Travel: Use !travel <location> to move between areas

+

Explore: Use !explore to find wild pets in your current location

+

Unlock: Some locations require achievements - catch specific pet types to unlock new areas!

+

Weather: Check !weather for conditions that boost certain pet spawn rates

+
+ +
+ {locations_html} +
+ +
+

+ šŸ’” Use !wild <location> in #petz to see what pets spawn in a specific area +

+
+ +""" + + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(html.encode()) + + def serve_player_profile(self, nickname): + """Serve individual player profile page""" + # URL decode the nickname in case it has special characters + from urllib.parse import unquote + nickname = unquote(nickname) + + # Get database instance from the server class + database = self.server.database if hasattr(self.server, 'database') else None + + if not database: + self.serve_player_error(nickname, "Database not available") + return + + # Fetch player data + try: + import asyncio + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + player_data = loop.run_until_complete(self.fetch_player_data(database, nickname)) + loop.close() + + if player_data is None: + self.serve_player_not_found(nickname) + return + + self.serve_player_data(nickname, player_data) + + except Exception as e: + print(f"Error fetching player data for {nickname}: {e}") + self.serve_player_error(nickname, f"Error loading player data: {str(e)}") + return + + async def fetch_player_data(self, database, nickname): + """Fetch all player data from database""" + try: + # Get player info + import aiosqlite + async with aiosqlite.connect(database.db_path) as db: + # Get player basic info + cursor = await db.execute(""" + SELECT p.*, l.name as location_name, l.description as location_desc + FROM players p + LEFT JOIN locations l ON p.current_location_id = l.id + WHERE p.nickname = ? + """, (nickname,)) + player = await cursor.fetchone() + if not player: + return None + + # Convert to dict manually + player_dict = { + 'id': player[0], + 'nickname': player[1], + 'created_at': player[2], + 'last_active': player[3], + 'level': player[4], + 'experience': player[5], + 'money': player[6], + 'current_location_id': player[7], + 'location_name': player[8], + 'location_desc': player[9] + } + + # Get player pets + cursor = await db.execute(""" + 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 = ? + ORDER BY p.is_active DESC, p.level DESC, p.id ASC + """, (player_dict['id'],)) + pets_rows = await cursor.fetchall() + pets = [] + for row in pets_rows: + pet_dict = { + 'id': row[0], 'player_id': row[1], 'species_id': row[2], + 'nickname': row[3], 'level': row[4], 'experience': row[5], + 'hp': row[6], 'max_hp': row[7], 'attack': row[8], + 'defense': row[9], 'speed': row[10], 'happiness': row[11], + 'caught_at': row[12], 'is_active': row[13], + 'species_name': row[14], 'type1': row[15], 'type2': row[16] + } + pets.append(pet_dict) + + # Get player achievements + cursor = await db.execute(""" + SELECT pa.*, a.name as achievement_name, a.description as achievement_desc + FROM player_achievements pa + JOIN achievements a ON pa.achievement_id = a.id + WHERE pa.player_id = ? + ORDER BY pa.completed_at DESC + """, (player_dict['id'],)) + achievements_rows = await cursor.fetchall() + achievements = [] + for row in achievements_rows: + achievement_dict = { + 'id': row[0], 'player_id': row[1], 'achievement_id': row[2], + 'completed_at': row[3], 'achievement_name': row[4], 'achievement_desc': row[5] + } + achievements.append(achievement_dict) + + return { + 'player': player_dict, + 'pets': pets, + 'achievements': achievements + } + + except Exception as e: + print(f"Database error fetching player {nickname}: {e}") + return None + + def serve_player_not_found(self, nickname): + """Serve player not found page""" + html = f""" + + + + + PetBot - Player Not Found + + + + ← Back to Game Hub + +
+

🚫 Player Not Found

+
+ +
+

Player "{nickname}" not found

+

This player hasn't started their journey yet or doesn't exist.

+

Players can use !start in #petz to begin their adventure!

+
+ +""" + + self.send_response(404) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(html.encode()) + + def serve_player_error(self, nickname, error_msg): + """Serve player error page""" + html = f""" + + + + + PetBot - Error + + + + ← Back to Game Hub + +
+

āš ļø Error

+
+ +
+

Unable to load player data

+

{error_msg}

+

Please try again later or contact an administrator.

+
+ +""" + + self.send_response(500) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(html.encode()) + + def serve_player_data(self, nickname, player_data): + """Serve player profile page with real data""" + player = player_data['player'] + pets = player_data['pets'] + achievements = player_data['achievements'] + + # Calculate stats + active_pets = [pet for pet in pets if pet['is_active']] + total_pets = len(pets) + active_count = len(active_pets) + + # Build pets table HTML + pets_html = "" + if pets: + for pet in pets: + status = "⭐ Active" if pet['is_active'] else "šŸ“¦ Storage" + status_class = "pet-active" if pet['is_active'] else "pet-stored" + name = pet['nickname'] or pet['species_name'] + + type_str = pet['type1'] + if pet['type2']: + type_str += f"/{pet['type2']}" + + pets_html += f""" + + {status} + {name} + {pet['species_name']} + {type_str} + {pet['level']} + {pet['hp']}/{pet['max_hp']} + ATK: {pet['attack']} | DEF: {pet['defense']} | SPD: {pet['speed']} + """ + else: + pets_html = """ + + + No pets found. Use !explore and !catch to start your collection! + + """ + + # Build achievements HTML + achievements_html = "" + if achievements: + for achievement in achievements: + achievements_html += f""" +
+ šŸ† {achievement['achievement_name']}
+ {achievement['achievement_desc']}
+ Earned: {achievement['completed_at']} +
""" + else: + achievements_html = """ +
+ No achievements yet. Keep exploring and catching pets to earn achievements! +
""" + + html = f""" + + + + + PetBot - {nickname}'s Profile + + + + ← Back to Game Hub + +
+

🐾 {nickname}'s Profile

+

Level {player['level']} Trainer

+

Currently in {player.get('location_name', 'Unknown Location')}

+
+ +
+
šŸ“Š Player Statistics
+
+
+
+
{player['level']}
+
Level
+
+
+
{player['experience']}
+
Experience
+
+
+
${player['money']}
+
Money
+
+
+
{total_pets}
+
Pets Caught
+
+
+
{active_count}
+
Active Pets
+
+
+
{len(achievements)}
+
Achievements
+
+
+
+
+ +
+
🐾 Pet Collection
+
+ + + + + + + + + + + + + + {pets_html} + +
StatusNameSpeciesTypeLevelHPStats
+
+
+ +
+
šŸ† Achievements
+
+ {achievements_html} +
+
+ +""" + + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(html.encode()) + + def log_message(self, format, *args): + """Override to reduce logging noise""" + pass + +class PetBotWebServer: + def __init__(self, database, port=8080): + self.database = database + self.port = port + self.server = None + + def run(self): + """Start the HTTP web server""" + print(f"🌐 Starting PetBot web server on http://0.0.0.0:{self.port}") + print(f"šŸ“” Accessible from WSL at: http://172.27.217.61:{self.port}") + print(f"šŸ“” Accessible from Windows at: http://localhost:{self.port}") + self.server = HTTPServer(('0.0.0.0', self.port), PetBotRequestHandler) + # Pass database to the server so handlers can access it + self.server.database = self.database + self.server.serve_forever() + + def start_in_thread(self): + """Start the web server in a background thread""" + thread = Thread(target=self.run, daemon=True) + thread.start() + print(f"āœ… Web server started at http://localhost:{self.port}") + return thread + +def run_standalone(): + """Run the web server standalone for testing""" + print("🐾 PetBot Web Server (Standalone Mode)") + print("=" * 40) + + # Initialize database + database = Database() + # Note: In standalone mode, we can't easily run async init + # This is mainly for testing the web routes + + # Start web server + server = PetBotWebServer(database) + print("šŸš€ Starting web server...") + print("šŸ“ Available routes:") + print(" http://localhost:8080/ - Game Hub") + print(" http://localhost:8080/help - Command Help") + print(" http://localhost:8080/players - Player List") + print(" http://localhost:8080/leaderboard - Leaderboard") + print(" http://localhost:8080/locations - Locations") + print("") + print("Press Ctrl+C to stop") + + try: + server.run() + except KeyboardInterrupt: + print("\nāœ… Web server stopped") + +if __name__ == "__main__": + run_standalone() \ No newline at end of file