Fix TypeScript build errors
This commit is contained in:
@@ -1,30 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements
|
||||
COPY requirements.txt ./
|
||||
COPY api/requirements.txt ./api-requirements.txt
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
pip install --no-cache-dir -r api-requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY bot/ ./bot/
|
||||
COPY data/ ./data/
|
||||
COPY api/ ./api/
|
||||
COPY gamedata/ ./gamedata/
|
||||
COPY migrate_*.py ./
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the API server
|
||||
CMD ["python", "-m", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
371
README.md
371
README.md
@@ -1,371 +0,0 @@
|
||||
# Echoes of the Ashes
|
||||
|
||||
A post-apocalyptic survival RPG available on **Telegram** and **Web**, featuring turn-based exploration, resource management, and a persistent world.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 🌐 Play Now
|
||||
|
||||
- **Telegram Bot**: [@your_bot_username](https://t.me/your_bot_username)
|
||||
- **Web/Mobile**: [echoesoftheashgame.patacuack.net](https://echoesoftheashgame.patacuack.net)
|
||||
|
||||
## 🎮 Features
|
||||
|
||||
### Core Gameplay
|
||||
- **🗺️ Exploration**: Navigate through 7 interconnected locations
|
||||
- **👀 Interact**: Search and interact with 24+ unique objects
|
||||
- **🎒 Inventory**: Collect, use, and manage 28 different items
|
||||
- **⚡️ Stamina System**: Actions require stamina management with automatic regeneration
|
||||
- **❤️ Survival**: Heal using consumables, avoid damage
|
||||
- **🔄 Cooldowns**: Per-action cooldown system prevents spam
|
||||
- **♻️ Auto-Recovery**: Stamina regenerates over time (1+ per 5 minutes based on endurance)
|
||||
|
||||
### Visual Experience
|
||||
- **📸 Location Images**: Every location has a unique image
|
||||
- **🖼️ Smart Caching**: Images cached in database for instant loading
|
||||
- **✨ Smooth Transitions**: Uses `edit_message_media` for seamless navigation
|
||||
- **🧭 Context-Aware**: Location images persist across menus
|
||||
|
||||
### Game World
|
||||
- **7 Locations**: Downtown, Gas Station, Residential, Clinic, Plaza, Park, Overpass
|
||||
- **5 Interactable Types**: Rubble, Sedans, Houses, Medical Cabinets, Tool Sheds, Dumpsters, Vending Machines
|
||||
- **28 Items**: Resources, consumables, weapons, equipment, quest items
|
||||
- **Risk vs Reward**: Higher risk actions can cause damage but yield better loot
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Telegram Bot
|
||||
|
||||
1. Get a Bot Token from [@BotFather](https://t.me/botfather)
|
||||
2. Create `.env` file with your credentials
|
||||
3. Run `docker-compose up -d --build`
|
||||
4. Find your bot and send `/start`
|
||||
|
||||
See [Installation Guide](#installation) for detailed instructions.
|
||||
|
||||
### Progressive Web App (PWA)
|
||||
|
||||
1. Run `./setup_pwa.sh` to set up the web version
|
||||
2. Open [echoesoftheashgame.patacuack.net](https://echoesoftheashgame.patacuack.net)
|
||||
3. Register an account and play!
|
||||
|
||||
See [PWA_QUICKSTART.md](PWA_QUICKSTART.md) for detailed instructions.
|
||||
|
||||
## 📱 Platform Features
|
||||
|
||||
### Telegram Bot
|
||||
- 🤖 Native Telegram integration
|
||||
- 🔔 Instant push notifications
|
||||
- 💬 Chat-based gameplay
|
||||
- 👥 Easy sharing with friends
|
||||
|
||||
### Web/Mobile PWA
|
||||
- 🌐 Play in any browser
|
||||
- 📱 Install as mobile app
|
||||
- 🎨 Modern responsive UI
|
||||
- 🔐 Separate authentication
|
||||
- ⚡ Offline support (coming soon)
|
||||
- 🔔 Web push notifications (coming soon)
|
||||
|
||||
## 🛠️ Installation
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose
|
||||
- For Telegram: Bot Token from [@BotFather](https://t.me/botfather)
|
||||
- For PWA: Node.js 20+ (for development)
|
||||
|
||||
### Basic Setup
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
cd /opt/dockers/echoes_of_the_ashes
|
||||
```
|
||||
|
||||
2. Create `.env` file:
|
||||
```env
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
DATABASE_URL=postgresql+psycopg://user:password@echoes_of_the_ashes_db:5432/telegram_rpg
|
||||
POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=password
|
||||
POSTGRES_DB=telegram_rpg
|
||||
JWT_SECRET_KEY=generate-with-openssl-rand-hex-32
|
||||
```
|
||||
|
||||
3. Start services:
|
||||
```bash
|
||||
# Telegram bot only
|
||||
docker-compose up -d --build
|
||||
|
||||
# With PWA (web version)
|
||||
./setup_pwa.sh
|
||||
```
|
||||
|
||||
4. Check logs:
|
||||
```bash
|
||||
docker logs echoes_of_the_ashes_bot -f
|
||||
docker logs echoes_of_the_ashes_api -f
|
||||
docker logs echoes_of_the_ashes_pwa -f
|
||||
```
|
||||
|
||||
## 🎯 How to Play
|
||||
|
||||
### Basic Commands
|
||||
- `/start` - Start your journey or return to main menu
|
||||
|
||||
### Main Menu
|
||||
- **🗺️ Move** - Travel to connected locations
|
||||
- **👀 Inspect Area** - View and interact with objects
|
||||
- **👤 Profile** - View your character stats
|
||||
- **🎒 Inventory** - Manage your items
|
||||
|
||||
### Actions
|
||||
- **Search/Loot** - Find items in the environment (costs stamina)
|
||||
- **Use Items** - Consume food/medicine to restore HP/stamina
|
||||
- **Drop Items** - Leave items at current location
|
||||
- **Pick Up** - Collect items from the ground
|
||||
|
||||
### Stats
|
||||
- **HP**: Health Points (die at 0)
|
||||
- **Stamina**: Required for actions (regenerates over time)
|
||||
- **Weight/Volume**: Inventory capacity limits
|
||||
|
||||
## 🗺️ World Map
|
||||
|
||||
```
|
||||
🛣️ Highway Overpass
|
||||
|
|
||||
🏥 Clinic --- ⛽️ Gas Station
|
||||
| |
|
||||
🏘️ Residential --- 🌆 Downtown --- 🏬 Plaza
|
||||
| |
|
||||
+------------ 🌳 Park ------------+
|
||||
```
|
||||
|
||||
## 📦 Items
|
||||
|
||||
### Consumables
|
||||
| Item | Effect | Emoji |
|
||||
|------|--------|-------|
|
||||
| First Aid Kit | +50 HP | 🩹 |
|
||||
| Mystery Pills | +30 HP | 💊 |
|
||||
| Canned Beans | +20 HP, +5 Stamina | 🥫 |
|
||||
| Energy Bar | +15 Stamina | 🍫 |
|
||||
| Bottled Water | +10 Stamina | 💧 |
|
||||
|
||||
### Resources
|
||||
- ⚙️ Scrap Metal
|
||||
- 🪵 Wood Planks
|
||||
- 📌 Rusty Nails
|
||||
- 🧵 Cloth Scraps
|
||||
- 🍶 Plastic Bottles
|
||||
|
||||
### Equipment
|
||||
- 🎒 Hiking Backpack (+20 capacity)
|
||||
- 🔦 Flashlight
|
||||
- 🔧 Tire Iron
|
||||
- ⚾ Baseball Bat
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Tech Stack
|
||||
- **Language**: Python 3.11
|
||||
- **Bot Framework**: python-telegram-bot 21.0.1
|
||||
- **Database**: PostgreSQL 15 (async with SQLAlchemy)
|
||||
- **Deployment**: Docker Compose
|
||||
- **Scheduler**: APScheduler (for stamina regeneration)
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
telegram-rpg/
|
||||
├── bot/
|
||||
│ ├── database.py # Database operations
|
||||
│ ├── handlers.py # Telegram event handlers
|
||||
│ ├── keyboards.py # Inline keyboard layouts
|
||||
│ └── logic.py # Game logic
|
||||
├── data/
|
||||
│ ├── items.py # Item definitions
|
||||
│ ├── models.py # Game world models
|
||||
│ └── world_loader.py # World construction
|
||||
├── docs/ # Comprehensive documentation
|
||||
├── images/ # Location and interactable images
|
||||
├── main.py # Entry point
|
||||
└── docker-compose.yml # Container orchestration
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
- **players**: Character stats and state
|
||||
- **inventory**: Player item storage
|
||||
- **dropped_items**: World item storage
|
||||
- **cooldowns**: Per-action cooldown tracking
|
||||
- **image_cache**: Telegram file_id caching
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
Detailed documentation in `docs/`:
|
||||
- **INVENTORY_USE.md** - Item usage system
|
||||
- **EXPANDED_WORLD.md** - All locations and items
|
||||
- **WORLD_MAP.md** - Map visualization and strategy
|
||||
- **IMAGE_SYSTEM.md** - Image caching implementation
|
||||
- **UX_IMPROVEMENTS.md** - Clean chat mechanics
|
||||
- **ACTION_FEEDBACK.md** - Action result display
|
||||
- **SMOOTH_TRANSITIONS.md** - Message editing system
|
||||
- **UPDATE_SUMMARY.md** - Latest changes
|
||||
|
||||
## 🎨 Adding Content
|
||||
|
||||
### New Item
|
||||
Edit `data/items.py`:
|
||||
```python
|
||||
"new_item": {
|
||||
"name": "New Item",
|
||||
"weight": 1.0,
|
||||
"volume": 0.5,
|
||||
"type": "consumable",
|
||||
"effects": {"hp": 20},
|
||||
"emoji": "🎁"
|
||||
}
|
||||
```
|
||||
|
||||
### New Interactable
|
||||
Edit `data/world_loader.py`:
|
||||
```python
|
||||
NEW_TEMPLATE = Interactable(
|
||||
id="new_object",
|
||||
name="New Object",
|
||||
image_path="images/interactables/new.png"
|
||||
)
|
||||
action = Action(id="search", label="🔎 Search", stamina_cost=2)
|
||||
action.add_outcome("success", Outcome(
|
||||
text="You find something!",
|
||||
items_reward={"new_item": 1}
|
||||
))
|
||||
NEW_TEMPLATE.add_action(action)
|
||||
```
|
||||
|
||||
### New Location
|
||||
```python
|
||||
new_location = Location(
|
||||
id="new_place",
|
||||
name="🏛️ New Place",
|
||||
description="Description here",
|
||||
image_path="images/locations/new_place.png"
|
||||
)
|
||||
new_location.add_interactable("new_place_object", NEW_TEMPLATE)
|
||||
new_location.add_exit("north", "other_location")
|
||||
world.add_location(new_location)
|
||||
```
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run bot
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Database Management
|
||||
```bash
|
||||
# Access database
|
||||
docker exec -it echoes_of_the_ashes_db psql -U user -d telegram_rpg
|
||||
|
||||
# Backup database
|
||||
docker exec echoes_of_the_ashes_db pg_dump -U user telegram_rpg > backup.sql
|
||||
|
||||
# Restore database
|
||||
docker exec -i echoes_of_the_ashes_db psql -U user telegram_rpg < backup.sql
|
||||
```
|
||||
|
||||
### Logs
|
||||
```bash
|
||||
# Follow bot logs
|
||||
docker logs echoes_of_the_ashes_bot -f
|
||||
|
||||
# Database logs
|
||||
docker logs echoes_of_the_ashes_db -f
|
||||
```
|
||||
|
||||
## 🎲 Game Mechanics
|
||||
|
||||
### Outcome Probability
|
||||
- **Critical Failure**: Rare, negative effects
|
||||
- **Failure**: Common, no reward
|
||||
- **Success**: Common, standard rewards
|
||||
|
||||
Configured in `bot/logic.py`:
|
||||
```python
|
||||
def roll_outcome(action: Action):
|
||||
roll = random.random()
|
||||
if roll < 0.1: return "critical_failure"
|
||||
elif roll < 0.5: return "failure"
|
||||
else: return "success"
|
||||
```
|
||||
|
||||
### Stamina Regeneration
|
||||
- **Rate**: 1 stamina per 5 minutes
|
||||
- **Maximum**: Defined by player stats
|
||||
- **Automatic**: Background scheduler
|
||||
|
||||
### Cooldowns
|
||||
- **Per-Action**: Each action has independent cooldown
|
||||
- **Duration**: Configured per action (30-60 minutes typical)
|
||||
- **Storage**: Composite key `instance_id:action_id`
|
||||
|
||||
## 🚧 Future Plans
|
||||
|
||||
### Planned Features
|
||||
- [ ] Combat system
|
||||
- [ ] Crafting mechanics
|
||||
- [ ] Quest system
|
||||
- [ ] NPC interactions
|
||||
- [ ] Base building
|
||||
- [ ] Equipment slots
|
||||
- [ ] Status effects
|
||||
- [ ] Day/night cycle
|
||||
- [ ] Weather system
|
||||
- [ ] Trading economy
|
||||
|
||||
### Balance Improvements
|
||||
- [ ] Dynamic difficulty
|
||||
- [ ] Rare item spawns
|
||||
- [ ] Location-based dangers
|
||||
- [ ] Resource scarcity tuning
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please:
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Test thoroughly
|
||||
5. Submit a pull request
|
||||
|
||||
## 📝 License
|
||||
|
||||
This project is open source and available under the MIT License.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- Built with [python-telegram-bot](https://python-telegram-bot.org/)
|
||||
- Inspired by classic post-apocalyptic RPGs
|
||||
- Community feedback and testing
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues or questions:
|
||||
- Open a GitHub issue
|
||||
- Check the documentation in `docs/`
|
||||
- Review error logs with `docker logs`
|
||||
|
||||
---
|
||||
|
||||
**Current Version**: 1.1.0 (Expanded World Update)
|
||||
**Last Updated**: October 16, 2025
|
||||
**Status**: ✅ Active Development
|
||||
@@ -1,417 +0,0 @@
|
||||
"""
|
||||
Action handlers for button callbacks.
|
||||
This module contains organized handler functions for different types of player actions.
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
import random
|
||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from telegram.ext import ContextTypes
|
||||
from . import keyboards, logic
|
||||
from .api_client import api_client
|
||||
from .utils import format_stat_bar
|
||||
from data.world_loader import game_world
|
||||
from data.items import ITEMS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# UTILITY FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
async def check_and_redirect_if_in_combat(query, user_id: int, player: dict) -> bool:
|
||||
"""
|
||||
Check if player is in combat and redirect to combat view if so.
|
||||
Returns True if player is in combat (and was redirected), False otherwise.
|
||||
"""
|
||||
combat_data = await api_client.get_combat(user_id)
|
||||
if combat_data:
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(combat_data['npc_id'])
|
||||
|
||||
message = f"⚔️ You're in combat with {npc_def.emoji} {npc_def.name}!\n"
|
||||
message += format_stat_bar("Your HP", "❤️", player['hp'], player['max_hp']) + "\n"
|
||||
message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n"
|
||||
message += "🎯 Your turn!" if combat_data['turn'] == 'player' else "⏳ Enemy's turn..."
|
||||
|
||||
keyboard = await keyboards.combat_keyboard(user_id)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboard,
|
||||
image_path=npc_def.image_url if npc_def else None
|
||||
)
|
||||
await query.answer("⚔️ You're in combat! Finish or flee first.", show_alert=True)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_player_status_text(player_id: int) -> str:
|
||||
"""Generate player status text with location and stats.
|
||||
|
||||
Args:
|
||||
player_id: The unique database ID of the player (not telegram_id)
|
||||
"""
|
||||
from .api_client import api_client
|
||||
|
||||
player = await api_client.get_player_by_id(player_id)
|
||||
if not player:
|
||||
return "Could not find player data."
|
||||
|
||||
location = game_world.get_location(player["location_id"])
|
||||
if not location:
|
||||
return "Error: Player is in an unknown location."
|
||||
|
||||
# Get inventory from API
|
||||
inv_result = await api_client.get_inventory(player_id)
|
||||
inventory = inv_result.get('inventory', [])
|
||||
weight, volume = logic.calculate_inventory_load(inventory)
|
||||
max_weight, max_volume = logic.get_player_capacity(inventory, player)
|
||||
|
||||
# Get equipped items
|
||||
equipped_items = []
|
||||
for item in inventory:
|
||||
if item.get('is_equipped'):
|
||||
item_def = ITEMS.get(item['item_id'], {})
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
equipped_items.append(f"{emoji} {item_def.get('name', 'Unknown')}")
|
||||
|
||||
# Build status with visual bars
|
||||
status = f"<b>📍 Location:</b> {location.name}\n"
|
||||
status += f"{format_stat_bar('HP', '❤️', player['hp'], player['max_hp'])}\n"
|
||||
status += f"{format_stat_bar('Stamina', '⚡', player['stamina'], player['max_stamina'])}\n"
|
||||
status += f"🎒 <b>Load:</b> {weight}/{max_weight} kg | {volume}/{max_volume} vol\n"
|
||||
|
||||
if equipped_items:
|
||||
status += f"⚔️ <b>Equipped:</b> {', '.join(equipped_items)}\n"
|
||||
|
||||
status += "━━━━━━━━━━━━━━━━━━━━\n"
|
||||
status += f"<i>{location.description}</i>"
|
||||
return status
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# INSPECTION & WORLD INTERACTION HANDLERS
|
||||
# ============================================================================
|
||||
|
||||
async def handle_inspect_area(query, user_id: int, player: dict, data: list = None):
|
||||
"""Handle inspect area action - show NPCs and interactables in current location."""
|
||||
# Check if player is in combat and redirect if so
|
||||
if await check_and_redirect_if_in_combat(query, user_id, player):
|
||||
return
|
||||
|
||||
await query.answer()
|
||||
location_id = player['location_id']
|
||||
location = game_world.get_location(location_id)
|
||||
dropped_items = await api_client.get_dropped_items_in_location(location_id)
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(location_id)
|
||||
|
||||
keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies)
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="You scan the area. You notice...",
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
|
||||
|
||||
async def handle_attack_wandering(query, user_id: int, player: dict, data: list):
|
||||
"""Handle attacking a wandering enemy."""
|
||||
enemy_db_id = int(data[1])
|
||||
await query.answer()
|
||||
|
||||
# Get the enemy from database
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(player['location_id'])
|
||||
enemy_data = next((e for e in wandering_enemies if e['id'] == enemy_db_id), None)
|
||||
|
||||
if not enemy_data:
|
||||
await query.answer("That enemy has already moved on!", show_alert=True)
|
||||
# Refresh inspect menu
|
||||
location_id = player['location_id']
|
||||
location = game_world.get_location(location_id)
|
||||
dropped_items = await api_client.get_dropped_items_in_location(location_id)
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(location_id)
|
||||
keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies)
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="You scan the area. You notice...",
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
return
|
||||
|
||||
npc_id = enemy_data['npc_id']
|
||||
|
||||
# Remove enemy from wandering table (they're now in combat)
|
||||
await api_client.remove_wandering_enemy(enemy_db_id)
|
||||
|
||||
from data.npcs import NPCS
|
||||
from bot import combat
|
||||
|
||||
# Initiate combat
|
||||
combat_data = await combat.initiate_combat(
|
||||
user_id, npc_id, player['location_id'], from_wandering_enemy=True
|
||||
)
|
||||
|
||||
if combat_data:
|
||||
npc_def = NPCS.get(npc_id)
|
||||
message = f"⚔️ You engage the {npc_def.emoji} {npc_def.name}!\n\n"
|
||||
message += f"{npc_def.description}\n\n"
|
||||
message += format_stat_bar("Your HP", "❤️", player['hp'], player['max_hp']) + "\n"
|
||||
message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n"
|
||||
message += "🎯 Your turn! What will you do?"
|
||||
|
||||
keyboard = await keyboards.combat_keyboard(user_id)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboard,
|
||||
image_path=npc_def.image_url if npc_def else None
|
||||
)
|
||||
else:
|
||||
await query.answer("Failed to initiate combat.", show_alert=True)
|
||||
|
||||
|
||||
async def handle_inspect_interactable(query, user_id: int, player: dict, data: list):
|
||||
"""Handle inspecting an interactable object."""
|
||||
# Check if player is in combat and redirect if so
|
||||
if await check_and_redirect_if_in_combat(query, user_id, player):
|
||||
return
|
||||
|
||||
location_id, instance_id = data[1], data[2]
|
||||
|
||||
location = game_world.get_location(location_id)
|
||||
if not location:
|
||||
await query.answer("Location not found.", show_alert=True)
|
||||
return
|
||||
|
||||
interactable = location.get_interactable(instance_id)
|
||||
if not interactable:
|
||||
await query.answer("Object not found.", show_alert=False)
|
||||
return
|
||||
|
||||
# Check if ALL actions are on cooldown
|
||||
all_on_cooldown = True
|
||||
for action_id in interactable.actions.keys():
|
||||
cooldown_key = f"{instance_id}:{action_id}"
|
||||
if await api_client.get_cooldown(cooldown_key) == 0:
|
||||
all_on_cooldown = False
|
||||
break
|
||||
|
||||
if all_on_cooldown and len(interactable.actions) > 0:
|
||||
await query.answer(
|
||||
f"The {interactable.name} has already been searched. Try again later.",
|
||||
show_alert=False
|
||||
)
|
||||
return
|
||||
|
||||
# Show action menu
|
||||
await query.answer()
|
||||
image_path = interactable.image_path if interactable else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=f"You focus on the {interactable.name}. What do you do?",
|
||||
reply_markup=await keyboards.actions_keyboard(location_id, instance_id),
|
||||
image_path=image_path
|
||||
)
|
||||
|
||||
|
||||
async def handle_action(query, user_id: int, player: dict, data: list):
|
||||
"""Handle performing an action on an interactable object."""
|
||||
# Check if player is in combat and redirect if so
|
||||
if await check_and_redirect_if_in_combat(query, user_id, player):
|
||||
return
|
||||
|
||||
location_id, instance_id, action_id = data[1], data[2], data[3]
|
||||
cooldown_key = f"{instance_id}:{action_id}"
|
||||
cooldown = await api_client.get_cooldown(cooldown_key)
|
||||
|
||||
if cooldown > 0:
|
||||
await query.answer("Someone got to it just before you!", show_alert=False)
|
||||
return
|
||||
|
||||
location = game_world.get_location(location_id)
|
||||
if not location:
|
||||
await query.answer("Location not found.", show_alert=True)
|
||||
return
|
||||
|
||||
action_obj = location.get_interactable(instance_id).get_action(action_id)
|
||||
|
||||
if player['stamina'] < action_obj.stamina_cost:
|
||||
await query.answer("You are too tired to do that!", show_alert=False)
|
||||
return
|
||||
|
||||
await query.answer()
|
||||
|
||||
# Set cooldown
|
||||
await api_client.set_cooldown(cooldown_key)
|
||||
|
||||
# Resolve action
|
||||
outcome = logic.resolve_action(player, action_obj)
|
||||
new_stamina = player['stamina'] - action_obj.stamina_cost
|
||||
new_hp = player['hp'] - outcome.damage_taken
|
||||
await api_client.update_player(user_id, {"stamina": new_stamina, "hp": new_hp})
|
||||
|
||||
# Build detailed action result
|
||||
result_details = [f"<i>{outcome.text}</i>"]
|
||||
|
||||
if action_obj.stamina_cost > 0:
|
||||
result_details.append(f"⚡️ <b>Stamina:</b> -{action_obj.stamina_cost}")
|
||||
|
||||
if outcome.damage_taken > 0:
|
||||
result_details.append(f"❤️ <b>HP:</b> -{outcome.damage_taken}")
|
||||
|
||||
# Add items gained
|
||||
if outcome.items_reward:
|
||||
items_text = []
|
||||
items_failed = []
|
||||
for item_id, quantity in outcome.items_reward.items():
|
||||
can_add, reason = await logic.can_add_item_to_inventory(user_id, item_id, quantity)
|
||||
|
||||
if can_add:
|
||||
await api_client.add_item_to_inventory(user_id, item_id, quantity)
|
||||
item_def = ITEMS.get(item_id, {})
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
item_name = item_def.get('name', item_id)
|
||||
items_text.append(f"{emoji} {item_name} x{quantity}")
|
||||
else:
|
||||
item_def = ITEMS.get(item_id, {})
|
||||
item_name = item_def.get('name', item_id)
|
||||
items_failed.append(f"{item_name} ({reason})")
|
||||
|
||||
if items_text:
|
||||
result_details.append(f"🎁 <b>Gained:</b> {', '.join(items_text)}")
|
||||
if items_failed:
|
||||
result_details.append(f"⚠️ <b>Couldn't take:</b> {', '.join(items_failed)}")
|
||||
|
||||
final_text = await get_player_status_text(user_id)
|
||||
final_text += f"\n\n<b>━━━ Action Result ━━━</b>\n" + "\n".join(result_details)
|
||||
|
||||
# Get location image for the result screen
|
||||
current_location = game_world.get_location(player['location_id'])
|
||||
location_image = current_location.image_path if current_location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=final_text,
|
||||
reply_markup=keyboards.main_menu_keyboard(),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NAVIGATION & MOVEMENT HANDLERS
|
||||
# ============================================================================
|
||||
|
||||
async def handle_main_menu(query, user_id: int, player: dict, data: list = None):
|
||||
"""Return to main menu."""
|
||||
await query.answer()
|
||||
status_text = await get_player_status_text(user_id)
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=status_text,
|
||||
reply_markup=keyboards.main_menu_keyboard(),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_move_menu(query, user_id: int, player: dict, data: list = None):
|
||||
"""Show movement options menu."""
|
||||
# Check if player is in combat and redirect if so
|
||||
if await check_and_redirect_if_in_combat(query, user_id, player):
|
||||
return
|
||||
|
||||
await query.answer()
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="Where do you want to go?",
|
||||
reply_markup=await keyboards.move_keyboard(player['location_id'], user_id),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_move(query, user_id: int, player: dict, data: list):
|
||||
"""Handle player movement to a new location."""
|
||||
# Check if player is in combat and redirect if so
|
||||
if await check_and_redirect_if_in_combat(query, user_id, player):
|
||||
return
|
||||
|
||||
destination_id = data[1]
|
||||
|
||||
# Use API to move player
|
||||
from .api_client import api_client
|
||||
result = await api_client.move_player(player['id'], destination_id)
|
||||
|
||||
if not result.get('success'):
|
||||
await query.answer(result.get('message', 'Cannot move there!'), show_alert=True)
|
||||
return
|
||||
|
||||
await query.answer(result.get('message', 'Moving...'), show_alert=False)
|
||||
|
||||
# Refresh player data from API using unique id
|
||||
player = await api_client.get_player_by_id(user_id)
|
||||
|
||||
# Check for random NPC encounter
|
||||
from data.npcs import NPCS, get_random_npc_for_location, get_location_encounter_rate
|
||||
encounter_rate = get_location_encounter_rate(destination_id)
|
||||
|
||||
if random.random() < encounter_rate:
|
||||
from bot import combat
|
||||
logger.info(f"Encounter triggered at {destination_id} (rate: {encounter_rate})")
|
||||
|
||||
npc_id = get_random_npc_for_location(destination_id)
|
||||
|
||||
if npc_id:
|
||||
combat_data = await combat.initiate_combat(user_id, npc_id, destination_id)
|
||||
|
||||
if combat_data:
|
||||
npc_def = NPCS.get(npc_id)
|
||||
message = f"⚠️ A {npc_def.emoji} {npc_def.name} appears!\n\n"
|
||||
message += f"{npc_def.description}\n\n"
|
||||
message += format_stat_bar("Your HP", "❤️", player['hp'], player['max_hp']) + "\n"
|
||||
message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n"
|
||||
message += "🎯 Your turn! What will you do?"
|
||||
|
||||
keyboard = await keyboards.combat_keyboard(user_id)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboard,
|
||||
image_path=npc_def.image_url if npc_def else None
|
||||
)
|
||||
return
|
||||
|
||||
status_text = await get_player_status_text(user_id)
|
||||
new_location = game_world.get_location(destination_id)
|
||||
location_image = new_location.image_path if new_location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=status_text,
|
||||
reply_markup=keyboards.main_menu_keyboard(),
|
||||
image_path=location_image
|
||||
)
|
||||
@@ -1,198 +0,0 @@
|
||||
"""
|
||||
API Client for Telegram Bot
|
||||
Connects bot to FastAPI game server instead of using direct database access
|
||||
"""
|
||||
|
||||
import os
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://echoes_of_the_ashes_api:8000")
|
||||
API_INTERNAL_KEY = os.getenv("API_INTERNAL_KEY", "internal-bot-access-key-change-me")
|
||||
|
||||
|
||||
class GameAPIClient:
|
||||
"""Client for interacting with the FastAPI game server"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = API_BASE_URL
|
||||
self.headers = {
|
||||
"X-Internal-Key": API_INTERNAL_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client"""
|
||||
await self.client.aclose()
|
||||
|
||||
# ==================== Player Management ====================
|
||||
|
||||
async def get_player(self, telegram_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get player by telegram ID"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.base_url}/api/internal/player/telegram/{telegram_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting player: {e}")
|
||||
return None
|
||||
|
||||
async def create_player(self, telegram_id: int, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Create a new player"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/internal/player",
|
||||
headers=self.headers,
|
||||
json={"telegram_id": telegram_id, "name": name}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error creating player: {e}")
|
||||
return None
|
||||
|
||||
async def update_player(self, telegram_id: int, updates: Dict[str, Any]) -> bool:
|
||||
"""Update player data"""
|
||||
try:
|
||||
response = await self.client.patch(
|
||||
f"{self.base_url}/api/internal/player/telegram/{telegram_id}",
|
||||
headers=self.headers,
|
||||
json=updates
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error updating player: {e}")
|
||||
return False
|
||||
|
||||
# ==================== Location & Movement ====================
|
||||
|
||||
async def get_location(self, location_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get location details"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.base_url}/api/internal/location/{location_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting location: {e}")
|
||||
return None
|
||||
|
||||
async def move_player(self, telegram_id: int, direction: str) -> Optional[Dict[str, Any]]:
|
||||
"""Move player in a direction"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/internal/player/telegram/{telegram_id}/move",
|
||||
headers=self.headers,
|
||||
json={"direction": direction}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
# Return error details
|
||||
return {"success": False, "error": e.response.json().get("detail", str(e))}
|
||||
except Exception as e:
|
||||
print(f"Error moving player: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ==================== Combat ====================
|
||||
|
||||
async def start_combat(self, telegram_id: int, npc_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Start combat with an NPC"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/internal/combat/start",
|
||||
headers=self.headers,
|
||||
json={"telegram_id": telegram_id, "npc_id": npc_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error starting combat: {e}")
|
||||
return None
|
||||
|
||||
async def get_combat(self, telegram_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get active combat state"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.base_url}/api/internal/combat/telegram/{telegram_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting combat: {e}")
|
||||
return None
|
||||
|
||||
async def combat_action(self, telegram_id: int, action: str) -> Optional[Dict[str, Any]]:
|
||||
"""Perform a combat action (attack, defend, flee)"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/internal/combat/telegram/{telegram_id}/action",
|
||||
headers=self.headers,
|
||||
json={"action": action}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error performing combat action: {e}")
|
||||
return None
|
||||
|
||||
# ==================== Inventory ====================
|
||||
|
||||
async def get_inventory(self, telegram_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get player's inventory"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.base_url}/api/internal/player/telegram/{telegram_id}/inventory",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting inventory: {e}")
|
||||
return None
|
||||
|
||||
async def use_item(self, telegram_id: int, item_db_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Use an item from inventory"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/internal/player/telegram/{telegram_id}/use_item",
|
||||
headers=self.headers,
|
||||
json={"item_db_id": item_db_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error using item: {e}")
|
||||
return None
|
||||
|
||||
async def equip_item(self, telegram_id: int, item_db_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Equip/unequip an item"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/internal/player/telegram/{telegram_id}/equip",
|
||||
headers=self.headers,
|
||||
json={"item_db_id": item_db_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error equipping item: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Global API client instance
|
||||
api_client = GameAPIClient()
|
||||
@@ -1,623 +0,0 @@
|
||||
"""
|
||||
API client for the bot to communicate with the standalone API.
|
||||
All database operations now go through the API.
|
||||
"""
|
||||
import httpx
|
||||
import os
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
|
||||
class APIClient:
|
||||
"""Client for bot-to-API communication"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_url = os.getenv("API_BASE_URL", os.getenv("API_URL", "http://echoes_of_the_ashes_api:8000"))
|
||||
self.internal_key = os.getenv("API_INTERNAL_KEY", "change-this-internal-key")
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {self.internal_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client"""
|
||||
await self.client.aclose()
|
||||
|
||||
# Player operations
|
||||
async def get_player(self, telegram_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get player by Telegram ID"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/player/{telegram_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting player: {e}")
|
||||
return None
|
||||
|
||||
async def get_player_by_id(self, player_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get player by unique database ID"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/player/by_id/{player_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting player by id: {e}")
|
||||
return None
|
||||
|
||||
async def create_player(self, telegram_id: int, name: str = "Survivor") -> Optional[Dict[str, Any]]:
|
||||
"""Create a new player"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player",
|
||||
headers=self.headers,
|
||||
params={"telegram_id": telegram_id, "name": name}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error creating player: {e}")
|
||||
return None
|
||||
|
||||
# Movement operations
|
||||
async def move_player(self, player_id: int, direction: str) -> Dict[str, Any]:
|
||||
"""Move player in a direction"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/move",
|
||||
headers=self.headers,
|
||||
params={"direction": direction}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error moving player: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
# Inspection operations
|
||||
async def inspect_area(self, player_id: int) -> Dict[str, Any]:
|
||||
"""Inspect current area"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/inspect",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error inspecting area: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
# Interaction operations
|
||||
async def interact(self, player_id: int, interactable_id: str, action_id: str) -> Dict[str, Any]:
|
||||
"""Interact with an object"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/interact",
|
||||
headers=self.headers,
|
||||
params={"interactable_id": interactable_id, "action_id": action_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error interacting: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
# Inventory operations
|
||||
async def get_inventory(self, player_id: int) -> Dict[str, Any]:
|
||||
"""Get player inventory"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/inventory",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting inventory: {e}")
|
||||
return {"success": False, "inventory": []}
|
||||
|
||||
async def use_item(self, player_id: int, item_id: str) -> Dict[str, Any]:
|
||||
"""Use an item"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/use_item",
|
||||
headers=self.headers,
|
||||
params={"item_id": item_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error using item: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
async def pickup_item(self, player_id: int, item_id: str) -> Dict[str, Any]:
|
||||
"""Pick up an item"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/pickup",
|
||||
headers=self.headers,
|
||||
params={"item_id": item_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error picking up item: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
async def drop_item(self, player_id: int, item_id: str, quantity: int = 1) -> Dict[str, Any]:
|
||||
"""Drop an item"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/drop_item",
|
||||
headers=self.headers,
|
||||
params={"item_id": item_id, "quantity": quantity}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error dropping item: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
async def equip_item(self, player_id: int, item_id: str) -> Dict[str, Any]:
|
||||
"""Equip an item"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/equip",
|
||||
headers=self.headers,
|
||||
params={"item_id": item_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error equipping item: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
async def unequip_item(self, player_id: int, item_id: str) -> Dict[str, Any]:
|
||||
"""Unequip an item"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/unequip",
|
||||
headers=self.headers,
|
||||
params={"item_id": item_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error unequipping item: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
# Combat operations
|
||||
async def get_combat(self, player_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get active combat for player"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/combat",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting combat: {e}")
|
||||
return None
|
||||
|
||||
async def create_combat(self, player_id: int, npc_id: str, npc_hp: int, npc_max_hp: int, location_id: str, from_wandering: bool = False) -> Optional[Dict[str, Any]]:
|
||||
"""Create new combat"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/combat/create",
|
||||
headers=self.headers,
|
||||
params={
|
||||
"player_id": player_id,
|
||||
"npc_id": npc_id,
|
||||
"npc_hp": npc_hp,
|
||||
"npc_max_hp": npc_max_hp,
|
||||
"location_id": location_id,
|
||||
"from_wandering": from_wandering
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error creating combat: {e}")
|
||||
return None
|
||||
|
||||
async def update_combat(self, player_id: int, updates: Dict[str, Any]) -> bool:
|
||||
"""Update combat state"""
|
||||
try:
|
||||
response = await self.client.patch(
|
||||
f"{self.api_url}/api/internal/combat/{player_id}",
|
||||
headers=self.headers,
|
||||
json=updates
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error updating combat: {e}")
|
||||
return False
|
||||
|
||||
async def end_combat(self, player_id: int) -> bool:
|
||||
"""End combat"""
|
||||
try:
|
||||
response = await self.client.delete(
|
||||
f"{self.api_url}/api/internal/combat/{player_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error ending combat: {e}")
|
||||
return False
|
||||
|
||||
# Player update operations
|
||||
async def update_player(self, player_id: int, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Update player fields"""
|
||||
try:
|
||||
response = await self.client.patch(
|
||||
f"{self.api_url}/api/internal/player/{player_id}",
|
||||
headers=self.headers,
|
||||
json=updates
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error updating player: {e}")
|
||||
return None
|
||||
|
||||
# Dropped items operations
|
||||
async def drop_item_to_world(self, item_id: str, quantity: int, location_id: str) -> bool:
|
||||
"""Drop an item to the world"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/dropped-items",
|
||||
headers=self.headers,
|
||||
params={"item_id": item_id, "quantity": quantity, "location_id": location_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error dropping item: {e}")
|
||||
return False
|
||||
|
||||
async def get_dropped_item(self, dropped_item_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific dropped item"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/dropped-items/{dropped_item_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting dropped item: {e}")
|
||||
return None
|
||||
|
||||
async def get_dropped_items_in_location(self, location_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all dropped items in a location"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/location/{location_id}/dropped-items",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting dropped items: {e}")
|
||||
return []
|
||||
|
||||
async def update_dropped_item(self, dropped_item_id: int, quantity: int) -> bool:
|
||||
"""Update dropped item quantity"""
|
||||
try:
|
||||
response = await self.client.patch(
|
||||
f"{self.api_url}/api/internal/dropped-items/{dropped_item_id}",
|
||||
headers=self.headers,
|
||||
params={"quantity": quantity}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error updating dropped item: {e}")
|
||||
return False
|
||||
|
||||
async def remove_dropped_item(self, dropped_item_id: int) -> bool:
|
||||
"""Remove a dropped item"""
|
||||
try:
|
||||
response = await self.client.delete(
|
||||
f"{self.api_url}/api/internal/dropped-items/{dropped_item_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error removing dropped item: {e}")
|
||||
return False
|
||||
|
||||
# Corpse operations
|
||||
async def create_player_corpse(self, player_name: str, location_id: str, items: str) -> Optional[int]:
|
||||
"""Create a player corpse"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/corpses/player",
|
||||
headers=self.headers,
|
||||
params={"player_name": player_name, "location_id": location_id, "items": items}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('corpse_id')
|
||||
except Exception as e:
|
||||
print(f"Error creating player corpse: {e}")
|
||||
return None
|
||||
|
||||
async def get_player_corpse(self, corpse_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get a player corpse"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/corpses/player/{corpse_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting player corpse: {e}")
|
||||
return None
|
||||
|
||||
async def update_player_corpse(self, corpse_id: int, items: str) -> bool:
|
||||
"""Update player corpse items"""
|
||||
try:
|
||||
response = await self.client.patch(
|
||||
f"{self.api_url}/api/internal/corpses/player/{corpse_id}",
|
||||
headers=self.headers,
|
||||
params={"items": items}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error updating player corpse: {e}")
|
||||
return False
|
||||
|
||||
async def remove_player_corpse(self, corpse_id: int) -> bool:
|
||||
"""Remove a player corpse"""
|
||||
try:
|
||||
response = await self.client.delete(
|
||||
f"{self.api_url}/api/internal/corpses/player/{corpse_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error removing player corpse: {e}")
|
||||
return False
|
||||
|
||||
async def create_npc_corpse(self, npc_id: str, location_id: str, loot_remaining: str) -> Optional[int]:
|
||||
"""Create an NPC corpse"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/corpses/npc",
|
||||
headers=self.headers,
|
||||
params={"npc_id": npc_id, "location_id": location_id, "loot_remaining": loot_remaining}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('corpse_id')
|
||||
except Exception as e:
|
||||
print(f"Error creating NPC corpse: {e}")
|
||||
return None
|
||||
|
||||
async def get_npc_corpse(self, corpse_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get an NPC corpse"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/corpses/npc/{corpse_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting NPC corpse: {e}")
|
||||
return None
|
||||
|
||||
async def update_npc_corpse(self, corpse_id: int, loot_remaining: str) -> bool:
|
||||
"""Update NPC corpse loot"""
|
||||
try:
|
||||
response = await self.client.patch(
|
||||
f"{self.api_url}/api/internal/corpses/npc/{corpse_id}",
|
||||
headers=self.headers,
|
||||
params={"loot_remaining": loot_remaining}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error updating NPC corpse: {e}")
|
||||
return False
|
||||
|
||||
async def remove_npc_corpse(self, corpse_id: int) -> bool:
|
||||
"""Remove an NPC corpse"""
|
||||
try:
|
||||
response = await self.client.delete(
|
||||
f"{self.api_url}/api/internal/corpses/npc/{corpse_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error removing NPC corpse: {e}")
|
||||
return False
|
||||
|
||||
# Wandering enemies operations
|
||||
async def spawn_wandering_enemy(self, npc_id: str, location_id: str, current_hp: int, max_hp: int) -> Optional[int]:
|
||||
"""Spawn a wandering enemy"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/wandering-enemies",
|
||||
headers=self.headers,
|
||||
params={"npc_id": npc_id, "location_id": location_id, "current_hp": current_hp, "max_hp": max_hp}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('enemy_id')
|
||||
except Exception as e:
|
||||
print(f"Error spawning wandering enemy: {e}")
|
||||
return None
|
||||
|
||||
async def get_wandering_enemies_in_location(self, location_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all wandering enemies in a location"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/location/{location_id}/wandering-enemies",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting wandering enemies: {e}")
|
||||
return []
|
||||
|
||||
async def remove_wandering_enemy(self, enemy_id: int) -> bool:
|
||||
"""Remove a wandering enemy"""
|
||||
try:
|
||||
response = await self.client.delete(
|
||||
f"{self.api_url}/api/internal/wandering-enemies/{enemy_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error removing wandering enemy: {e}")
|
||||
return False
|
||||
|
||||
async def get_inventory_item(self, item_db_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific inventory item by database ID"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/inventory/item/{item_db_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting inventory item: {e}")
|
||||
return None
|
||||
|
||||
# Cooldown operations
|
||||
async def get_cooldown(self, cooldown_key: str) -> int:
|
||||
"""Get remaining cooldown time in seconds"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/cooldown/{cooldown_key}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('remaining_seconds', 0)
|
||||
except Exception as e:
|
||||
print(f"Error getting cooldown: {e}")
|
||||
return 0
|
||||
|
||||
async def set_cooldown(self, cooldown_key: str, duration_seconds: int = 600) -> bool:
|
||||
"""Set a cooldown"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/cooldown/{cooldown_key}",
|
||||
headers=self.headers,
|
||||
params={"duration_seconds": duration_seconds}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error setting cooldown: {e}")
|
||||
return False
|
||||
|
||||
# Corpse list operations
|
||||
async def get_player_corpses_in_location(self, location_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all player corpses in a location"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/location/{location_id}/corpses/player",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting player corpses: {e}")
|
||||
return []
|
||||
|
||||
async def get_npc_corpses_in_location(self, location_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all NPC corpses in a location"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/location/{location_id}/corpses/npc",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting NPC corpses: {e}")
|
||||
return []
|
||||
|
||||
# Image cache operations
|
||||
async def get_cached_image(self, image_path: str) -> Optional[str]:
|
||||
"""Get cached telegram file ID for an image"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/image-cache/{image_path}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('telegram_file_id')
|
||||
except Exception as e:
|
||||
# Not found is expected, not an error
|
||||
return None
|
||||
|
||||
async def cache_image(self, image_path: str, telegram_file_id: str) -> bool:
|
||||
"""Cache a telegram file ID for an image"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/image-cache",
|
||||
headers=self.headers,
|
||||
params={"image_path": image_path, "telegram_file_id": telegram_file_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error caching image: {e}")
|
||||
return False
|
||||
|
||||
# Status effects operations
|
||||
async def get_player_status_effects(self, player_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get player status effects"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/status-effects",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting status effects: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# Global API client instance
|
||||
api_client = APIClient()
|
||||
@@ -1,201 +0,0 @@
|
||||
"""
|
||||
Background tasks for the bot.
|
||||
Handles periodic maintenance, regeneration, and processing.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from bot import database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def decay_dropped_items(shutdown_event):
|
||||
"""A background task that periodically cleans up old dropped items."""
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
# Wait for 5 minutes before the next cleanup
|
||||
await asyncio.wait_for(shutdown_event.wait(), timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
start_time = time.time()
|
||||
logger.info("Running item decay task...")
|
||||
|
||||
# Set decay time to 1 hour (3600 seconds)
|
||||
decay_seconds = 3600
|
||||
timestamp_limit = int(time.time()) - decay_seconds
|
||||
items_removed = await database.remove_expired_dropped_items(timestamp_limit)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if items_removed > 0:
|
||||
logger.info(f"Decayed and removed {items_removed} old items in {elapsed:.2f}s")
|
||||
|
||||
|
||||
async def regenerate_stamina(shutdown_event):
|
||||
"""A background task that periodically regenerates stamina for all players."""
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
# Wait for 5 minutes before the next regeneration cycle
|
||||
await asyncio.wait_for(shutdown_event.wait(), timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
start_time = time.time()
|
||||
logger.info("Running stamina regeneration...")
|
||||
|
||||
players_updated = await database.regenerate_all_players_stamina()
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if players_updated > 0:
|
||||
logger.info(f"Regenerated stamina for {players_updated} players in {elapsed:.2f}s")
|
||||
|
||||
# Alert if regeneration is taking too long (potential scaling issue)
|
||||
if elapsed > 5.0:
|
||||
logger.warning(f"⚠️ Stamina regeneration took {elapsed:.2f}s (threshold: 5s) - check database load!")
|
||||
|
||||
|
||||
async def check_combat_timers(shutdown_event):
|
||||
"""A background task that checks for idle combat turns and auto-attacks."""
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
# Wait for 30 seconds before next check
|
||||
await asyncio.wait_for(shutdown_event.wait(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
start_time = time.time()
|
||||
# Check for combats idle for more than 5 minutes (300 seconds)
|
||||
idle_threshold = time.time() - 300
|
||||
idle_combats = await database.get_all_idle_combats(idle_threshold)
|
||||
|
||||
if idle_combats:
|
||||
logger.info(f"Processing {len(idle_combats)} idle combats...")
|
||||
|
||||
for combat in idle_combats:
|
||||
try:
|
||||
from bot import combat as combat_logic
|
||||
# Force end player's turn and let NPC attack
|
||||
if combat['turn'] == 'player':
|
||||
await database.update_combat(combat['player_id'], {
|
||||
'turn': 'npc',
|
||||
'turn_started_at': time.time()
|
||||
})
|
||||
# NPC attacks
|
||||
await combat_logic.npc_attack(combat['player_id'])
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing idle combat: {e}")
|
||||
|
||||
# Log performance for monitoring
|
||||
if idle_combats:
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(f"Processed {len(idle_combats)} idle combats in {elapsed:.2f}s")
|
||||
|
||||
# Warn if taking too long (potential scaling issue)
|
||||
if elapsed > 10.0:
|
||||
logger.warning(f"⚠️ Combat timer check took {elapsed:.2f}s (threshold: 10s) - consider batching!")
|
||||
|
||||
|
||||
async def decay_corpses(shutdown_event):
|
||||
"""A background task that removes old corpses."""
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
# Wait for 10 minutes before next cleanup
|
||||
await asyncio.wait_for(shutdown_event.wait(), timeout=600)
|
||||
except asyncio.TimeoutError:
|
||||
start_time = time.time()
|
||||
logger.info("Running corpse decay...")
|
||||
|
||||
# Player corpses decay after 24 hours
|
||||
player_corpse_limit = time.time() - (24 * 3600)
|
||||
player_corpses_removed = await database.remove_expired_player_corpses(player_corpse_limit)
|
||||
|
||||
# NPC corpses decay after 2 hours
|
||||
npc_corpse_limit = time.time() - (2 * 3600)
|
||||
npc_corpses_removed = await database.remove_expired_npc_corpses(npc_corpse_limit)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if player_corpses_removed > 0 or npc_corpses_removed > 0:
|
||||
logger.info(f"Decayed {player_corpses_removed} player corpses and {npc_corpses_removed} NPC corpses in {elapsed:.2f}s")
|
||||
|
||||
|
||||
async def process_status_effects(shutdown_event):
|
||||
"""
|
||||
A background task that applies damage from persistent status effects.
|
||||
Runs every 5 minutes to process status effect ticks.
|
||||
"""
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
# Wait for 5 minutes before next processing cycle
|
||||
await asyncio.wait_for(shutdown_event.wait(), timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
start_time = time.time()
|
||||
logger.info("Running status effects processor...")
|
||||
|
||||
try:
|
||||
# Decrement all status effect ticks and get affected players
|
||||
affected_players = await database.decrement_all_status_effect_ticks()
|
||||
|
||||
if not affected_players:
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(f"No active status effects to process ({elapsed:.3f}s)")
|
||||
continue
|
||||
|
||||
# Process each affected player
|
||||
deaths = 0
|
||||
damage_dealt = 0
|
||||
|
||||
for player_id in affected_players:
|
||||
try:
|
||||
# Get current status effects (after decrement)
|
||||
effects = await database.get_player_status_effects(player_id)
|
||||
|
||||
if not effects:
|
||||
continue
|
||||
|
||||
# Calculate total damage
|
||||
from bot.status_utils import calculate_status_damage
|
||||
total_damage = calculate_status_damage(effects)
|
||||
|
||||
if total_damage > 0:
|
||||
damage_dealt += total_damage
|
||||
player = await database.get_player(player_id)
|
||||
|
||||
if not player or player['is_dead']:
|
||||
continue
|
||||
|
||||
new_hp = max(0, player['hp'] - total_damage)
|
||||
|
||||
# Check if player died from status effects
|
||||
if new_hp <= 0:
|
||||
await database.update_player(player_id, {'hp': 0, 'is_dead': True})
|
||||
deaths += 1
|
||||
|
||||
# Create player corpse
|
||||
inventory = await database.get_inventory(player_id)
|
||||
await database.create_player_corpse(
|
||||
player_name=player['name'],
|
||||
location_id=player['location_id'],
|
||||
items=inventory
|
||||
)
|
||||
|
||||
# Remove status effects from dead player
|
||||
await database.remove_all_status_effects(player_id)
|
||||
|
||||
logger.info(f"Player {player['name']} (ID: {player_id}) died from status effects")
|
||||
else:
|
||||
# Apply damage
|
||||
await database.update_player(player_id, {'hp': new_hp})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing status effects for player {player_id}: {e}")
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
f"Processed status effects for {len(affected_players)} players "
|
||||
f"({damage_dealt} total damage, {deaths} deaths) in {elapsed:.3f}s"
|
||||
)
|
||||
|
||||
# Warn if taking too long (potential scaling issue)
|
||||
if elapsed > 5.0:
|
||||
logger.warning(
|
||||
f"⚠️ Status effects processing took {elapsed:.3f}s (threshold: 5s) "
|
||||
f"- {len(affected_players)} players affected"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in status effects processor: {e}")
|
||||
527
bot/combat.py
527
bot/combat.py
@@ -1,527 +0,0 @@
|
||||
"""
|
||||
Combat system logic for turn-based NPC encounters.
|
||||
"""
|
||||
|
||||
import random
|
||||
import json
|
||||
import time
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from bot.api_client import api_client
|
||||
from bot.utils import format_stat_bar
|
||||
from data.npcs import NPCS, STATUS_EFFECTS
|
||||
from data.items import ITEMS
|
||||
|
||||
|
||||
# XP curve for leveling
|
||||
def xp_for_level(level: int) -> int:
|
||||
"""Calculate XP needed to reach a level."""
|
||||
if level <= 1:
|
||||
return 0 # Level 1 starts at 0 XP
|
||||
return int(100 * (level ** 1.5))
|
||||
|
||||
|
||||
async def calculate_player_damage(player: dict) -> int:
|
||||
"""Calculate player's damage output based on stats and equipped weapon."""
|
||||
base_damage = 5
|
||||
strength_bonus = player['strength'] // 2
|
||||
level_bonus = player['level']
|
||||
|
||||
# Check for equipped weapon
|
||||
inventory = await api_client.get_inventory(player['telegram_id'])
|
||||
weapon_damage = 0
|
||||
|
||||
for item in inventory:
|
||||
if item.get('is_equipped'):
|
||||
item_def = ITEMS.get(item['item_id'], {})
|
||||
if item_def.get('type') == 'weapon':
|
||||
# Get weapon damage range
|
||||
damage_min = item_def.get('damage_min', 0)
|
||||
damage_max = item_def.get('damage_max', 0)
|
||||
weapon_damage = random.randint(damage_min, damage_max)
|
||||
break
|
||||
|
||||
# Random variance
|
||||
variance = random.randint(-2, 2)
|
||||
|
||||
return max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
|
||||
|
||||
|
||||
def calculate_npc_damage(npc_def, npc_hp: int, npc_max_hp: int) -> int:
|
||||
"""Calculate NPC's damage output."""
|
||||
base_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
||||
|
||||
# Enraged bonus if low HP
|
||||
hp_percent = npc_hp / npc_max_hp
|
||||
if hp_percent < 0.3:
|
||||
base_damage = int(base_damage * 1.5)
|
||||
|
||||
return max(1, base_damage)
|
||||
|
||||
|
||||
async def initiate_combat(player_id: int, npc_id: str, location_id: str, from_wandering_enemy: bool = False) -> Dict:
|
||||
"""
|
||||
Start a new combat encounter.
|
||||
Args:
|
||||
player_id: Telegram user ID
|
||||
npc_id: NPC definition ID
|
||||
location_id: Where combat is happening
|
||||
from_wandering_enemy: If True, enemy will respawn if player flees or dies
|
||||
Returns combat state dict.
|
||||
"""
|
||||
npc_def = NPCS.get(npc_id)
|
||||
if not npc_def:
|
||||
return None
|
||||
|
||||
# Randomize NPC HP
|
||||
npc_hp = random.randint(npc_def.hp_min, npc_def.hp_max)
|
||||
|
||||
# Create combat in database
|
||||
combat_id = await api_client.create_combat(
|
||||
player_id=player_id,
|
||||
npc_id=npc_id,
|
||||
npc_hp=npc_hp,
|
||||
npc_max_hp=npc_hp,
|
||||
location_id=location_id,
|
||||
from_wandering_enemy=from_wandering_enemy
|
||||
)
|
||||
|
||||
return await api_client.get_combat(player_id)
|
||||
|
||||
|
||||
async def player_attack(player_id: int) -> Tuple[str, bool, bool]:
|
||||
"""
|
||||
Player attacks the NPC.
|
||||
Returns: (message, npc_died, player_turn_ended)
|
||||
"""
|
||||
combat = await api_client.get_combat(player_id)
|
||||
if not combat or combat['turn'] != 'player':
|
||||
return ("It's not your turn!", False, False)
|
||||
|
||||
player = await api_client.get_player(player_id)
|
||||
npc_def = NPCS.get(combat['npc_id'])
|
||||
|
||||
if not player or not npc_def:
|
||||
return ("Combat error!", False, False)
|
||||
|
||||
# Check if player is stunned
|
||||
player_effects = json.loads(combat['player_status_effects'])
|
||||
is_stunned = any(effect['name'] == 'Stunned' for effect in player_effects)
|
||||
if is_stunned:
|
||||
# Update status effects
|
||||
player_effects = update_status_effects(player_effects)
|
||||
await api_client.update_combat(player_id, {
|
||||
'turn': 'npc',
|
||||
'turn_started_at': time.time(),
|
||||
'player_status_effects': json.dumps(player_effects)
|
||||
})
|
||||
return ("⚠️ You're stunned and cannot attack! The enemy seizes the opportunity!", False, True)
|
||||
|
||||
# Calculate damage
|
||||
raw_damage = await calculate_player_damage(player)
|
||||
actual_damage = max(1, raw_damage - npc_def.defense)
|
||||
|
||||
new_npc_hp = max(0, combat['npc_hp'] - actual_damage)
|
||||
|
||||
# Check for critical hit (10% chance)
|
||||
is_crit = random.random() < 0.1
|
||||
if is_crit:
|
||||
actual_damage = int(actual_damage * 1.5)
|
||||
new_npc_hp = max(0, combat['npc_hp'] - actual_damage)
|
||||
|
||||
message = "━━━ YOUR TURN ━━━\n"
|
||||
message += f"⚔️ You attack the {npc_def.name} for {actual_damage} damage!"
|
||||
if is_crit:
|
||||
message += " 💥 CRITICAL HIT!"
|
||||
|
||||
# Check for status effect infliction (5% chance to stun)
|
||||
npc_effects = json.loads(combat['npc_status_effects'])
|
||||
if random.random() < 0.05:
|
||||
npc_effects.append({
|
||||
'name': 'Stunned',
|
||||
'turns_remaining': 1,
|
||||
'damage_per_turn': 0
|
||||
})
|
||||
message += f"\n🌟 You stunned the {npc_def.name}!"
|
||||
|
||||
# Apply status effect damage to player
|
||||
player_effects, status_damage, status_messages = apply_status_effects(player_effects)
|
||||
if status_damage > 0:
|
||||
new_player_hp = max(0, player['hp'] - status_damage)
|
||||
await api_client.update_player(player_id, {'hp': new_player_hp})
|
||||
message += f"\n{status_messages}"
|
||||
|
||||
if new_player_hp <= 0:
|
||||
await handle_player_death(player_id)
|
||||
return (message + "\n\n💀 You have died from your wounds...", True, True)
|
||||
|
||||
# Check if NPC died
|
||||
if new_npc_hp <= 0:
|
||||
await api_client.update_combat(player_id, {
|
||||
'npc_hp': 0,
|
||||
'npc_status_effects': json.dumps(npc_effects),
|
||||
'player_status_effects': json.dumps(player_effects)
|
||||
})
|
||||
|
||||
# Handle victory
|
||||
victory_msg = await handle_npc_death(player_id, combat, npc_def)
|
||||
return (message + "\n\n" + victory_msg, True, True)
|
||||
|
||||
# Update combat - switch to NPC turn
|
||||
await api_client.update_combat(player_id, {
|
||||
'npc_hp': new_npc_hp,
|
||||
'turn': 'npc',
|
||||
'turn_started_at': time.time(),
|
||||
'npc_status_effects': json.dumps(npc_effects),
|
||||
'player_status_effects': json.dumps(player_effects)
|
||||
})
|
||||
|
||||
# Show both health bars after player's turn
|
||||
message += "\n━━━━━━━━━━━━━━━━━━━━\n"
|
||||
message += format_stat_bar("Your HP", "❤️", player['hp'], player['max_hp']) + "\n"
|
||||
message += format_stat_bar(npc_def.name, npc_def.emoji, new_npc_hp, combat['npc_max_hp'])
|
||||
|
||||
return (message, False, True)
|
||||
|
||||
|
||||
|
||||
async def npc_attack(player_id: int) -> Tuple[str, bool]:
|
||||
"""
|
||||
NPC attacks the player.
|
||||
Returns: (message, player_died)
|
||||
"""
|
||||
combat = await api_client.get_combat(player_id)
|
||||
if not combat or combat['turn'] != 'npc':
|
||||
return ("", False)
|
||||
|
||||
player = await api_client.get_player(player_id)
|
||||
npc_def = NPCS.get(combat['npc_id'])
|
||||
|
||||
if not player or not npc_def:
|
||||
return ("Combat error!", False)
|
||||
|
||||
# Check if NPC is stunned
|
||||
npc_effects = json.loads(combat['npc_status_effects'])
|
||||
is_stunned = any(effect['name'] == 'Stunned' for effect in npc_effects)
|
||||
if is_stunned:
|
||||
# Update status effects
|
||||
npc_effects = update_status_effects(npc_effects)
|
||||
await api_client.update_combat(player_id, {
|
||||
'turn': 'player',
|
||||
'turn_started_at': time.time(),
|
||||
'npc_status_effects': json.dumps(npc_effects)
|
||||
})
|
||||
return (f"⚠️ The {npc_def.name} is stunned and cannot attack!", False)
|
||||
|
||||
# Calculate damage
|
||||
damage = calculate_npc_damage(npc_def, combat['npc_hp'], combat['npc_max_hp'])
|
||||
|
||||
# Apply damage to player
|
||||
new_player_hp = max(0, player['hp'] - damage)
|
||||
await api_client.update_player(player_id, {'hp': new_player_hp})
|
||||
|
||||
message = "━━━ ENEMY TURN ━━━\n"
|
||||
message += f"💥 The {npc_def.name} attacks you for {damage} damage!"
|
||||
|
||||
# Check for status effect infliction
|
||||
player_effects = json.loads(combat['player_status_effects'])
|
||||
if random.random() < npc_def.status_inflict_chance:
|
||||
# Bleeding is most common
|
||||
player_effects.append({
|
||||
'name': 'Bleeding',
|
||||
'turns_remaining': 3,
|
||||
'damage_per_turn': 2
|
||||
})
|
||||
message += "\n🩸 You're bleeding!"
|
||||
|
||||
# Apply status effect damage to NPC
|
||||
npc_effects, status_damage, status_messages = apply_status_effects(npc_effects)
|
||||
if status_damage > 0:
|
||||
new_npc_hp = max(0, combat['npc_hp'] - status_damage)
|
||||
await api_client.update_combat(player_id, {'npc_hp': new_npc_hp})
|
||||
message += f"\n{status_messages}"
|
||||
|
||||
if new_npc_hp <= 0:
|
||||
victory_msg = await handle_npc_death(player_id, combat, npc_def)
|
||||
return (message + "\n\n" + victory_msg, False)
|
||||
|
||||
# Check if player died
|
||||
if new_player_hp <= 0:
|
||||
await handle_player_death(player_id)
|
||||
return (message + "\n\n💀 You have been slain...", True)
|
||||
|
||||
# Update combat - switch to player turn
|
||||
await api_client.update_combat(player_id, {
|
||||
'turn': 'player',
|
||||
'turn_started_at': time.time(),
|
||||
'player_status_effects': json.dumps(player_effects),
|
||||
'npc_status_effects': json.dumps(npc_effects)
|
||||
})
|
||||
|
||||
# Show both health bars after enemy's turn
|
||||
message += "\n━━━━━━━━━━━━━━━━━━━━\n"
|
||||
message += format_stat_bar("Your HP", "❤️", new_player_hp, player['max_hp']) + "\n"
|
||||
message += format_stat_bar(npc_def.name, npc_def.emoji, combat['npc_hp'], combat['npc_max_hp'])
|
||||
|
||||
return (message, False)
|
||||
|
||||
|
||||
async def flee_attempt(player_id: int) -> Tuple[str, bool, bool]:
|
||||
"""
|
||||
Player attempts to flee from combat.
|
||||
Returns: (message, fled_successfully, turn_ended)
|
||||
"""
|
||||
combat = await api_client.get_combat(player_id)
|
||||
if not combat or combat['turn'] != 'player':
|
||||
return ("It's not your turn!", False, False)
|
||||
|
||||
player = await api_client.get_player(player_id)
|
||||
npc_def = NPCS.get(combat['npc_id'])
|
||||
|
||||
# Base flee chance is 50%, modified by agility
|
||||
flee_chance = 0.5 + (player['agility'] / 100)
|
||||
|
||||
if random.random() < flee_chance:
|
||||
# Success! Check if we need to respawn the wandering enemy
|
||||
if combat.get('from_wandering_enemy', False):
|
||||
# Respawn the enemy at the same location with full HP
|
||||
await api_client.spawn_wandering_enemy(
|
||||
npc_id=combat['npc_id'],
|
||||
location_id=combat['location_id'],
|
||||
current_hp=npc_def.hp,
|
||||
max_hp=npc_def.hp
|
||||
)
|
||||
|
||||
await api_client.end_combat(player_id)
|
||||
return (f"🏃 You successfully flee from the {npc_def.name}!", True, True)
|
||||
else:
|
||||
# Failed - lose turn and NPC attacks
|
||||
message = f"❌ You failed to escape! The {npc_def.name} takes advantage!"
|
||||
|
||||
# NPC gets a free attack
|
||||
await api_client.update_combat(player_id, {
|
||||
'turn': 'npc',
|
||||
'turn_started_at': time.time()
|
||||
})
|
||||
|
||||
return (message, False, True)
|
||||
|
||||
|
||||
def update_status_effects(effects: List[Dict]) -> List[Dict]:
|
||||
"""Decrease turn counters on status effects."""
|
||||
new_effects = []
|
||||
for effect in effects:
|
||||
effect['turns_remaining'] -= 1
|
||||
if effect['turns_remaining'] > 0:
|
||||
new_effects.append(effect)
|
||||
return new_effects
|
||||
|
||||
|
||||
def apply_status_effects(effects: List[Dict]) -> Tuple[List[Dict], int, str]:
|
||||
"""
|
||||
Apply status effect damage with stacking.
|
||||
Returns: (updated_effects, total_damage, message)
|
||||
"""
|
||||
from bot.status_utils import stack_status_effects
|
||||
|
||||
if not effects:
|
||||
return effects, 0, ""
|
||||
|
||||
# Convert effect format if needed (name -> effect_name, damage_per_turn -> damage_per_tick)
|
||||
normalized_effects = []
|
||||
for effect in effects:
|
||||
normalized = {
|
||||
'effect_name': effect.get('name', effect.get('effect_name', 'Unknown')),
|
||||
'effect_icon': effect.get('icon', effect.get('effect_icon', '❓')),
|
||||
'damage_per_tick': effect.get('damage_per_turn', effect.get('damage_per_tick', 0)),
|
||||
'ticks_remaining': effect.get('turns_remaining', effect.get('ticks_remaining', 0))
|
||||
}
|
||||
normalized_effects.append(normalized)
|
||||
|
||||
# Stack effects
|
||||
stacked = stack_status_effects(normalized_effects)
|
||||
|
||||
total_damage = 0
|
||||
messages = []
|
||||
|
||||
for name, data in stacked.items():
|
||||
if data['total_damage'] > 0:
|
||||
total_damage += data['total_damage']
|
||||
# Show stacked damage
|
||||
if data['stacks'] > 1:
|
||||
messages.append(f"{data['icon']} {name.replace('_', ' ').title()}: -{data['total_damage']} HP (×{data['stacks']})")
|
||||
else:
|
||||
messages.append(f"{data['icon']} {name.replace('_', ' ').title()}: -{data['total_damage']} HP")
|
||||
|
||||
return effects, total_damage, "\n".join(messages)
|
||||
|
||||
|
||||
async def handle_npc_death(player_id: int, combat: Dict, npc_def) -> str:
|
||||
"""Handle NPC death - give XP, drop loot, create corpse."""
|
||||
player = await api_client.get_player(player_id)
|
||||
|
||||
# Give XP
|
||||
new_xp = player['xp'] + npc_def.xp_reward
|
||||
level_up_msg = ""
|
||||
|
||||
# Check for level up
|
||||
current_level = player['level']
|
||||
xp_needed = xp_for_level(current_level + 1)
|
||||
|
||||
if new_xp >= xp_needed:
|
||||
new_level = current_level + 1
|
||||
# Give stat points instead of auto-allocating
|
||||
# Players get 5 points per level to spend as they wish
|
||||
points_gained = 5
|
||||
new_unspent_points = player.get('unspent_points', 0) + points_gained
|
||||
|
||||
await api_client.update_player(player_id, {
|
||||
'xp': new_xp,
|
||||
'level': new_level,
|
||||
'hp': player['max_hp'], # Heal on level up
|
||||
'stamina': player['max_stamina'], # Restore stamina on level up
|
||||
'unspent_points': new_unspent_points
|
||||
})
|
||||
|
||||
level_up_msg = f"\n\n🎉 LEVEL UP! You are now level {new_level}!"
|
||||
level_up_msg += f"\n⭐ You have {points_gained} stat points to spend!"
|
||||
level_up_msg += f"\n❤️ Fully healed and stamina restored!"
|
||||
level_up_msg += f"\n\n💡 Check your profile to spend your points!"
|
||||
else:
|
||||
await api_client.update_player(player_id, {'xp': new_xp})
|
||||
|
||||
# Drop loot
|
||||
loot_msg = "\n\n💰 Loot dropped:"
|
||||
loot_items = []
|
||||
for loot_item in npc_def.loot_table:
|
||||
if random.random() < loot_item.drop_chance:
|
||||
quantity = random.randint(loot_item.quantity_min, loot_item.quantity_max)
|
||||
await api_client.drop_item_to_world(
|
||||
loot_item.item_id,
|
||||
quantity,
|
||||
combat['location_id']
|
||||
)
|
||||
item_def = ITEMS.get(loot_item.item_id, {})
|
||||
loot_msg += f"\n{item_def.get('emoji', '❔')} {item_def.get('name', 'Unknown')} x{quantity}"
|
||||
loot_items.append(loot_item.item_id)
|
||||
|
||||
if not loot_items:
|
||||
loot_msg += "\nNothing..."
|
||||
|
||||
# Create corpse if it has corpse loot
|
||||
if npc_def.corpse_loot:
|
||||
corpse_loot_json = json.dumps([{
|
||||
'item_id': cl.item_id,
|
||||
'quantity_min': cl.quantity_min,
|
||||
'quantity_max': cl.quantity_max,
|
||||
'required_tool': cl.required_tool
|
||||
} for cl in npc_def.corpse_loot])
|
||||
|
||||
await api_client.create_npc_corpse(
|
||||
npc_id=combat['npc_id'],
|
||||
location_id=combat['location_id'],
|
||||
loot_remaining=corpse_loot_json
|
||||
)
|
||||
loot_msg += f"\n\n{npc_def.emoji} The corpse can be scavenged for more resources."
|
||||
|
||||
# End combat
|
||||
await api_client.end_combat(player_id)
|
||||
|
||||
message = f"🏆 Victory! {npc_def.death_message}"
|
||||
message += f"\n+{npc_def.xp_reward} XP"
|
||||
message += level_up_msg
|
||||
message += loot_msg
|
||||
|
||||
return message
|
||||
|
||||
|
||||
async def handle_player_death(player_id: int):
|
||||
"""Handle player death - create corpse bag with all items."""
|
||||
player = await api_client.get_player(player_id)
|
||||
inventory_items = await api_client.get_inventory(player_id)
|
||||
|
||||
# Check if combat was with a wandering enemy that should respawn
|
||||
combat = await api_client.get_combat(player_id)
|
||||
if combat and combat.get('from_wandering_enemy', False):
|
||||
# Respawn the enemy at the same location with full HP
|
||||
npc_def = NPCS.get(combat['npc_id'])
|
||||
await api_client.spawn_wandering_enemy(
|
||||
npc_id=combat['npc_id'],
|
||||
location_id=combat['location_id'],
|
||||
current_hp=npc_def.hp,
|
||||
max_hp=npc_def.hp
|
||||
)
|
||||
|
||||
# Create corpse bag if player has items
|
||||
if inventory_items:
|
||||
items_json = json.dumps([{
|
||||
'item_id': item['item_id'],
|
||||
'quantity': item['quantity']
|
||||
} for item in inventory_items])
|
||||
|
||||
await api_client.create_player_corpse(
|
||||
player_name=player['name'],
|
||||
location_id=player['location_id'],
|
||||
items=items_json
|
||||
)
|
||||
|
||||
# Remove all items from player
|
||||
for item in inventory_items:
|
||||
await api_client.remove_item_from_inventory(item['id'], item['quantity'])
|
||||
|
||||
# Mark player as dead and end any combat
|
||||
await api_client.update_player(player_id, {'is_dead': True, 'hp': 0})
|
||||
await api_client.end_combat(player_id)
|
||||
|
||||
|
||||
async def use_item_in_combat(player_id: int, item_db_id: int) -> Tuple[str, bool]:
|
||||
"""
|
||||
Use a consumable item during combat.
|
||||
Returns: (message, turn_ended)
|
||||
"""
|
||||
combat = await api_client.get_combat(player_id)
|
||||
if not combat or combat['turn'] != 'player':
|
||||
return ("It's not your turn!", False)
|
||||
|
||||
item_data = await api_client.get_inventory_item(item_db_id)
|
||||
if not item_data or item_data['player_id'] != player_id:
|
||||
return ("You don't have that item!", False)
|
||||
|
||||
item_def = ITEMS.get(item_data['item_id'])
|
||||
if not item_def or item_def.get('type') != 'consumable':
|
||||
return ("That item cannot be used in combat!", False)
|
||||
|
||||
player = await api_client.get_player(player_id)
|
||||
|
||||
# Apply consumable effects
|
||||
message = f"💊 Used {item_def['name']}!"
|
||||
|
||||
hp_restore = item_def.get('hp_restore', 0)
|
||||
stamina_restore = item_def.get('stamina_restore', 0)
|
||||
|
||||
updates = {}
|
||||
if hp_restore > 0:
|
||||
new_hp = min(player['hp'] + hp_restore, player['max_hp'])
|
||||
updates['hp'] = new_hp
|
||||
message += f"\n❤️ +{hp_restore} HP"
|
||||
|
||||
if stamina_restore > 0:
|
||||
new_stamina = min(player['stamina'] + stamina_restore, player['max_stamina'])
|
||||
updates['stamina'] = new_stamina
|
||||
message += f"\n⚡ +{stamina_restore} Stamina"
|
||||
|
||||
if updates:
|
||||
await api_client.update_player(player_id, updates)
|
||||
|
||||
# Remove item from inventory
|
||||
if item_data['quantity'] > 1:
|
||||
await api_client.update_inventory_item(item_db_id, item_data['quantity'] - 1)
|
||||
else:
|
||||
await api_client.remove_item_from_inventory(item_db_id, 1)
|
||||
|
||||
# Using an item ends your turn
|
||||
await api_client.update_combat(player_id, {
|
||||
'turn': 'npc',
|
||||
'turn_started_at': time.time()
|
||||
})
|
||||
|
||||
return (message, True)
|
||||
@@ -1,165 +0,0 @@
|
||||
"""
|
||||
Combat-related action handlers.
|
||||
"""
|
||||
import logging
|
||||
from . import keyboards
|
||||
from .api_client import api_client
|
||||
from .utils import format_stat_bar
|
||||
from data.world_loader import game_world
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_combat_attack(query, user_id: int, player: dict, data: list = None):
|
||||
"""Handle player attack action in combat."""
|
||||
from bot import combat
|
||||
await query.answer()
|
||||
|
||||
message, npc_died, turn_ended = await combat.player_attack(user_id)
|
||||
|
||||
if npc_died:
|
||||
# Combat ended - return to main menu
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboards.main_menu_keyboard(),
|
||||
image_path=location_image
|
||||
)
|
||||
elif turn_ended:
|
||||
# NPC's turn - auto-attack
|
||||
npc_message, player_died = await combat.npc_attack(user_id)
|
||||
message += "\n\n" + npc_message
|
||||
|
||||
if player_died:
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(query, text=message, reply_markup=None)
|
||||
else:
|
||||
combat_data = await api_client.get_combat(user_id)
|
||||
if combat_data:
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(combat_data['npc_id'])
|
||||
keyboard = await keyboards.combat_keyboard(user_id)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboard,
|
||||
image_path=npc_def.image_url if npc_def else None
|
||||
)
|
||||
else:
|
||||
await query.answer(message, show_alert=False)
|
||||
|
||||
|
||||
async def handle_combat_flee(query, user_id: int, player: dict, data: list = None):
|
||||
"""Handle flee attempt from combat."""
|
||||
from bot import combat
|
||||
await query.answer()
|
||||
|
||||
message, fled, turn_ended = await combat.flee_attempt(user_id)
|
||||
|
||||
if fled:
|
||||
# Successfully fled - return to main menu
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboards.main_menu_keyboard(),
|
||||
image_path=location_image
|
||||
)
|
||||
elif turn_ended:
|
||||
# Failed to flee - NPC attacks
|
||||
npc_message, player_died = await combat.npc_attack(user_id)
|
||||
message += "\n\n" + npc_message
|
||||
|
||||
if player_died:
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(query, text=message, reply_markup=None)
|
||||
else:
|
||||
combat_data = await api_client.get_combat(user_id)
|
||||
if combat_data:
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(combat_data['npc_id'])
|
||||
keyboard = await keyboards.combat_keyboard(user_id)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboard,
|
||||
image_path=npc_def.image_url if npc_def else None
|
||||
)
|
||||
else:
|
||||
await query.answer(message, show_alert=False)
|
||||
|
||||
|
||||
async def handle_combat_use_item_menu(query, user_id: int, player: dict, data: list = None):
|
||||
"""Show menu of usable items during combat."""
|
||||
await query.answer()
|
||||
|
||||
|
||||
async def handle_combat_use_item(query, user_id: int, player: dict, data: list):
|
||||
"""Use an item during combat."""
|
||||
from bot import combat
|
||||
item_db_id = int(data[1])
|
||||
|
||||
message, turn_ended = await combat.use_item_in_combat(user_id, item_db_id)
|
||||
await query.answer(message, show_alert=False)
|
||||
|
||||
if turn_ended:
|
||||
# NPC's turn
|
||||
npc_message, player_died = await combat.npc_attack(user_id)
|
||||
|
||||
if player_died:
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message + "\n\n" + npc_message,
|
||||
reply_markup=None
|
||||
)
|
||||
else:
|
||||
combat_data = await api_client.get_combat(user_id)
|
||||
if combat_data:
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(combat_data['npc_id'])
|
||||
keyboard = await keyboards.combat_keyboard(user_id)
|
||||
full_message = message + "\n\n" + npc_message + "\n\n🎯 Your turn!"
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=full_message,
|
||||
reply_markup=keyboard,
|
||||
image_path=npc_def.image_url if npc_def else None
|
||||
)
|
||||
|
||||
|
||||
async def handle_combat_back(query, user_id: int, player: dict, data: list = None):
|
||||
"""Return to combat menu from item selection."""
|
||||
await query.answer()
|
||||
combat_data = await api_client.get_combat(user_id)
|
||||
|
||||
if combat_data:
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(combat_data['npc_id'])
|
||||
keyboard = await keyboards.combat_keyboard(user_id)
|
||||
|
||||
message = f"⚔️ Combat with {npc_def.emoji} {npc_def.name}!\n"
|
||||
message += format_stat_bar("Your HP", "❤️", player['hp'], player['max_hp']) + "\n"
|
||||
message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n"
|
||||
message += "🎯 Your turn!" if combat_data['turn'] == 'player' else "⏳ Enemy's turn..."
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboard,
|
||||
image_path=npc_def.image_url if npc_def else None
|
||||
)
|
||||
109
bot/commands.py
109
bot/commands.py
@@ -1,109 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Command handlers for the Telegram bot.
|
||||
Handles slash commands like /start, /export_map, /spawn_stats.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
from io import BytesIO
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from . import keyboards
|
||||
from .api_client import api_client
|
||||
from .utils import admin_only
|
||||
from .action_handlers import get_player_status_text
|
||||
from data.world_loader import game_world
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /start command - initialize or show player status."""
|
||||
from .api_client import api_client
|
||||
|
||||
user = update.effective_user
|
||||
player = await api_client.get_player(user.id)
|
||||
|
||||
if not player:
|
||||
player = await api_client.create_player(user.id, user.first_name)
|
||||
await update.message.reply_html(
|
||||
f"Welcome, {user.mention_html()}! Your story is just beginning."
|
||||
)
|
||||
|
||||
# Get player status and location image
|
||||
player = await api_client.get_player(user.id)
|
||||
status_text = await get_player_status_text(user.id)
|
||||
location = game_world.get_location(player['location_id'])
|
||||
|
||||
# Send with image if available
|
||||
if location and location.image_path:
|
||||
cached_file_id = await api_client.get_cached_image(location.image_path)
|
||||
if cached_file_id:
|
||||
await update.message.reply_photo(
|
||||
photo=cached_file_id,
|
||||
caption=status_text,
|
||||
reply_markup=keyboards.main_menu_keyboard(),
|
||||
parse_mode='HTML'
|
||||
)
|
||||
elif os.path.exists(location.image_path):
|
||||
with open(location.image_path, 'rb') as img_file:
|
||||
msg = await update.message.reply_photo(
|
||||
photo=img_file,
|
||||
caption=status_text,
|
||||
reply_markup=keyboards.main_menu_keyboard(),
|
||||
parse_mode='HTML'
|
||||
)
|
||||
if msg.photo:
|
||||
await api_client.cache_image(location.image_path, msg.photo[-1].file_id)
|
||||
else:
|
||||
await update.message.reply_html(
|
||||
status_text,
|
||||
reply_markup=keyboards.main_menu_keyboard()
|
||||
)
|
||||
else:
|
||||
await update.message.reply_html(
|
||||
status_text,
|
||||
reply_markup=keyboards.main_menu_keyboard()
|
||||
)
|
||||
|
||||
|
||||
@admin_only
|
||||
async def export_map(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Export map data as JSON for external visualization."""
|
||||
from data.world_loader import export_map_data
|
||||
|
||||
map_data = export_map_data()
|
||||
json_str = json.dumps(map_data, indent=2)
|
||||
|
||||
# Send as text file
|
||||
file = BytesIO(json_str.encode('utf-8'))
|
||||
file.name = "map_data.json"
|
||||
|
||||
await update.message.reply_document(
|
||||
document=file,
|
||||
filename="map_data.json",
|
||||
caption="🗺️ Game Map Data\n\nThis JSON file contains all locations, coordinates, and connections.\nYou can use it to visualize the game map in external tools."
|
||||
)
|
||||
|
||||
|
||||
@admin_only
|
||||
async def spawn_stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Show wandering enemy spawn statistics (debug command)."""
|
||||
from bot.spawn_manager import get_spawn_stats
|
||||
|
||||
stats = await get_spawn_stats()
|
||||
|
||||
text = "📊 <b>Wandering Enemy Statistics</b>\n\n"
|
||||
text += f"<b>Total Active Enemies:</b> {stats['total_active']}\n\n"
|
||||
|
||||
if stats['by_location']:
|
||||
text += "<b>Enemies by Location:</b>\n"
|
||||
for loc_id, count in stats['by_location'].items():
|
||||
location = game_world.get_location(loc_id)
|
||||
loc_name = location.name if location else loc_id
|
||||
text += f"• {loc_name}: {count}\n"
|
||||
else:
|
||||
text += "<i>No wandering enemies currently active.</i>"
|
||||
|
||||
await update.message.reply_html(text)
|
||||
@@ -1,235 +0,0 @@
|
||||
"""
|
||||
Corpse looting handlers (player and NPC corpses).
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
import random
|
||||
from . import keyboards, logic
|
||||
from .api_client import api_client
|
||||
from data.world_loader import game_world
|
||||
from data.items import ITEMS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_loot_player_corpse(query, user_id: int, player: dict, data: list):
|
||||
"""Show player corpse loot menu."""
|
||||
corpse_id = int(data[1])
|
||||
corpse = await api_client.get_player_corpse(corpse_id)
|
||||
|
||||
if not corpse:
|
||||
await query.answer("Corpse not found.", show_alert=False)
|
||||
return
|
||||
|
||||
items = json.loads(corpse['items'])
|
||||
keyboard = keyboards.player_corpse_loot_keyboard(corpse_id, items)
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
await query.answer()
|
||||
text = f"🎒 {corpse['player_name']}'s bag\n\nYou find the remains of another survivor..."
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
|
||||
|
||||
async def handle_take_corpse_item(query, user_id: int, player: dict, data: list):
|
||||
"""Take an item from a player corpse."""
|
||||
corpse_id = int(data[1])
|
||||
item_index = int(data[2])
|
||||
|
||||
corpse = await api_client.get_player_corpse(corpse_id)
|
||||
if not corpse:
|
||||
await query.answer("Corpse not found.", show_alert=False)
|
||||
return
|
||||
|
||||
items = json.loads(corpse['items'])
|
||||
if item_index >= len(items):
|
||||
await query.answer("Item not found.", show_alert=False)
|
||||
return
|
||||
|
||||
item_data = items[item_index]
|
||||
item_def = ITEMS.get(item_data['item_id'], {})
|
||||
|
||||
# Check inventory capacity
|
||||
can_add, reason = await logic.can_add_item_to_inventory(
|
||||
user_id, item_data['item_id'], item_data['quantity']
|
||||
)
|
||||
|
||||
if not can_add:
|
||||
await query.answer(reason, show_alert=False)
|
||||
return
|
||||
|
||||
# Add to inventory
|
||||
await api_client.add_item_to_inventory(user_id, item_data['item_id'], item_data['quantity'])
|
||||
|
||||
# Remove from corpse
|
||||
items.pop(item_index)
|
||||
|
||||
if items:
|
||||
await api_client.update_player_corpse(corpse_id, json.dumps(items))
|
||||
keyboard = keyboards.player_corpse_loot_keyboard(corpse_id, items)
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
await query.answer(f"Took {item_def.get('name', 'Unknown')}.", show_alert=False)
|
||||
text = f"🎒 {corpse['player_name']}'s bag"
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
else:
|
||||
# Bag is empty, remove it
|
||||
await api_client.remove_player_corpse(corpse_id)
|
||||
await query.answer(
|
||||
f"Took {item_def.get('name', 'Unknown')}. The bag is now empty.",
|
||||
show_alert=False
|
||||
)
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
dropped_items = await api_client.get_dropped_items_in_location(player['location_id'])
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(player['location_id'])
|
||||
keyboard = await keyboards.inspect_keyboard(player['location_id'], dropped_items, wandering_enemies)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="You scan the area. You notice...",
|
||||
reply_markup=keyboard,
|
||||
image_path=location.image_path if location else None
|
||||
)
|
||||
|
||||
|
||||
async def handle_scavenge_npc_corpse(query, user_id: int, player: dict, data: list):
|
||||
"""Show NPC corpse scavenging menu."""
|
||||
corpse_id = int(data[1])
|
||||
corpse = await api_client.get_npc_corpse(corpse_id)
|
||||
|
||||
if not corpse:
|
||||
await query.answer("Corpse not found.", show_alert=False)
|
||||
return
|
||||
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(corpse['npc_id'])
|
||||
loot_items = json.loads(corpse['loot_remaining'])
|
||||
keyboard = keyboards.npc_corpse_scavenge_keyboard(corpse_id, loot_items)
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
await query.answer()
|
||||
text = f"🔪 {npc_def.emoji} {npc_def.name} Corpse\n\n{npc_def.description}"
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
|
||||
|
||||
async def handle_scavenge_corpse_item(query, user_id: int, player: dict, data: list):
|
||||
"""Scavenge a specific item from an NPC corpse."""
|
||||
corpse_id = int(data[1])
|
||||
loot_index = int(data[2])
|
||||
|
||||
corpse = await api_client.get_npc_corpse(corpse_id)
|
||||
if not corpse:
|
||||
await query.answer("Corpse not found.", show_alert=False)
|
||||
return
|
||||
|
||||
loot_items = json.loads(corpse['loot_remaining'])
|
||||
if loot_index >= len(loot_items):
|
||||
await query.answer("Nothing to scavenge here.", show_alert=False)
|
||||
return
|
||||
|
||||
loot_data = loot_items[loot_index]
|
||||
required_tool = loot_data.get('required_tool')
|
||||
|
||||
# Check if player has required tool
|
||||
if required_tool:
|
||||
inventory_items = await api_client.get_inventory(user_id)
|
||||
has_tool = any(item['item_id'] == required_tool for item in inventory_items)
|
||||
|
||||
if not has_tool:
|
||||
tool_def = ITEMS.get(required_tool, {})
|
||||
await query.answer(
|
||||
f"You need a {tool_def.get('name', 'tool')} to scavenge this.",
|
||||
show_alert=False
|
||||
)
|
||||
return
|
||||
|
||||
# Determine quantity
|
||||
quantity = random.randint(loot_data['quantity_min'], loot_data['quantity_max'])
|
||||
item_def = ITEMS.get(loot_data['item_id'], {})
|
||||
|
||||
# Check inventory capacity
|
||||
can_add, reason = await logic.can_add_item_to_inventory(
|
||||
user_id, loot_data['item_id'], quantity
|
||||
)
|
||||
|
||||
if not can_add:
|
||||
await query.answer(reason, show_alert=False)
|
||||
return
|
||||
|
||||
# Add to inventory
|
||||
await api_client.add_item_to_inventory(user_id, loot_data['item_id'], quantity)
|
||||
|
||||
# Remove from corpse
|
||||
loot_items.pop(loot_index)
|
||||
|
||||
if loot_items:
|
||||
await api_client.update_npc_corpse(corpse_id, json.dumps(loot_items))
|
||||
keyboard = keyboards.npc_corpse_scavenge_keyboard(corpse_id, loot_items)
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
await query.answer(
|
||||
f"Scavenged {quantity}x {item_def.get('name', 'Unknown')}.",
|
||||
show_alert=False
|
||||
)
|
||||
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(corpse['npc_id'])
|
||||
text = f"🔪 {npc_def.emoji} {npc_def.name} Corpse"
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
else:
|
||||
# Nothing left, remove corpse
|
||||
await api_client.remove_npc_corpse(corpse_id)
|
||||
await query.answer(
|
||||
f"Scavenged {quantity}x {item_def.get('name', 'Unknown')}. Nothing left on the corpse.",
|
||||
show_alert=False
|
||||
)
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
dropped_items = await api_client.get_dropped_items_in_location(player['location_id'])
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(player['location_id'])
|
||||
keyboard = await keyboards.inspect_keyboard(player['location_id'], dropped_items, wandering_enemies)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="You scan the area. You notice...",
|
||||
reply_markup=keyboard,
|
||||
image_path=location.image_path if location else None
|
||||
)
|
||||
729
bot/database.py
729
bot/database.py
@@ -1,729 +0,0 @@
|
||||
import time
|
||||
import os
|
||||
from typing import Set
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy import (
|
||||
MetaData, Table, Column, Integer, String, Boolean, ForeignKey, Float, UniqueConstraint,
|
||||
)
|
||||
|
||||
DB_USER, DB_PASS, DB_NAME, DB_HOST, DB_PORT = os.getenv("POSTGRES_USER"), os.getenv("POSTGRES_PASSWORD"), os.getenv("POSTGRES_DB"), os.getenv("POSTGRES_HOST"), os.getenv("POSTGRES_PORT")
|
||||
DATABASE_URL = f"postgresql+psycopg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||
engine = create_async_engine(DATABASE_URL)
|
||||
metadata = MetaData()
|
||||
|
||||
# ... (players, inventory, dropped_items tables are unchanged) ...
|
||||
players = Table(
|
||||
"players",
|
||||
metadata,
|
||||
Column("telegram_id", Integer, primary_key=True),
|
||||
Column("id", Integer, unique=True, autoincrement=True), # Web users ID
|
||||
Column("username", String(50), unique=True, nullable=True), # Web users username
|
||||
Column("password_hash", String(255), nullable=True), # Web users password hash
|
||||
Column("name", String, default="Survivor"),
|
||||
Column("hp", Integer, default=100),
|
||||
Column("max_hp", Integer, default=100),
|
||||
Column("stamina", Integer, default=20),
|
||||
Column("max_stamina", Integer, default=20),
|
||||
Column("strength", Integer, default=5),
|
||||
Column("agility", Integer, default=5),
|
||||
Column("endurance", Integer, default=5),
|
||||
Column("intellect", Integer, default=5),
|
||||
Column("location_id", String, default="start_point"),
|
||||
Column("is_dead", Boolean, default=False),
|
||||
Column("level", Integer, default=1),
|
||||
Column("xp", Integer, default=0),
|
||||
Column("unspent_points", Integer, default=0)
|
||||
)
|
||||
inventory = Table("inventory", metadata, Column("id", Integer, primary_key=True, autoincrement=True), Column("player_id", Integer, ForeignKey("players.telegram_id", ondelete="CASCADE")), Column("item_id", String), Column("quantity", Integer, default=1), Column("is_equipped", Boolean, default=False))
|
||||
dropped_items = Table("dropped_items", metadata, Column("id", Integer, primary_key=True, autoincrement=True), Column("item_id", String), Column("quantity", Integer, default=1), Column("location_id", String), Column("drop_timestamp", Float))
|
||||
|
||||
# Combat-related tables
|
||||
active_combats = Table(
|
||||
"active_combats",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("player_id", Integer, ForeignKey("players.telegram_id", ondelete="CASCADE"), unique=True),
|
||||
Column("npc_id", String, nullable=False),
|
||||
Column("npc_hp", Integer, nullable=False),
|
||||
Column("npc_max_hp", Integer, nullable=False),
|
||||
Column("turn", String, nullable=False), # "player" or "npc"
|
||||
Column("turn_started_at", Float, nullable=False),
|
||||
Column("player_status_effects", String, default=""), # JSON string
|
||||
Column("npc_status_effects", String, default=""), # JSON string
|
||||
Column("location_id", String, nullable=False),
|
||||
Column("from_wandering_enemy", Boolean, default=False), # If True, respawn on flee/death
|
||||
)
|
||||
|
||||
player_corpses = Table(
|
||||
"player_corpses",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("player_name", String, nullable=False),
|
||||
Column("location_id", String, nullable=False),
|
||||
Column("items", String, nullable=False), # JSON string of items
|
||||
Column("death_timestamp", Float, nullable=False),
|
||||
)
|
||||
|
||||
npc_corpses = Table(
|
||||
"npc_corpses",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("npc_id", String, nullable=False),
|
||||
Column("location_id", String, nullable=False),
|
||||
Column("loot_remaining", String, nullable=False), # JSON string
|
||||
Column("death_timestamp", Float, nullable=False),
|
||||
)
|
||||
|
||||
interactable_cooldowns = Table(
|
||||
"interactable_cooldowns",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("interactable_instance_id", String, nullable=False, unique=True), # Renamed for clarity
|
||||
Column("expiry_timestamp", Float, nullable=False),
|
||||
)
|
||||
|
||||
# Table to cache Telegram file IDs for images
|
||||
image_cache = Table(
|
||||
"image_cache",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("image_path", String, nullable=False, unique=True), # Local file path
|
||||
Column("telegram_file_id", String, nullable=False), # Telegram's file_id for reuse
|
||||
Column("uploaded_at", Float, nullable=False),
|
||||
)
|
||||
|
||||
# Wandering enemies table - managed by spawn system
|
||||
wandering_enemies = Table(
|
||||
"wandering_enemies",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("npc_id", String, nullable=False),
|
||||
Column("location_id", String, nullable=False),
|
||||
Column("spawn_timestamp", Float, nullable=False),
|
||||
Column("despawn_timestamp", Float, nullable=False), # When this enemy should despawn
|
||||
)
|
||||
|
||||
# Persistent status effects table
|
||||
player_status_effects = Table(
|
||||
"player_status_effects",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("player_id", Integer, ForeignKey("players.telegram_id", ondelete="CASCADE"), nullable=False),
|
||||
Column("effect_name", String(50), nullable=False),
|
||||
Column("effect_icon", String(10), nullable=False),
|
||||
Column("damage_per_tick", Integer, nullable=False, default=0),
|
||||
Column("ticks_remaining", Integer, nullable=False),
|
||||
Column("applied_at", Float, nullable=False),
|
||||
)
|
||||
|
||||
async def create_tables():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(metadata.create_all)
|
||||
|
||||
# ... (All other database functions are unchanged except the cooldown ones) ...
|
||||
async def get_player(telegram_id: int = None, player_id: int = None, username: str = None):
|
||||
"""Get player by telegram_id, player_id (web users), or username."""
|
||||
async with engine.connect() as conn:
|
||||
if telegram_id is not None:
|
||||
result = await conn.execute(players.select().where(players.c.telegram_id == telegram_id))
|
||||
elif player_id is not None:
|
||||
result = await conn.execute(players.select().where(players.c.id == player_id))
|
||||
elif username is not None:
|
||||
result = await conn.execute(players.select().where(players.c.username == username))
|
||||
else:
|
||||
return None
|
||||
row = result.first()
|
||||
return row._asdict() if row else None
|
||||
|
||||
async def create_player(telegram_id: int = None, name: str = "Survivor", username: str = None, password_hash: str = None):
|
||||
"""Create a player (Telegram or web user)."""
|
||||
async with engine.connect() as conn:
|
||||
values = {
|
||||
"name": name,
|
||||
"telegram_id": telegram_id,
|
||||
"username": username,
|
||||
"password_hash": password_hash,
|
||||
}
|
||||
result = await conn.execute(players.insert().values(**values))
|
||||
await conn.commit()
|
||||
|
||||
# For telegram users, the primary key is telegram_id
|
||||
# For web users, we need to get the auto-generated id
|
||||
if telegram_id:
|
||||
# Add starting inventory for Telegram users
|
||||
await conn.execute(inventory.insert().values(player_id=telegram_id, item_id="tattered_rucksack", is_equipped=True))
|
||||
await conn.commit()
|
||||
|
||||
# Return the created player
|
||||
if telegram_id:
|
||||
return await get_player(telegram_id=telegram_id)
|
||||
elif username:
|
||||
return await get_player(username=username)
|
||||
|
||||
async def update_player(telegram_id: int = None, player_id: int = None, updates: dict = None):
|
||||
"""Update player by telegram_id (Telegram users) or player_id (web users)."""
|
||||
if updates is None:
|
||||
updates = {}
|
||||
async with engine.connect() as conn:
|
||||
if telegram_id is not None:
|
||||
await conn.execute(players.update().where(players.c.telegram_id == telegram_id).values(**updates))
|
||||
elif player_id is not None:
|
||||
await conn.execute(players.update().where(players.c.id == player_id).values(**updates))
|
||||
else:
|
||||
raise ValueError("Must provide either telegram_id or player_id")
|
||||
await conn.commit()
|
||||
async def get_inventory(player_id: int):
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(inventory.select().where(inventory.c.player_id == player_id))
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
async def get_inventory_item(item_db_id: int):
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(inventory.select().where(inventory.c.id == item_db_id))
|
||||
row = result.first()
|
||||
return row._asdict() if row else None
|
||||
async def add_item_to_inventory(player_id: int, item_id: str, quantity: int = 1):
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(inventory.select().where(inventory.c.player_id == player_id, inventory.c.item_id == item_id))
|
||||
existing_item = result.first()
|
||||
if existing_item: stmt = inventory.update().where(inventory.c.id == existing_item.id).values(quantity=inventory.c.quantity + quantity)
|
||||
else: stmt = inventory.insert().values(player_id=player_id, item_id=item_id, quantity=quantity)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def add_equipped_item_to_inventory(player_id: int, item_id: str) -> int:
|
||||
"""Add a single equipped item to inventory and return its ID."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = inventory.insert().values(
|
||||
player_id=player_id,
|
||||
item_id=item_id,
|
||||
quantity=1,
|
||||
is_equipped=True
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
return result.inserted_primary_key[0]
|
||||
|
||||
async def update_inventory_item(item_db_id: int, quantity: int = None, is_equipped: bool = None):
|
||||
"""Update inventory item properties."""
|
||||
async with engine.connect() as conn:
|
||||
updates = {}
|
||||
if quantity is not None:
|
||||
updates['quantity'] = quantity
|
||||
if is_equipped is not None:
|
||||
updates['is_equipped'] = is_equipped
|
||||
|
||||
if updates:
|
||||
stmt = inventory.update().where(inventory.c.id == item_db_id).values(**updates)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def remove_item_from_inventory(item_db_id: int, quantity: int = 1):
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(inventory.select().where(inventory.c.id == item_db_id))
|
||||
item_data = result.first()
|
||||
if not item_data: return
|
||||
if item_data.quantity > quantity: stmt = inventory.update().where(inventory.c.id == item_db_id).values(quantity=inventory.c.quantity - quantity)
|
||||
else: stmt = inventory.delete().where(inventory.c.id == item_db_id)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
async def drop_item_to_world(item_id: str, quantity: int, location_id: str):
|
||||
"""Drop item to world. Combines with existing stacks of same item in same location."""
|
||||
async with engine.connect() as conn:
|
||||
# Check if this item already exists in this location
|
||||
result = await conn.execute(
|
||||
dropped_items.select().where(
|
||||
(dropped_items.c.item_id == item_id) &
|
||||
(dropped_items.c.location_id == location_id)
|
||||
)
|
||||
)
|
||||
existing_item = result.first()
|
||||
|
||||
if existing_item:
|
||||
# Stack exists, add to it
|
||||
new_quantity = existing_item.quantity + quantity
|
||||
stmt = dropped_items.update().where(dropped_items.c.id == existing_item.id).values(
|
||||
quantity=new_quantity,
|
||||
drop_timestamp=time.time() # Update timestamp
|
||||
)
|
||||
else:
|
||||
# Create new stack
|
||||
stmt = dropped_items.insert().values(
|
||||
item_id=item_id,
|
||||
quantity=quantity,
|
||||
location_id=location_id,
|
||||
drop_timestamp=time.time()
|
||||
)
|
||||
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
async def get_dropped_items_in_location(location_id: str):
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(dropped_items.select().where(dropped_items.c.location_id == location_id).limit(10))
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
async def get_dropped_item(dropped_item_id: int):
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(dropped_items.select().where(dropped_items.c.id == dropped_item_id))
|
||||
row = result.first()
|
||||
return row._asdict() if row else None
|
||||
async def remove_dropped_item(dropped_item_id: int):
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(dropped_items.delete().where(dropped_items.c.id == dropped_item_id))
|
||||
await conn.commit()
|
||||
|
||||
async def update_dropped_item(dropped_item_id: int, new_quantity: int):
|
||||
"""Update the quantity of a dropped item."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = dropped_items.update().where(dropped_items.c.id == dropped_item_id).values(quantity=new_quantity)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def remove_expired_dropped_items(timestamp_limit: float) -> int:
|
||||
async with engine.connect() as conn:
|
||||
stmt = dropped_items.delete().where(dropped_items.c.drop_timestamp < timestamp_limit)
|
||||
result = await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
async def regenerate_all_players_stamina() -> int:
|
||||
"""
|
||||
Regenerate stamina for all active players using a single optimized query.
|
||||
|
||||
Recovery formula:
|
||||
- Base recovery: 1 stamina per cycle (5 minutes)
|
||||
- Endurance bonus: +1 stamina per 10 endurance points
|
||||
- Example: 5 endurance = 1 stamina, 15 endurance = 2 stamina, 25 endurance = 3 stamina
|
||||
- Only regenerates up to max_stamina
|
||||
- Only regenerates for living players
|
||||
|
||||
PERFORMANCE: Single SQL query, scales to 100K+ players efficiently.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
async with engine.connect() as conn:
|
||||
# Single UPDATE query with database-side calculation
|
||||
# Much more efficient than fetching all players and updating individually
|
||||
stmt = text("""
|
||||
UPDATE players
|
||||
SET stamina = LEAST(
|
||||
stamina + 1 + (endurance / 10),
|
||||
max_stamina
|
||||
)
|
||||
WHERE is_dead = FALSE
|
||||
AND stamina < max_stamina
|
||||
""")
|
||||
|
||||
result = await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
COOLDOWN_DURATION = 300
|
||||
async def set_cooldown(instance_id: str):
|
||||
expiry_time = time.time() + COOLDOWN_DURATION
|
||||
async with engine.connect() as conn:
|
||||
update_stmt = interactable_cooldowns.update().where(interactable_cooldowns.c.interactable_instance_id == instance_id).values(expiry_timestamp=expiry_time)
|
||||
result = await conn.execute(update_stmt)
|
||||
if result.rowcount == 0:
|
||||
insert_stmt = interactable_cooldowns.insert().values(interactable_instance_id=instance_id, expiry_timestamp=expiry_time)
|
||||
await conn.execute(insert_stmt)
|
||||
await conn.commit()
|
||||
|
||||
# --- Combat Functions ---
|
||||
async def create_combat(player_id: int, npc_id: str, npc_hp: int, npc_max_hp: int, location_id: str, from_wandering_enemy: bool = False):
|
||||
"""Start a new combat encounter."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = active_combats.insert().values(
|
||||
player_id=player_id,
|
||||
npc_id=npc_id,
|
||||
npc_hp=npc_hp,
|
||||
npc_max_hp=npc_max_hp,
|
||||
turn="player",
|
||||
turn_started_at=time.time(),
|
||||
location_id=location_id,
|
||||
player_status_effects="[]",
|
||||
npc_status_effects="[]",
|
||||
from_wandering_enemy=from_wandering_enemy
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
return result.inserted_primary_key[0]
|
||||
|
||||
async def get_combat(player_id: int):
|
||||
"""Get active combat for a player."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = active_combats.select().where(active_combats.c.player_id == player_id)
|
||||
result = await conn.execute(stmt)
|
||||
row = result.first()
|
||||
return row._asdict() if row else None
|
||||
|
||||
async def update_combat(player_id: int, updates: dict):
|
||||
"""Update combat state."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = active_combats.update().where(active_combats.c.player_id == player_id).values(**updates)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def end_combat(player_id: int):
|
||||
"""Remove active combat."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = active_combats.delete().where(active_combats.c.player_id == player_id)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def get_all_idle_combats(idle_threshold: float):
|
||||
"""Get all combats where the turn has been idle too long."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = active_combats.select().where(active_combats.c.turn_started_at < idle_threshold)
|
||||
result = await conn.execute(stmt)
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
|
||||
async def create_player_corpse(player_name: str, location_id: str, items: str):
|
||||
"""Create a player corpse bag."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = player_corpses.insert().values(
|
||||
player_name=player_name,
|
||||
location_id=location_id,
|
||||
items=items,
|
||||
death_timestamp=time.time()
|
||||
)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def get_player_corpses_in_location(location_id: str):
|
||||
"""Get all player corpses in a location."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = player_corpses.select().where(player_corpses.c.location_id == location_id)
|
||||
result = await conn.execute(stmt)
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
|
||||
async def get_player_corpse(corpse_id: int):
|
||||
"""Get a specific player corpse."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = player_corpses.select().where(player_corpses.c.id == corpse_id)
|
||||
result = await conn.execute(stmt)
|
||||
row = result.first()
|
||||
return row._asdict() if row else None
|
||||
|
||||
async def update_player_corpse(corpse_id: int, items: str):
|
||||
"""Update items in a player corpse."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = player_corpses.update().where(player_corpses.c.id == corpse_id).values(items=items)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def remove_player_corpse(corpse_id: int):
|
||||
"""Remove a player corpse."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = player_corpses.delete().where(player_corpses.c.id == corpse_id)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def remove_expired_player_corpses(timestamp_limit: float) -> int:
|
||||
"""Remove old player corpses."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = player_corpses.delete().where(player_corpses.c.death_timestamp < timestamp_limit)
|
||||
result = await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
async def create_npc_corpse(npc_id: str, location_id: str, loot_remaining: str):
|
||||
"""Create an NPC corpse for scavenging."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = npc_corpses.insert().values(
|
||||
npc_id=npc_id,
|
||||
location_id=location_id,
|
||||
loot_remaining=loot_remaining,
|
||||
death_timestamp=time.time()
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
return result.inserted_primary_key[0]
|
||||
|
||||
async def get_npc_corpses_in_location(location_id: str):
|
||||
"""Get all NPC corpses in a location."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = npc_corpses.select().where(npc_corpses.c.location_id == location_id)
|
||||
result = await conn.execute(stmt)
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
|
||||
async def get_npc_corpse(corpse_id: int):
|
||||
"""Get a specific NPC corpse."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = npc_corpses.select().where(npc_corpses.c.id == corpse_id)
|
||||
result = await conn.execute(stmt)
|
||||
row = result.first()
|
||||
return row._asdict() if row else None
|
||||
|
||||
async def update_npc_corpse(corpse_id: int, loot_remaining: str):
|
||||
"""Update loot in an NPC corpse."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = npc_corpses.update().where(npc_corpses.c.id == corpse_id).values(loot_remaining=loot_remaining)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def remove_npc_corpse(corpse_id: int):
|
||||
"""Remove an NPC corpse."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = npc_corpses.delete().where(npc_corpses.c.id == corpse_id)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def remove_expired_npc_corpses(timestamp_limit: float) -> int:
|
||||
"""Remove old NPC corpses."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = npc_corpses.delete().where(npc_corpses.c.death_timestamp < timestamp_limit)
|
||||
result = await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
async def get_cooldown(instance_id: str) -> int:
|
||||
async with engine.connect() as conn:
|
||||
stmt = interactable_cooldowns.select().where(interactable_cooldowns.c.interactable_instance_id == instance_id)
|
||||
result = await conn.execute(stmt)
|
||||
cooldown = result.first()
|
||||
if cooldown and cooldown.expiry_timestamp > time.time():
|
||||
return int(cooldown.expiry_timestamp - time.time())
|
||||
return 0
|
||||
|
||||
async def get_cooldowns_for_location(location_id: str) -> Set[str]:
|
||||
"""Get all active cooldown instance IDs for a location by checking the prefix."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = interactable_cooldowns.select().where(
|
||||
interactable_cooldowns.c.interactable_instance_id.startswith(location_id + "_"),
|
||||
interactable_cooldowns.c.expiry_timestamp > time.time()
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
return {row.interactable_instance_id for row in result.fetchall()}
|
||||
|
||||
# --- Image Cache Functions ---
|
||||
async def get_cached_image(image_path: str):
|
||||
"""Get the Telegram file_id for a cached image."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = image_cache.select().where(image_cache.c.image_path == image_path)
|
||||
result = await conn.execute(stmt)
|
||||
row = result.first()
|
||||
return row.telegram_file_id if row else None
|
||||
|
||||
async def cache_image(image_path: str, telegram_file_id: str):
|
||||
"""Store a Telegram file_id for an image path."""
|
||||
async with engine.connect() as conn:
|
||||
# Check if already exists
|
||||
stmt = image_cache.select().where(image_cache.c.image_path == image_path)
|
||||
result = await conn.execute(stmt)
|
||||
existing = result.first()
|
||||
|
||||
if existing:
|
||||
# Update existing entry
|
||||
update_stmt = image_cache.update().where(
|
||||
image_cache.c.image_path == image_path
|
||||
).values(telegram_file_id=telegram_file_id, uploaded_at=time.time())
|
||||
await conn.execute(update_stmt)
|
||||
else:
|
||||
# Insert new entry
|
||||
insert_stmt = image_cache.insert().values(
|
||||
image_path=image_path,
|
||||
telegram_file_id=telegram_file_id,
|
||||
uploaded_at=time.time()
|
||||
)
|
||||
await conn.execute(insert_stmt)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
# --- Wandering Enemies Functions ---
|
||||
async def spawn_wandering_enemy(npc_id: str, location_id: str, lifetime_seconds: int = 600):
|
||||
"""Spawn a wandering enemy at a location. Lifetime defaults to 10 minutes."""
|
||||
async with engine.connect() as conn:
|
||||
current_time = time.time()
|
||||
despawn_time = current_time + lifetime_seconds
|
||||
|
||||
await conn.execute(wandering_enemies.insert().values(
|
||||
npc_id=npc_id,
|
||||
location_id=location_id,
|
||||
spawn_timestamp=current_time,
|
||||
despawn_timestamp=despawn_time
|
||||
))
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def get_wandering_enemies_in_location(location_id: str):
|
||||
"""Get all active wandering enemies at a location."""
|
||||
async with engine.connect() as conn:
|
||||
current_time = time.time()
|
||||
stmt = wandering_enemies.select().where(
|
||||
wandering_enemies.c.location_id == location_id,
|
||||
wandering_enemies.c.despawn_timestamp > current_time
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
|
||||
|
||||
async def remove_wandering_enemy(enemy_id: int):
|
||||
"""Remove a wandering enemy (when engaged in combat or manually despawned)."""
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(wandering_enemies.delete().where(wandering_enemies.c.id == enemy_id))
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def cleanup_expired_wandering_enemies():
|
||||
"""Remove all expired wandering enemies."""
|
||||
async with engine.connect() as conn:
|
||||
current_time = time.time()
|
||||
result = await conn.execute(
|
||||
wandering_enemies.delete().where(wandering_enemies.c.despawn_timestamp <= current_time)
|
||||
)
|
||||
await conn.commit()
|
||||
return result.rowcount # Number of enemies despawned
|
||||
|
||||
|
||||
async def get_wandering_enemy_count_in_location(location_id: str) -> int:
|
||||
"""Count active wandering enemies at a location."""
|
||||
async with engine.connect() as conn:
|
||||
current_time = time.time()
|
||||
from sqlalchemy import func
|
||||
stmt = wandering_enemies.select().where(
|
||||
wandering_enemies.c.location_id == location_id,
|
||||
wandering_enemies.c.despawn_timestamp > current_time
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
return len(result.fetchall())
|
||||
|
||||
|
||||
async def get_all_active_wandering_enemies():
|
||||
"""Get all active wandering enemies across all locations."""
|
||||
async with engine.connect() as conn:
|
||||
current_time = time.time()
|
||||
stmt = wandering_enemies.select().where(
|
||||
wandering_enemies.c.despawn_timestamp > current_time
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STATUS EFFECTS
|
||||
# ============================================================================
|
||||
|
||||
async def get_player_status_effects(player_id: int):
|
||||
"""Get all active status effects for a player."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = player_status_effects.select().where(
|
||||
player_status_effects.c.player_id == player_id,
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
|
||||
|
||||
async def add_status_effect(player_id: int, effect_name: str, effect_icon: str,
|
||||
damage_per_tick: int, ticks_remaining: int):
|
||||
"""Add a new status effect to a player."""
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(
|
||||
player_status_effects.insert().values(
|
||||
player_id=player_id,
|
||||
effect_name=effect_name,
|
||||
effect_icon=effect_icon,
|
||||
damage_per_tick=damage_per_tick,
|
||||
ticks_remaining=ticks_remaining,
|
||||
applied_at=time.time()
|
||||
)
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def update_status_effect_ticks(effect_id: int, ticks_remaining: int):
|
||||
"""Update the remaining ticks for a status effect."""
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(
|
||||
player_status_effects.update().where(
|
||||
player_status_effects.c.id == effect_id
|
||||
).values(ticks_remaining=ticks_remaining)
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def remove_status_effect(effect_id: int):
|
||||
"""Remove a specific status effect."""
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(
|
||||
player_status_effects.delete().where(player_status_effects.c.id == effect_id)
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def remove_all_status_effects(player_id: int):
|
||||
"""Remove all status effects from a player."""
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(
|
||||
player_status_effects.delete().where(player_status_effects.c.player_id == player_id)
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def remove_status_effects_by_name(player_id: int, effect_name: str, count: int = 1):
|
||||
"""
|
||||
Remove a specific number of status effects by name for a player.
|
||||
Used for treatment items that cure specific effects.
|
||||
Returns the number of effects actually removed.
|
||||
"""
|
||||
async with engine.connect() as conn:
|
||||
# Get the effects to remove
|
||||
stmt = player_status_effects.select().where(
|
||||
player_status_effects.c.player_id == player_id,
|
||||
player_status_effects.c.effect_name == effect_name,
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
).limit(count)
|
||||
result = await conn.execute(stmt)
|
||||
effects_to_remove = result.fetchall()
|
||||
|
||||
# Remove them
|
||||
effect_ids = [row.id for row in effects_to_remove]
|
||||
if effect_ids:
|
||||
await conn.execute(
|
||||
player_status_effects.delete().where(
|
||||
player_status_effects.c.id.in_(effect_ids)
|
||||
)
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
return len(effect_ids)
|
||||
|
||||
|
||||
async def get_all_players_with_status_effects():
|
||||
"""Get all player IDs that have active status effects (for background processing)."""
|
||||
async with engine.connect() as conn:
|
||||
from sqlalchemy import distinct
|
||||
stmt = player_status_effects.select().with_only_columns(
|
||||
distinct(player_status_effects.c.player_id)
|
||||
).where(player_status_effects.c.ticks_remaining > 0)
|
||||
result = await conn.execute(stmt)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def decrement_all_status_effect_ticks():
|
||||
"""
|
||||
Decrement ticks for all active status effects and return affected player IDs.
|
||||
Used by background processor.
|
||||
"""
|
||||
async with engine.connect() as conn:
|
||||
# Get player IDs with effects before updating
|
||||
from sqlalchemy import distinct
|
||||
stmt = player_status_effects.select().with_only_columns(
|
||||
distinct(player_status_effects.c.player_id)
|
||||
).where(player_status_effects.c.ticks_remaining > 0)
|
||||
result = await conn.execute(stmt)
|
||||
affected_players = [row[0] for row in result.fetchall()]
|
||||
|
||||
# Decrement ticks
|
||||
await conn.execute(
|
||||
player_status_effects.update().where(
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
).values(ticks_remaining=player_status_effects.c.ticks_remaining - 1)
|
||||
)
|
||||
|
||||
# Remove expired effects
|
||||
await conn.execute(
|
||||
player_status_effects.delete().where(player_status_effects.c.ticks_remaining <= 0)
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
return affected_players
|
||||
174
bot/handlers.py
174
bot/handlers.py
@@ -1,174 +0,0 @@
|
||||
"""
|
||||
Main handlers for the Telegram bot.
|
||||
This module contains the core button callback routing.
|
||||
All other functionality is organized in separate modules:
|
||||
- action_handlers.py - World interaction handlers
|
||||
- inventory_handlers.py - Inventory management
|
||||
- combat_handlers.py - Combat actions
|
||||
- profile_handlers.py - Character stats
|
||||
- corpse_handlers.py - Looting system
|
||||
- pickup_handlers.py - Item collection
|
||||
- message_utils.py - Message sending/editing utilities
|
||||
- commands.py - Slash command handlers
|
||||
"""
|
||||
import logging
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from .message_utils import send_or_edit_with_image
|
||||
|
||||
# Import organized action handlers
|
||||
from .action_handlers import (
|
||||
handle_inspect_area,
|
||||
handle_attack_wandering,
|
||||
handle_inspect_interactable,
|
||||
handle_action,
|
||||
handle_main_menu,
|
||||
handle_move_menu,
|
||||
handle_move
|
||||
)
|
||||
from .inventory_handlers import (
|
||||
handle_inventory_menu,
|
||||
handle_inventory_item,
|
||||
handle_inventory_use,
|
||||
handle_inventory_drop,
|
||||
handle_inventory_equip,
|
||||
handle_inventory_unequip
|
||||
)
|
||||
from .pickup_handlers import (
|
||||
handle_pickup_menu,
|
||||
handle_pickup
|
||||
)
|
||||
from .combat_handlers import (
|
||||
handle_combat_attack,
|
||||
handle_combat_flee,
|
||||
handle_combat_use_item_menu,
|
||||
handle_combat_use_item,
|
||||
handle_combat_back
|
||||
)
|
||||
from .profile_handlers import (
|
||||
handle_profile,
|
||||
handle_spend_points_menu,
|
||||
handle_spend_point
|
||||
)
|
||||
from .corpse_handlers import (
|
||||
handle_loot_player_corpse,
|
||||
handle_take_corpse_item,
|
||||
handle_scavenge_npc_corpse,
|
||||
handle_scavenge_corpse_item
|
||||
)
|
||||
|
||||
# Import command handlers (for main.py to register)
|
||||
from .commands import start, export_map, spawn_stats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HANDLER REGISTRY
|
||||
# ============================================================================
|
||||
|
||||
# Map of action types to their handler functions
|
||||
# All handlers have signature: async def handle_*(query, user_id, player, data=None)
|
||||
HANDLER_MAP = {
|
||||
# Inspection & World Interaction
|
||||
'inspect_area': handle_inspect_area,
|
||||
'inspect_area_menu': handle_inspect_area,
|
||||
'attack_wandering': handle_attack_wandering,
|
||||
'inspect': handle_inspect_interactable,
|
||||
'action': handle_action,
|
||||
|
||||
# Navigation & Menu
|
||||
'main_menu': handle_main_menu,
|
||||
'move_menu': handle_move_menu,
|
||||
'move': handle_move,
|
||||
|
||||
# Profile & Stats
|
||||
'profile': handle_profile,
|
||||
'spend_points_menu': handle_spend_points_menu,
|
||||
'spend_point': handle_spend_point,
|
||||
|
||||
# Inventory Management
|
||||
'inventory_menu': handle_inventory_menu,
|
||||
'inventory_item': handle_inventory_item,
|
||||
'inventory_use': handle_inventory_use,
|
||||
'inventory_drop': handle_inventory_drop,
|
||||
'inventory_equip': handle_inventory_equip,
|
||||
'inventory_unequip': handle_inventory_unequip,
|
||||
|
||||
# Item Pickup
|
||||
'pickup_menu': handle_pickup_menu,
|
||||
'pickup': handle_pickup,
|
||||
|
||||
# Combat Actions
|
||||
'combat_attack': handle_combat_attack,
|
||||
'combat_flee': handle_combat_flee,
|
||||
'combat_use_item_menu': handle_combat_use_item_menu,
|
||||
'combat_use_item': handle_combat_use_item,
|
||||
'combat_back': handle_combat_back,
|
||||
|
||||
# Corpse Looting
|
||||
'loot_player_corpse': handle_loot_player_corpse,
|
||||
'take_corpse_item': handle_take_corpse_item,
|
||||
'scavenge_npc_corpse': handle_scavenge_npc_corpse,
|
||||
'scavenge_corpse_item': handle_scavenge_corpse_item,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BUTTON CALLBACK ROUTER
|
||||
# ============================================================================
|
||||
|
||||
async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""
|
||||
Main router for button callbacks.
|
||||
Delegates to specific handler functions based on action type.
|
||||
All handlers have a unified signature: (query, user_id, player, data=None)
|
||||
|
||||
Note: user_id passed to handlers is actually the player's unique DB id (not telegram_id)
|
||||
"""
|
||||
from .api_client import api_client
|
||||
|
||||
query = update.callback_query
|
||||
telegram_id = query.from_user.id
|
||||
data = query.data.split(':')
|
||||
action_type = data[0]
|
||||
|
||||
# Get player by telegram_id and translate to unique id
|
||||
player = await api_client.get_player(telegram_id)
|
||||
if not player or player['is_dead']:
|
||||
await query.answer()
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="💀 Your journey has ended. You died in the wasteland. Create a new character with /start to begin again.",
|
||||
reply_markup=None
|
||||
)
|
||||
return
|
||||
|
||||
# From now on, use player's unique database id
|
||||
user_id = player['id']
|
||||
|
||||
# Check if player is in combat - restrict most actions
|
||||
combat = await api_client.get_combat(user_id)
|
||||
allowed_in_combat = {
|
||||
'combat_attack', 'combat_flee', 'combat_use_item_menu',
|
||||
'combat_use_item', 'combat_back', 'no_op'
|
||||
}
|
||||
if combat and action_type not in allowed_in_combat:
|
||||
await query.answer("You're in combat! Focus on the fight!", show_alert=False)
|
||||
return
|
||||
|
||||
# Route to appropriate handler
|
||||
if action_type == 'no_op':
|
||||
await query.answer()
|
||||
return
|
||||
|
||||
handler = HANDLER_MAP.get(action_type)
|
||||
if handler:
|
||||
try:
|
||||
await handler(query, user_id, player, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling button action {action_type}: {e}", exc_info=True)
|
||||
await query.answer("An error occurred. Please try again.", show_alert=True)
|
||||
else:
|
||||
logger.warning(f"Unknown action type: {action_type}")
|
||||
await query.answer("Unknown action", show_alert=False)
|
||||
@@ -1,338 +0,0 @@
|
||||
"""
|
||||
Inventory-related action handlers.
|
||||
"""
|
||||
import logging
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from . import keyboards, logic
|
||||
from data.world_loader import game_world
|
||||
from data.items import ITEMS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_inventory_menu(query, user_id: int, player: dict, data: list = None):
|
||||
"""Display player inventory with item management options."""
|
||||
from .utils import format_stat_bar
|
||||
from .api_client import api_client
|
||||
await query.answer()
|
||||
|
||||
# Get inventory from API
|
||||
inv_result = await api_client.get_inventory(player['id'])
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
current_weight, current_volume = logic.calculate_inventory_load(inventory_items)
|
||||
max_weight, max_volume = logic.get_player_capacity(inventory_items, player)
|
||||
|
||||
text = "<b>🎒 Your Inventory:</b>\n"
|
||||
text += f"{format_stat_bar('HP', '❤️', player['hp'], player['max_hp'])}\n"
|
||||
text += f"{format_stat_bar('Stamina', '⚡', player['stamina'], player['max_stamina'])}\n"
|
||||
text += f"📊 Weight: {current_weight}/{max_weight} kg\n"
|
||||
text += f"📦 Volume: {current_volume}/{max_volume} vol\n"
|
||||
|
||||
if not inventory_items:
|
||||
text += "\n<i>Your inventory is empty.</i>"
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboards.inventory_keyboard(inventory_items),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_inventory_item(query, user_id: int, player: dict, data: list):
|
||||
"""Show details for a specific inventory item.
|
||||
|
||||
Note: item_db_id is the inventory row id from the API response.
|
||||
We need to get the full inventory and find the item by id.
|
||||
"""
|
||||
from .api_client import api_client
|
||||
|
||||
await query.answer()
|
||||
item_db_id = int(data[1])
|
||||
|
||||
# Get inventory from API
|
||||
inv_result = await api_client.get_inventory(user_id)
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
|
||||
# Find the specific item
|
||||
item = next((i for i in inventory_items if i['id'] == item_db_id), None)
|
||||
if not item:
|
||||
await query.answer("Item not found in inventory", show_alert=True)
|
||||
return
|
||||
|
||||
emoji = item.get('emoji', '❔')
|
||||
|
||||
# Build item details text
|
||||
text = f"{emoji} <b>{item.get('name', 'Unknown')}</b>\n"
|
||||
|
||||
description = item.get('description')
|
||||
if description:
|
||||
text += f"<i>{description}</i>\n\n"
|
||||
else:
|
||||
text += "\n"
|
||||
|
||||
text += f"<b>Weight:</b> {item.get('weight', 0)} kg | <b>Volume:</b> {item.get('volume', 0)} vol\n"
|
||||
|
||||
# Add weapon stats if applicable
|
||||
if item.get('type') == 'weapon':
|
||||
text += f"<b>Damage:</b> {item.get('damage_min', 0)}-{item.get('damage_max', 0)}\n"
|
||||
|
||||
# Add consumable effects if applicable
|
||||
if item.get('type') == 'consumable':
|
||||
effects = []
|
||||
if item.get('hp_restore'):
|
||||
effects.append(f"❤️ +{item.get('hp_restore')} HP")
|
||||
if item.get('stamina_restore'):
|
||||
effects.append(f"⚡ +{item.get('stamina_restore')} Stamina")
|
||||
if effects:
|
||||
text += f"<b>Effects:</b> {', '.join(effects)}\n"
|
||||
|
||||
# Add equipped status
|
||||
if item.get('is_equipped'):
|
||||
text += "\n✅ <b>Currently Equipped</b>"
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboards.inventory_item_actions_keyboard(
|
||||
item_db_id, item, item.get('is_equipped', False), item['quantity']
|
||||
),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_inventory_use(query, user_id: int, player: dict, data: list):
|
||||
"""Use a consumable item from inventory."""
|
||||
from .utils import format_stat_bar
|
||||
from .api_client import api_client
|
||||
|
||||
item_db_id = int(data[1])
|
||||
|
||||
# Get inventory from API to find the item
|
||||
inv_result = await api_client.get_inventory(user_id)
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
item = next((i for i in inventory_items if i['id'] == item_db_id), None)
|
||||
|
||||
if not item:
|
||||
await query.answer("Item not found.", show_alert=False)
|
||||
return
|
||||
|
||||
if item.get('type') != 'consumable':
|
||||
await query.answer("This item cannot be used.", show_alert=False)
|
||||
return
|
||||
|
||||
await query.answer()
|
||||
|
||||
# Use the API to use the item
|
||||
result = await api_client.use_item(user_id, item['item_id'])
|
||||
|
||||
if not result.get('success'):
|
||||
await query.answer(result.get('message', 'Failed to use item'), show_alert=True)
|
||||
return
|
||||
|
||||
# Refresh player data to get updated stats
|
||||
player = await api_client.get_player_by_id(user_id)
|
||||
|
||||
# Get updated inventory
|
||||
inv_result = await api_client.get_inventory(user_id)
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
current_weight, current_volume = logic.calculate_inventory_load(inventory_items)
|
||||
max_weight, max_volume = logic.get_player_capacity(inventory_items, player)
|
||||
|
||||
# Build status section with HP/Stamina bars
|
||||
text = "<b>🎒 Your Inventory:</b>\n"
|
||||
text += f"{format_stat_bar('HP', '❤️', player['hp'], player['max_hp'])}\n"
|
||||
text += f"{format_stat_bar('Stamina', '⚡', player['stamina'], player['max_stamina'])}\n"
|
||||
text += f"📊 Weight: {current_weight}/{max_weight} kg\n"
|
||||
text += f"📦 Volume: {current_volume}/{max_volume} vol\n"
|
||||
text += "━━━━━━━━━━━━━━━━━━━━\n"
|
||||
|
||||
# Build result message from API response
|
||||
text += result.get('message', 'Item used.')
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboards.inventory_keyboard(inventory_items),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_inventory_drop(query, user_id: int, player: dict, data: list):
|
||||
"""Drop an item from inventory to the world."""
|
||||
from .api_client import api_client
|
||||
|
||||
item_db_id = int(data[1])
|
||||
drop_amount_str = data[2] if len(data) > 2 else None
|
||||
|
||||
# Get inventory to find the item
|
||||
inv_result = await api_client.get_inventory(user_id)
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
item = next((i for i in inventory_items if i['id'] == item_db_id), None)
|
||||
|
||||
if not item:
|
||||
await query.answer("Item not found.", show_alert=False)
|
||||
return
|
||||
|
||||
# Determine how much to drop
|
||||
if drop_amount_str is None or drop_amount_str == "all":
|
||||
drop_amount = item['quantity']
|
||||
else:
|
||||
drop_amount = min(int(drop_amount_str), item['quantity'])
|
||||
|
||||
# Use API to drop item
|
||||
result = await api_client.drop_item(user_id, item['item_id'], drop_amount)
|
||||
|
||||
if result.get('success'):
|
||||
await query.answer(result.get('message', f"Dropped {drop_amount}x {item['name']}"), show_alert=False)
|
||||
else:
|
||||
await query.answer(result.get('message', 'Failed to drop item'), show_alert=True)
|
||||
return
|
||||
|
||||
# Get updated inventory
|
||||
inv_result = await api_client.get_inventory(user_id)
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
current_weight, current_volume = logic.calculate_inventory_load(inventory_items)
|
||||
max_weight, max_volume = logic.get_player_capacity(inventory_items, player)
|
||||
|
||||
text = "<b>🎒 Your Inventory:</b>\n"
|
||||
text += f"📊 Weight: {current_weight}/{max_weight} kg\n"
|
||||
text += f"📦 Volume: {current_volume}/{max_volume} vol\n\n"
|
||||
|
||||
if not inventory_items:
|
||||
text += "It's empty."
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboards.inventory_keyboard(inventory_items),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_inventory_equip(query, user_id: int, player: dict, data: list):
|
||||
"""Equip an item from inventory."""
|
||||
from .api_client import api_client
|
||||
|
||||
item_db_id = int(data[1])
|
||||
|
||||
# Get inventory to find the item
|
||||
inv_result = await api_client.get_inventory(user_id)
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
item = next((i for i in inventory_items if i['id'] == item_db_id), None)
|
||||
|
||||
if not item:
|
||||
await query.answer("Item not found.", show_alert=False)
|
||||
return
|
||||
|
||||
if not item.get('equippable'):
|
||||
await query.answer("This item cannot be equipped.", show_alert=False)
|
||||
return
|
||||
|
||||
# Use API to equip item
|
||||
result = await api_client.equip_item(user_id, item['item_id'])
|
||||
|
||||
if not result.get('success'):
|
||||
await query.answer(result.get('message', 'Failed to equip item'), show_alert=True)
|
||||
return
|
||||
|
||||
await query.answer(result.get('message', f"Equipped {item['name']}!"), show_alert=False)
|
||||
|
||||
# Refresh the item view
|
||||
emoji = item.get('emoji', '❔')
|
||||
text = f"{emoji} <b>{item.get('name', 'Unknown')}</b>\n"
|
||||
|
||||
description = item.get('description')
|
||||
if description:
|
||||
text += f"<i>{description}</i>\n\n"
|
||||
else:
|
||||
text += "\n"
|
||||
|
||||
text += f"<b>Weight:</b> {item.get('weight', 0)} kg | <b>Volume:</b> {item.get('volume', 0)} vol\n"
|
||||
|
||||
if item.get('type') == 'weapon':
|
||||
text += f"<b>Damage:</b> {item.get('damage_min', 0)}-{item.get('damage_max', 0)}\n"
|
||||
|
||||
text += "\n✅ <b>Currently Equipped</b>"
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboards.inventory_item_actions_keyboard(
|
||||
item_db_id, item, True, item['quantity']
|
||||
),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_inventory_unequip(query, user_id: int, player: dict, data: list):
|
||||
"""Unequip an item."""
|
||||
from .api_client import api_client
|
||||
|
||||
item_db_id = int(data[1])
|
||||
|
||||
# Get inventory to find the item
|
||||
inv_result = await api_client.get_inventory(user_id)
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
item = next((i for i in inventory_items if i['id'] == item_db_id), None)
|
||||
|
||||
if not item:
|
||||
await query.answer("Item not found.", show_alert=False)
|
||||
return
|
||||
|
||||
# Use API to unequip item
|
||||
result = await api_client.unequip_item(user_id, item['item_id'])
|
||||
|
||||
if not result.get('success'):
|
||||
await query.answer(result.get('message', 'Failed to unequip item'), show_alert=True)
|
||||
return
|
||||
|
||||
await query.answer(result.get('message', f"Unequipped {item['name']}."), show_alert=False)
|
||||
|
||||
# Refresh the item view
|
||||
emoji = item.get('emoji', '❔')
|
||||
text = f"{emoji} <b>{item.get('name', 'Unknown')}</b>\n"
|
||||
|
||||
description = item.get('description')
|
||||
if description:
|
||||
text += f"<i>{description}</i>\n\n"
|
||||
else:
|
||||
text += "\n"
|
||||
|
||||
text += f"<b>Weight:</b> {item.get('weight', 0)} kg | <b>Volume:</b> {item.get('volume', 0)} vol\n"
|
||||
|
||||
if item.get('type') == 'weapon':
|
||||
text += f"<b>Damage:</b> {item.get('damage_min', 0)}-{item.get('damage_max', 0)}\n"
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboards.inventory_item_actions_keyboard(
|
||||
item_db_id, item, False, item['quantity']
|
||||
),
|
||||
image_path=location_image
|
||||
)
|
||||
607
bot/keyboards.py
607
bot/keyboards.py
@@ -1,607 +0,0 @@
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from data.world_loader import game_world
|
||||
from data.items import ITEMS
|
||||
|
||||
# ... (main_menu_keyboard, move_keyboard are unchanged) ...
|
||||
def main_menu_keyboard() -> InlineKeyboardMarkup:
|
||||
keyboard = [[InlineKeyboardButton("🗺️ Move", callback_data="move_menu"), InlineKeyboardButton("👀 Inspect Area", callback_data="inspect_area")], [InlineKeyboardButton("👤 Profile", callback_data="profile"), InlineKeyboardButton("🎒 Inventory", callback_data="inventory_menu")]]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def move_keyboard(current_location_id: str, player_id: int) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create a movement keyboard with stamina costs.
|
||||
Layout:
|
||||
[ North (⚡5) ]
|
||||
[ West (⚡5) ] [ East (⚡5) ]
|
||||
[ South (⚡5) ]
|
||||
[ Other exits (inside, down, etc.) ]
|
||||
[ Back ]
|
||||
"""
|
||||
from bot import logic
|
||||
from bot.api_client import api_client
|
||||
|
||||
keyboard = []
|
||||
location = game_world.get_location(current_location_id)
|
||||
player = await api_client.get_player(player_id)
|
||||
inventory = await api_client.get_inventory(player_id)
|
||||
|
||||
if location and player:
|
||||
# Dictionary to hold direction buttons
|
||||
compass_directions = {}
|
||||
other_exits = []
|
||||
|
||||
for direction, destination_id in location.exits.items():
|
||||
destination = game_world.get_location(destination_id)
|
||||
if destination:
|
||||
# Calculate stamina cost for this specific route
|
||||
stamina_cost = logic.calculate_travel_stamina_cost(player, inventory, location, destination)
|
||||
|
||||
# Map direction to emoji and label
|
||||
direction_lower = direction.lower()
|
||||
if direction_lower == "north":
|
||||
emoji = "⬆️"
|
||||
compass_directions["north"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "south":
|
||||
emoji = "⬇️"
|
||||
compass_directions["south"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "east":
|
||||
emoji = "➡️"
|
||||
compass_directions["east"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "west":
|
||||
emoji = "⬅️"
|
||||
compass_directions["west"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "northeast":
|
||||
emoji = "↗️"
|
||||
compass_directions["northeast"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "northwest":
|
||||
emoji = "↖️"
|
||||
compass_directions["northwest"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "southeast":
|
||||
emoji = "↘️"
|
||||
compass_directions["southeast"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "southwest":
|
||||
emoji = "↙️"
|
||||
compass_directions["southwest"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "inside":
|
||||
emoji = "🚪"
|
||||
other_exits.append(InlineKeyboardButton(
|
||||
f"{emoji} Enter {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
))
|
||||
elif direction_lower == "outside":
|
||||
emoji = "🚪"
|
||||
other_exits.append(InlineKeyboardButton(
|
||||
f"{emoji} Exit to {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
))
|
||||
elif direction_lower == "down":
|
||||
emoji = "⬇️"
|
||||
other_exits.append(InlineKeyboardButton(
|
||||
f"{emoji} Descend to {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
))
|
||||
elif direction_lower == "up":
|
||||
emoji = "⬆️"
|
||||
other_exits.append(InlineKeyboardButton(
|
||||
f"{emoji} Ascend to {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
))
|
||||
else:
|
||||
# Generic fallback for any other direction
|
||||
emoji = "🔀"
|
||||
other_exits.append(InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
))
|
||||
|
||||
# Build compass layout
|
||||
# Row 1: Northwest, North, Northeast
|
||||
top_row = []
|
||||
if "northwest" in compass_directions:
|
||||
top_row.append(compass_directions["northwest"])
|
||||
if "north" in compass_directions:
|
||||
top_row.append(compass_directions["north"])
|
||||
if "northeast" in compass_directions:
|
||||
top_row.append(compass_directions["northeast"])
|
||||
if top_row:
|
||||
keyboard.append(top_row)
|
||||
|
||||
# Row 2: West and/or East
|
||||
middle_row = []
|
||||
if "west" in compass_directions:
|
||||
middle_row.append(compass_directions["west"])
|
||||
if "east" in compass_directions:
|
||||
middle_row.append(compass_directions["east"])
|
||||
if middle_row:
|
||||
keyboard.append(middle_row)
|
||||
|
||||
# Row 3: Southwest, South, Southeast
|
||||
bottom_row = []
|
||||
if "southwest" in compass_directions:
|
||||
bottom_row.append(compass_directions["southwest"])
|
||||
if "south" in compass_directions:
|
||||
bottom_row.append(compass_directions["south"])
|
||||
if "southeast" in compass_directions:
|
||||
bottom_row.append(compass_directions["southeast"])
|
||||
if bottom_row:
|
||||
keyboard.append(bottom_row)
|
||||
|
||||
# Add other exits (inside, outside, up, down, etc.)
|
||||
for exit_button in other_exits:
|
||||
keyboard.append([exit_button])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="main_menu")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def inspect_keyboard(location_id: str, dropped_items: list, wandering_enemies: list = None) -> InlineKeyboardMarkup:
|
||||
from bot.api_client import api_client
|
||||
from data.npcs import NPCS
|
||||
|
||||
keyboard = []
|
||||
location = game_world.get_location(location_id)
|
||||
|
||||
# Show wandering enemies first if present (in pairs, emoji only)
|
||||
if wandering_enemies:
|
||||
row = []
|
||||
for enemy in wandering_enemies:
|
||||
npc_def = NPCS.get(enemy['npc_id'])
|
||||
if npc_def:
|
||||
button = InlineKeyboardButton(
|
||||
f"⚠️ {npc_def.emoji} {npc_def.name}",
|
||||
callback_data=f"attack_wandering:{enemy['id']}"
|
||||
)
|
||||
row.append(button)
|
||||
if len(row) == 2:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row: # Add remaining enemy if odd number
|
||||
keyboard.append(row)
|
||||
if wandering_enemies:
|
||||
keyboard.append([InlineKeyboardButton("--- Environment ---", callback_data="no_op")])
|
||||
|
||||
# Show interactables in pairs when text is short enough
|
||||
if location:
|
||||
row = []
|
||||
for instance_id, interactable in location.interactables.items():
|
||||
label = interactable.name
|
||||
# Check if ANY action is available (not on cooldown)
|
||||
has_available_action = False
|
||||
for action_id in interactable.actions.keys():
|
||||
cooldown_key = f"{instance_id}:{action_id}"
|
||||
if await api_client.get_cooldown(cooldown_key) == 0:
|
||||
has_available_action = True
|
||||
break
|
||||
if not has_available_action and len(interactable.actions) > 0:
|
||||
label += " ⏳"
|
||||
|
||||
# Include location_id in callback data for efficient lookup
|
||||
button = InlineKeyboardButton(label, callback_data=f"inspect:{location_id}:{instance_id}")
|
||||
|
||||
# If text is short (< 20 chars), try to pair it
|
||||
if len(label) < 20:
|
||||
row.append(button)
|
||||
if len(row) == 2:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
else:
|
||||
# Long text, add any pending row first, then add this one alone
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
keyboard.append([button])
|
||||
|
||||
# Add remaining button if odd number
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
|
||||
# Show player corpse bags
|
||||
player_corpses = await api_client.get_player_corpses_in_location(location_id)
|
||||
if player_corpses:
|
||||
keyboard.append([InlineKeyboardButton("--- Fallen survivors ---", callback_data="no_op")])
|
||||
row = []
|
||||
for corpse in player_corpses:
|
||||
button = InlineKeyboardButton(
|
||||
f"🎒 {corpse['player_name']}'s bag",
|
||||
callback_data=f"loot_player_corpse:{corpse['id']}"
|
||||
)
|
||||
row.append(button)
|
||||
if len(row) == 2:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
|
||||
# Show NPC corpses
|
||||
npc_corpses = await api_client.get_npc_corpses_in_location(location_id)
|
||||
if npc_corpses:
|
||||
if not player_corpses: # Only add separator if not already added
|
||||
keyboard.append([InlineKeyboardButton("--- Corpses ---", callback_data="no_op")])
|
||||
row = []
|
||||
for corpse in npc_corpses:
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(corpse['npc_id'])
|
||||
if npc_def:
|
||||
button = InlineKeyboardButton(
|
||||
f"{npc_def.emoji} {npc_def.name}",
|
||||
callback_data=f"scavenge_npc_corpse:{corpse['id']}"
|
||||
)
|
||||
row.append(button)
|
||||
if len(row) == 2:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
|
||||
if dropped_items:
|
||||
keyboard.append([InlineKeyboardButton("--- Items on the ground ---", callback_data="no_op")])
|
||||
row = []
|
||||
for item in dropped_items:
|
||||
item_def = ITEMS.get(item['item_id'], {})
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
quantity_text = f" x{item['quantity']}" if item['quantity'] > 1 else ""
|
||||
button = InlineKeyboardButton(
|
||||
f"{emoji} {item_def.get('name', 'Unknown')}{quantity_text}",
|
||||
callback_data=f"pickup_menu:{item['id']}"
|
||||
)
|
||||
row.append(button)
|
||||
if len(row) == 2:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="main_menu")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
def pickup_options_keyboard(item_id: int, item_name: str, quantity: int) -> InlineKeyboardMarkup:
|
||||
"""Create pickup options keyboard with x1, x5, x10, and All options."""
|
||||
keyboard = []
|
||||
|
||||
if quantity == 1:
|
||||
# Just show a single "Pick" button for single items
|
||||
keyboard.append([InlineKeyboardButton("📦 Pick", callback_data=f"pickup:{item_id}:1")])
|
||||
else:
|
||||
# Build pickup row with available options
|
||||
pickup_row = [InlineKeyboardButton("📦 Pick x1", callback_data=f"pickup:{item_id}:1")]
|
||||
|
||||
if quantity >= 5:
|
||||
pickup_row.append(InlineKeyboardButton("📦 Pick x5", callback_data=f"pickup:{item_id}:5"))
|
||||
if quantity >= 10:
|
||||
pickup_row.append(InlineKeyboardButton("📦 Pick x10", callback_data=f"pickup:{item_id}:10"))
|
||||
|
||||
# Split into rows if more than 2 buttons
|
||||
if len(pickup_row) > 2:
|
||||
keyboard.append(pickup_row[:2])
|
||||
keyboard.append(pickup_row[2:])
|
||||
else:
|
||||
keyboard.append(pickup_row)
|
||||
|
||||
# Add "Pick All" option
|
||||
keyboard.append([InlineKeyboardButton(f"📦 Pick All ({quantity})", callback_data=f"pickup:{item_id}:all")])
|
||||
|
||||
# Back button
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="inspect_area")])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def actions_keyboard(location_id: str, instance_id: str) -> InlineKeyboardMarkup:
|
||||
from bot.api_client import api_client
|
||||
keyboard = []
|
||||
|
||||
location = game_world.get_location(location_id)
|
||||
|
||||
if location:
|
||||
interactable = location.get_interactable(instance_id)
|
||||
if interactable:
|
||||
for action_id, action in interactable.actions.items():
|
||||
cooldown_key = f"{instance_id}:{action_id}"
|
||||
cooldown = await api_client.get_cooldown(cooldown_key)
|
||||
label = action.label
|
||||
# Add stamina cost to the label
|
||||
if action.stamina_cost > 0:
|
||||
label += f" (⚡{action.stamina_cost})"
|
||||
if cooldown > 0:
|
||||
label += " ⏳"
|
||||
keyboard.append([InlineKeyboardButton(label, callback_data=f"action:{location_id}:{instance_id}:{action_id}")])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data=f"inspect_area_menu:{location_id}")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
# ... (inventory_keyboard, inventory_item_actions_keyboard are unchanged) ...
|
||||
def inventory_keyboard(inventory_items: list) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
if inventory_items:
|
||||
# Categorize and sort items
|
||||
# Group items by item_id and equipped status to handle stacking properly
|
||||
item_groups = {}
|
||||
|
||||
for item in inventory_items:
|
||||
item_def = ITEMS.get(item['item_id'], {})
|
||||
item_type = item_def.get('type', 'resource')
|
||||
item_name = item_def.get('name', 'Unknown')
|
||||
is_equipped = item.get('is_equipped', False)
|
||||
|
||||
# Create a unique key for grouping: item_id + equipped status
|
||||
group_key = (item['item_id'], is_equipped)
|
||||
|
||||
if group_key not in item_groups:
|
||||
item_groups[group_key] = {
|
||||
'name': item_name,
|
||||
'def': item_def,
|
||||
'type': item_type,
|
||||
'is_equipped': is_equipped,
|
||||
'items': []
|
||||
}
|
||||
item_groups[group_key]['items'].append(item)
|
||||
|
||||
# Categorize groups
|
||||
equipped = []
|
||||
consumables = []
|
||||
weapons = []
|
||||
equipment = []
|
||||
resources = []
|
||||
quest_items = []
|
||||
|
||||
for group_key, group_data in item_groups.items():
|
||||
item_name = group_data['name']
|
||||
item_def = group_data['def']
|
||||
item_type = group_data['type']
|
||||
is_equipped = group_data['is_equipped']
|
||||
items_list = group_data['items']
|
||||
|
||||
# Calculate total quantity and weight/volume for this group
|
||||
total_quantity = sum(itm['quantity'] for itm in items_list)
|
||||
weight_per_item = item_def.get('weight', 0)
|
||||
volume_per_item = item_def.get('volume', 0)
|
||||
total_weight = weight_per_item * total_quantity
|
||||
total_volume = volume_per_item * total_quantity
|
||||
|
||||
# Use the first item's ID for the callback (they're all the same item type)
|
||||
first_item_id = items_list[0]['id']
|
||||
|
||||
# Create item data tuple: (name, item_def, first_item_id, quantity, weight, volume, is_equipped)
|
||||
item_tuple = (item_name, item_def, first_item_id, total_quantity, total_weight, total_volume, is_equipped)
|
||||
|
||||
# Only equipped items go to equipped section
|
||||
if is_equipped:
|
||||
equipped.append(item_tuple)
|
||||
elif item_type == 'consumable':
|
||||
consumables.append(item_tuple)
|
||||
elif item_type == 'weapon':
|
||||
weapons.append(item_tuple)
|
||||
elif item_type == 'equipment':
|
||||
equipment.append(item_tuple)
|
||||
elif item_type == 'quest':
|
||||
quest_items.append(item_tuple)
|
||||
else:
|
||||
resources.append(item_tuple)
|
||||
|
||||
# Sort each category alphabetically by name
|
||||
equipped.sort(key=lambda x: x[0])
|
||||
consumables.sort(key=lambda x: x[0])
|
||||
weapons.sort(key=lambda x: x[0])
|
||||
equipment.sort(key=lambda x: x[0])
|
||||
resources.sort(key=lambda x: x[0])
|
||||
quest_items.sort(key=lambda x: x[0])
|
||||
|
||||
# Build keyboard sections
|
||||
def add_section(section_name, items_list):
|
||||
if items_list:
|
||||
keyboard.append([InlineKeyboardButton(f"--- {section_name} ---", callback_data="no_op")])
|
||||
row = []
|
||||
for item_name, item_def, item_id, quantity, weight, volume, is_equipped in items_list:
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
quantity_text = f" x{quantity}" if quantity > 1 else ""
|
||||
equipped_marker = " ✓" if is_equipped else ""
|
||||
# Round to 2 decimals
|
||||
weight_vol_text = f" ({weight:.2f}kg, {volume:.2f}vol)" if quantity > 0 else ""
|
||||
|
||||
button = InlineKeyboardButton(
|
||||
f"{emoji} {item_name}{quantity_text}{equipped_marker}{weight_vol_text}",
|
||||
callback_data=f"inventory_item:{item_id}"
|
||||
)
|
||||
row.append(button)
|
||||
if len(row) == 2:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
# Add remaining item if odd number
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
|
||||
# Add sections in order
|
||||
add_section("Equipped", equipped)
|
||||
add_section("Consumables", consumables)
|
||||
add_section("Weapons", weapons)
|
||||
add_section("Equipment", equipment)
|
||||
add_section("Resources", resources)
|
||||
add_section("Quest Items", quest_items)
|
||||
|
||||
if not keyboard:
|
||||
keyboard.append([InlineKeyboardButton("--- Inventory is empty ---", callback_data="no_op")])
|
||||
else:
|
||||
keyboard.append([InlineKeyboardButton("--- Inventory is empty ---", callback_data="no_op")])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="main_menu")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
def inventory_item_actions_keyboard(item_db_id: int, item_def: dict, is_equipped: bool = False, quantity: int = 1) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
# Use button for consumables
|
||||
if item_def.get('type') == 'consumable':
|
||||
keyboard.append([InlineKeyboardButton("➡️ Use Item", callback_data=f"inventory_use:{item_db_id}")])
|
||||
|
||||
# Equip/Unequip button for weapons and equipment
|
||||
if item_def.get('type') in ["weapon", "equipment"]:
|
||||
if is_equipped:
|
||||
keyboard.append([InlineKeyboardButton("❌ Unequip", callback_data=f"inventory_unequip:{item_db_id}")])
|
||||
else:
|
||||
keyboard.append([InlineKeyboardButton("✅ Equip", callback_data=f"inventory_equip:{item_db_id}")])
|
||||
|
||||
# Drop buttons - simplified for single items
|
||||
if quantity == 1:
|
||||
# Just show a single "Drop" button
|
||||
keyboard.append([InlineKeyboardButton("🗑️ Drop", callback_data=f"inventory_drop:{item_db_id}:all")])
|
||||
else:
|
||||
# Show x1, x5, x10 options based on quantity
|
||||
drop_row = [InlineKeyboardButton("🗑️ Drop x1", callback_data=f"inventory_drop:{item_db_id}:1")]
|
||||
if quantity >= 5:
|
||||
drop_row.append(InlineKeyboardButton("🗑️ Drop x5", callback_data=f"inventory_drop:{item_db_id}:5"))
|
||||
if quantity >= 10:
|
||||
drop_row.append(InlineKeyboardButton("🗑️ Drop x10", callback_data=f"inventory_drop:{item_db_id}:10"))
|
||||
|
||||
# Split into rows if more than 2 buttons
|
||||
if len(drop_row) > 2:
|
||||
keyboard.append(drop_row[:2])
|
||||
keyboard.append(drop_row[2:])
|
||||
else:
|
||||
keyboard.append(drop_row)
|
||||
|
||||
# Add "Drop All" option
|
||||
keyboard.append([InlineKeyboardButton(f"🗑️ Drop All ({quantity})", callback_data=f"inventory_drop:{item_db_id}:all")])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back to Inventory", callback_data="inventory_menu")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def combat_keyboard(player_id: int) -> InlineKeyboardMarkup:
|
||||
"""Create combat action keyboard."""
|
||||
from bot.api_client import api_client
|
||||
keyboard = []
|
||||
|
||||
# Attack option
|
||||
keyboard.append([InlineKeyboardButton("⚔️ Attack", callback_data="combat_attack")])
|
||||
|
||||
# Flee option
|
||||
keyboard.append([InlineKeyboardButton("🏃 Try to Flee", callback_data="combat_flee")])
|
||||
|
||||
# Use item option (show consumables)
|
||||
inventory_items = await api_client.get_inventory(player_id)
|
||||
consumables = [item for item in inventory_items if ITEMS.get(item['item_id'], {}).get('type') == 'consumable']
|
||||
|
||||
if consumables:
|
||||
keyboard.append([InlineKeyboardButton("💊 Use Item", callback_data="combat_use_item_menu")])
|
||||
|
||||
# Profile button (no effect on turn, just info)
|
||||
keyboard.append([InlineKeyboardButton("👤 Profile", callback_data="profile")])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def combat_items_keyboard(player_id: int) -> InlineKeyboardMarkup:
|
||||
"""Show consumable items during combat."""
|
||||
from bot.api_client import api_client
|
||||
keyboard = []
|
||||
|
||||
inventory_items = await api_client.get_inventory(player_id)
|
||||
consumables = [item for item in inventory_items if ITEMS.get(item['item_id'], {}).get('type') == 'consumable']
|
||||
|
||||
if consumables:
|
||||
keyboard.append([InlineKeyboardButton("--- Select item to use ---", callback_data="no_op")])
|
||||
for item in consumables:
|
||||
item_def = ITEMS.get(item['item_id'], {})
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
keyboard.append([InlineKeyboardButton(
|
||||
f"{emoji} {item_def.get('name', 'Unknown')} x{item['quantity']}",
|
||||
callback_data=f"combat_use_item:{item['id']}"
|
||||
)])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back to Combat", callback_data="combat_back")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
def corpse_keyboard(corpse_id: int, corpse_type: str) -> InlineKeyboardMarkup:
|
||||
"""Create keyboard for interacting with corpses."""
|
||||
keyboard = []
|
||||
|
||||
if corpse_type == "player":
|
||||
keyboard.append([InlineKeyboardButton("🎒 Loot Bag", callback_data=f"loot_player_corpse:{corpse_id}")])
|
||||
else: # NPC corpse
|
||||
keyboard.append([InlineKeyboardButton("🔪 Scavenge Corpse", callback_data=f"scavenge_npc_corpse:{corpse_id}")])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="inspect_area")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
def player_corpse_loot_keyboard(corpse_id: int, items: list) -> InlineKeyboardMarkup:
|
||||
"""Show items in a player corpse bag."""
|
||||
keyboard = []
|
||||
|
||||
if items:
|
||||
keyboard.append([InlineKeyboardButton("--- Take items ---", callback_data="no_op")])
|
||||
for i, item_data in enumerate(items):
|
||||
item_def = ITEMS.get(item_data['item_id'], {})
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
keyboard.append([InlineKeyboardButton(
|
||||
f"{emoji} Take {item_def.get('name', 'Unknown')} x{item_data['quantity']}",
|
||||
callback_data=f"take_corpse_item:{corpse_id}:{i}"
|
||||
)])
|
||||
else:
|
||||
keyboard.append([InlineKeyboardButton("--- Bag is empty ---", callback_data="no_op")])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="inspect_area")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
def npc_corpse_scavenge_keyboard(corpse_id: int, loot_items: list) -> InlineKeyboardMarkup:
|
||||
"""Show scavenging options for NPC corpse."""
|
||||
keyboard = []
|
||||
|
||||
if loot_items:
|
||||
keyboard.append([InlineKeyboardButton("--- Scavenge for materials ---", callback_data="no_op")])
|
||||
for i, loot_data in enumerate(loot_items):
|
||||
item_def = ITEMS.get(loot_data['item_id'], {})
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
|
||||
label = f"{emoji} {item_def.get('name', 'Unknown')}"
|
||||
if loot_data.get('required_tool'):
|
||||
tool_def = ITEMS.get(loot_data['required_tool'], {})
|
||||
label += f" (needs {tool_def.get('name', 'tool')})"
|
||||
|
||||
keyboard.append([InlineKeyboardButton(
|
||||
label,
|
||||
callback_data=f"scavenge_corpse_item:{corpse_id}:{i}"
|
||||
)])
|
||||
else:
|
||||
keyboard.append([InlineKeyboardButton("--- Nothing left to scavenge ---", callback_data="no_op")])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="inspect_area")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
def spend_points_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Create keyboard for spending stat points."""
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton("❤️ Max HP (+10)", callback_data="spend_point:max_hp"),
|
||||
InlineKeyboardButton("⚡ Stamina (+5)", callback_data="spend_point:max_stamina")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton("💪 Strength (+1)", callback_data="spend_point:strength"),
|
||||
InlineKeyboardButton("🏃 Agility (+1)", callback_data="spend_point:agility")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton("💚 Endurance (+1)", callback_data="spend_point:endurance"),
|
||||
InlineKeyboardButton("🧠 Intellect (+1)", callback_data="spend_point:intellect")
|
||||
],
|
||||
[InlineKeyboardButton("⬅️ Back to Profile", callback_data="profile")]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
119
bot/logic.py
119
bot/logic.py
@@ -1,119 +0,0 @@
|
||||
import random
|
||||
from typing import Tuple, Dict, Any
|
||||
|
||||
from data.items import ITEMS
|
||||
from data.models import Action, Outcome
|
||||
|
||||
def calculate_inventory_load(player_inventory: list) -> Tuple[float, float]:
|
||||
"""Calculates the total weight and volume of a player's inventory."""
|
||||
total_weight = 0.0
|
||||
total_volume = 0.0
|
||||
for item in player_inventory:
|
||||
item_def = ITEMS.get(item["item_id"])
|
||||
if item_def:
|
||||
total_weight += item_def["weight"] * item["quantity"]
|
||||
total_volume += item_def["volume"] * item["quantity"]
|
||||
return round(total_weight, 2), round(total_volume, 2)
|
||||
|
||||
def get_player_capacity(player_inventory: list, player_stats: dict) -> Tuple[float, float]:
|
||||
"""Calculates the total carrying capacity of a player."""
|
||||
base_weight_cap = player_stats['strength'] * 5 # Example formula
|
||||
base_volume_cap = player_stats['strength'] * 2 # Example formula
|
||||
|
||||
for item in player_inventory:
|
||||
if item["is_equipped"]:
|
||||
item_def = ITEMS.get(item["item_id"])
|
||||
if item_def and item_def.get("type") == "equipment":
|
||||
effects = item_def.get("effects", {})
|
||||
base_weight_cap += effects.get("capacity_weight", 0)
|
||||
base_volume_cap += effects.get("capacity_volume", 0)
|
||||
|
||||
return base_weight_cap, base_volume_cap
|
||||
|
||||
def resolve_action(player_stats: dict, action_obj: Action) -> Outcome:
|
||||
"""
|
||||
Resolves a player action, like searching, based on stats and luck.
|
||||
Returns the resulting Outcome object.
|
||||
"""
|
||||
# A simple success chance calculation
|
||||
base_chance = 50 + (player_stats.get('intellect', 5) * 2)
|
||||
roll = random.randint(1, 100)
|
||||
|
||||
outcome_key = "failure"
|
||||
if roll <= 5 and "critical_failure" in action_obj.outcomes:
|
||||
outcome_key = "critical_failure"
|
||||
elif roll <= base_chance and "success" in action_obj.outcomes:
|
||||
outcome_key = "success"
|
||||
|
||||
return action_obj.outcomes.get(outcome_key, action_obj.outcomes["failure"])
|
||||
|
||||
async def can_add_item_to_inventory(user_id: int, item_id: str, quantity: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if an item can be added to the player's inventory.
|
||||
Returns (can_add, reason_if_not)
|
||||
"""
|
||||
from .api_client import api_client
|
||||
|
||||
player = await api_client.get_player(user_id)
|
||||
if not player:
|
||||
return False, "Player not found."
|
||||
|
||||
inventory = await api_client.get_inventory(user_id)
|
||||
item_def = ITEMS.get(item_id)
|
||||
|
||||
if not item_def:
|
||||
return False, "Invalid item."
|
||||
|
||||
# Calculate current and projected weight/volume
|
||||
current_weight, current_volume = calculate_inventory_load(inventory)
|
||||
max_weight, max_volume = get_player_capacity(inventory, player)
|
||||
|
||||
item_weight = item_def["weight"] * quantity
|
||||
item_volume = item_def["volume"] * quantity
|
||||
|
||||
new_weight = current_weight + item_weight
|
||||
new_volume = current_volume + item_volume
|
||||
|
||||
if new_weight > max_weight:
|
||||
return False, f"Too heavy! ({new_weight:.1f}/{max_weight:.1f} kg)"
|
||||
|
||||
if new_volume > max_volume:
|
||||
return False, f"Not enough space! ({new_volume:.1f}/{max_volume:.1f} vol)"
|
||||
|
||||
return True, ""
|
||||
|
||||
def calculate_travel_stamina_cost(player: dict, inventory: list, from_location, to_location) -> int:
|
||||
"""
|
||||
Calculate stamina cost for traveling between locations.
|
||||
Based on distance, endurance (reduces cost), and carried weight (increases cost).
|
||||
|
||||
Args:
|
||||
player: Player stats dictionary
|
||||
inventory: Player's inventory list
|
||||
from_location: Location object being traveled from
|
||||
to_location: Location object being traveled to
|
||||
"""
|
||||
from data.travel_helpers import calculate_base_stamina_cost
|
||||
|
||||
# Get base cost from shared helper (used by map and game)
|
||||
distance_cost = calculate_base_stamina_cost(from_location, to_location)
|
||||
|
||||
# Endurance reduces cost (each point reduces by 0.5)
|
||||
endurance_reduction = player['endurance'] * 0.5
|
||||
|
||||
# Calculate weight burden
|
||||
current_weight, _ = calculate_inventory_load(inventory)
|
||||
max_weight, _ = get_player_capacity(inventory, player)
|
||||
|
||||
# Weight penalty: if carrying more than 50% capacity, add extra cost
|
||||
weight_ratio = current_weight / max_weight if max_weight > 0 else 0
|
||||
weight_penalty = 0
|
||||
|
||||
if weight_ratio > 0.5:
|
||||
# Each 10% over 50% adds 1 stamina
|
||||
weight_penalty = int((weight_ratio - 0.5) * 10)
|
||||
|
||||
# Calculate final cost (minimum 3)
|
||||
final_cost = max(3, int(distance_cost - endurance_reduction + weight_penalty))
|
||||
|
||||
return final_cost
|
||||
@@ -1,121 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Message utility functions for sending and editing Telegram messages.
|
||||
Handles image caching, smooth transitions, and message editing logic.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from telegram import InlineKeyboardMarkup, InputMediaPhoto
|
||||
from telegram.error import BadRequest
|
||||
from .api_client import api_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_or_edit_with_image(query, text: str, reply_markup: InlineKeyboardMarkup,
|
||||
image_path: str = None, parse_mode: str = 'HTML'):
|
||||
"""
|
||||
Send a message with an image (as caption) or edit existing message.
|
||||
Uses edit_message_media for smooth transitions when changing images.
|
||||
|
||||
Args:
|
||||
query: The callback query object
|
||||
text: Message text/caption
|
||||
reply_markup: Inline keyboard markup
|
||||
image_path: Optional path to image file
|
||||
parse_mode: Parse mode for text (default 'HTML')
|
||||
"""
|
||||
current_message = query.message
|
||||
has_photo = bool(current_message.photo)
|
||||
|
||||
if image_path:
|
||||
# Get or upload image
|
||||
cached_file_id = await api_client.get_cached_image(image_path)
|
||||
|
||||
if not cached_file_id and os.path.exists(image_path):
|
||||
# Upload new image
|
||||
try:
|
||||
with open(image_path, 'rb') as img_file:
|
||||
temp_msg = await current_message.reply_photo(
|
||||
photo=img_file,
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
if temp_msg.photo:
|
||||
cached_file_id = temp_msg.photo[-1].file_id
|
||||
await api_client.cache_image(image_path, cached_file_id)
|
||||
# Delete old message to keep chat clean
|
||||
try:
|
||||
await current_message.delete()
|
||||
except:
|
||||
pass
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Error uploading image: {e}")
|
||||
cached_file_id = None
|
||||
|
||||
if cached_file_id:
|
||||
# Check if current message has same photo
|
||||
if has_photo:
|
||||
current_file_id = current_message.photo[-1].file_id
|
||||
if current_file_id == cached_file_id:
|
||||
# Same image, just edit caption
|
||||
try:
|
||||
await query.edit_message_caption(
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
return
|
||||
except BadRequest as e:
|
||||
if "Message is not modified" in str(e):
|
||||
return
|
||||
else:
|
||||
# Different image - use edit_message_media for smooth transition
|
||||
try:
|
||||
media = InputMediaPhoto(
|
||||
media=cached_file_id,
|
||||
caption=text,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
await query.edit_message_media(
|
||||
media=media,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing message media: {e}")
|
||||
|
||||
# Current message has no photo - need to delete and send new
|
||||
if not has_photo:
|
||||
try:
|
||||
await current_message.delete()
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
await current_message.reply_photo(
|
||||
photo=cached_file_id,
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending cached image: {e}")
|
||||
else:
|
||||
# No image requested
|
||||
if has_photo:
|
||||
# Current message has photo, need to delete and send text-only
|
||||
try:
|
||||
await current_message.delete()
|
||||
except:
|
||||
pass
|
||||
await current_message.reply_html(text=text, reply_markup=reply_markup)
|
||||
else:
|
||||
# Both text-only, just edit
|
||||
try:
|
||||
await query.edit_message_text(text=text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||
except BadRequest as e:
|
||||
if "Message is not modified" not in str(e):
|
||||
await current_message.reply_html(text=text, reply_markup=reply_markup)
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
Pickup and item collection handlers.
|
||||
"""
|
||||
import logging
|
||||
from . import keyboards, logic
|
||||
from .api_client import api_client
|
||||
from data.world_loader import game_world
|
||||
from data.items import ITEMS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_pickup_menu(query, user_id: int, player: dict, data: list):
|
||||
"""Show pickup options for a dropped item."""
|
||||
dropped_item_id = int(data[1])
|
||||
item_to_pickup = await api_client.get_dropped_item(dropped_item_id)
|
||||
|
||||
if not item_to_pickup:
|
||||
await query.answer("Someone already picked that up!", show_alert=False)
|
||||
location_id = player['location_id']
|
||||
location = game_world.get_location(location_id)
|
||||
dropped_items = await api_client.get_dropped_items_in_location(location_id)
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(location_id)
|
||||
keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies)
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="You scan the area. You notice...",
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
return
|
||||
|
||||
item_def = ITEMS.get(item_to_pickup['item_id'], {})
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
text = f"{emoji} <b>{item_def.get('name', 'Unknown')}</b>\n\n"
|
||||
text += f"Available: {item_to_pickup['quantity']}\n"
|
||||
text += f"Weight: {item_def.get('weight', 0)} kg each\n"
|
||||
text += f"Volume: {item_def.get('volume', 0)} vol each\n\n"
|
||||
text += "How many do you want to pick up?"
|
||||
|
||||
await query.answer()
|
||||
keyboard = keyboards.pickup_options_keyboard(
|
||||
dropped_item_id,
|
||||
item_def.get('name', 'Unknown'),
|
||||
item_to_pickup['quantity']
|
||||
)
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
|
||||
|
||||
async def handle_pickup(query, user_id: int, player: dict, data: list):
|
||||
"""Pick up a dropped item from the world."""
|
||||
dropped_item_id = int(data[1])
|
||||
pickup_amount_str = data[2] if len(data) > 2 else "all"
|
||||
|
||||
item_to_pickup = await api_client.get_dropped_item(dropped_item_id)
|
||||
if not item_to_pickup:
|
||||
await query.answer("Someone already picked that up!", show_alert=False)
|
||||
location_id = player['location_id']
|
||||
location = game_world.get_location(location_id)
|
||||
dropped_items = await api_client.get_dropped_items_in_location(location_id)
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(location_id)
|
||||
keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies)
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="You scan the area. You notice...",
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
return
|
||||
|
||||
# Determine how much to pick up
|
||||
if pickup_amount_str == "all":
|
||||
pickup_amount = item_to_pickup['quantity']
|
||||
else:
|
||||
pickup_amount = min(int(pickup_amount_str), item_to_pickup['quantity'])
|
||||
|
||||
# Check inventory capacity
|
||||
can_add, reason = await logic.can_add_item_to_inventory(
|
||||
user_id, item_to_pickup['item_id'], pickup_amount
|
||||
)
|
||||
|
||||
if not can_add:
|
||||
await query.answer(reason, show_alert=True)
|
||||
return
|
||||
|
||||
# Add to inventory
|
||||
await api_client.add_item_to_inventory(user_id, item_to_pickup['item_id'], pickup_amount)
|
||||
|
||||
# Update or remove dropped item
|
||||
remaining = item_to_pickup['quantity'] - pickup_amount
|
||||
item_def = ITEMS.get(item_to_pickup['item_id'], {})
|
||||
|
||||
if remaining > 0:
|
||||
await api_client.update_dropped_item(dropped_item_id, remaining)
|
||||
await query.answer(
|
||||
f"Picked up {pickup_amount}x {item_def.get('name', 'item')}. {remaining} remaining.",
|
||||
show_alert=False
|
||||
)
|
||||
else:
|
||||
await api_client.remove_dropped_item(dropped_item_id)
|
||||
await query.answer(
|
||||
f"Picked up {pickup_amount}x {item_def.get('name', 'item')}.",
|
||||
show_alert=False
|
||||
)
|
||||
|
||||
# Return to inspect area
|
||||
location_id = player['location_id']
|
||||
location = game_world.get_location(location_id)
|
||||
dropped_items = await api_client.get_dropped_items_in_location(location_id)
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(location_id)
|
||||
keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies)
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="You scan the area. You notice...",
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
@@ -1,169 +0,0 @@
|
||||
"""
|
||||
Profile and character stat management handlers.
|
||||
"""
|
||||
import logging
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from . import keyboards
|
||||
from data.world_loader import game_world
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_profile(query, user_id: int, player: dict, data: list = None):
|
||||
"""Display player profile with stats and level info."""
|
||||
from .utils import format_stat_bar
|
||||
await query.answer()
|
||||
from bot import combat
|
||||
from .utils import format_stat_bar, create_progress_bar
|
||||
|
||||
# Calculate stats
|
||||
xp_current = player['xp']
|
||||
xp_needed = combat.xp_for_level(player['level'] + 1)
|
||||
xp_for_current_level = combat.xp_for_level(player['level'])
|
||||
xp_progress = max(0, xp_current - xp_for_current_level)
|
||||
xp_level_requirement = xp_needed - xp_for_current_level
|
||||
progress_percent = int((xp_progress / xp_level_requirement) * 100) if xp_level_requirement > 0 else 0
|
||||
|
||||
unspent = player.get('unspent_points', 0)
|
||||
|
||||
# Build profile with visual bars
|
||||
profile_text = f"👤 <b>{player['name']}</b>\n"
|
||||
profile_text += f"━━━━━━━━━━━━━━━━━━━━\n\n"
|
||||
profile_text += f"<b>Level:</b> {player['level']}\n"
|
||||
|
||||
# XP bar
|
||||
xp_bar = create_progress_bar(xp_progress, xp_level_requirement, length=10)
|
||||
profile_text += f"⭐ XP: {xp_bar} {progress_percent}% ({xp_current}/{xp_needed})\n"
|
||||
|
||||
if unspent > 0:
|
||||
profile_text += f"💎 <b>Unspent Points:</b> {unspent}\n"
|
||||
|
||||
profile_text += f"\n{format_stat_bar('HP', '❤️', player['hp'], player['max_hp'])}\n"
|
||||
profile_text += f"{format_stat_bar('Stamina', '⚡', player['stamina'], player['max_stamina'])}\n\n"
|
||||
profile_text += f"<b>Stats:</b>\n"
|
||||
profile_text += f"💪 Strength: {player['strength']}\n"
|
||||
profile_text += f"🏃 Agility: {player['agility']}\n"
|
||||
profile_text += f"💚 Endurance: {player['endurance']}\n"
|
||||
profile_text += f"🧠 Intellect: {player['intellect']}\n\n"
|
||||
profile_text += f"<b>Combat:</b>\n"
|
||||
profile_text += f"⚔️ Base Damage: {5 + player['strength'] // 2 + player['level']}\n"
|
||||
profile_text += f"🛡️ Flee Chance: {int((0.5 + player['agility'] / 100) * 100)}%\n"
|
||||
profile_text += f"💚 Stamina Regen: {1 + player['endurance'] // 10}/cycle\n\n"
|
||||
|
||||
# Show status effects if any
|
||||
try:
|
||||
from .api_client import api_client
|
||||
status_effects = await api_client.get_player_status_effects(user_id)
|
||||
if status_effects:
|
||||
from bot.status_utils import get_status_details
|
||||
from .api_client import api_client
|
||||
# Check if player is in combat
|
||||
combat_state = await api_client.get_combat(user_id)
|
||||
in_combat = combat_state is not None
|
||||
profile_text += f"<b>Status Effects:</b>\n"
|
||||
profile_text += get_status_details(status_effects, in_combat=in_combat) + "\n\n"
|
||||
except:
|
||||
pass # Status effects not critical, skip if error
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
# Add spend points button if player has unspent points
|
||||
keyboard_buttons = []
|
||||
if unspent > 0:
|
||||
keyboard_buttons.append([
|
||||
InlineKeyboardButton("⭐ Spend Stat Points", callback_data="spend_points_menu")
|
||||
])
|
||||
keyboard_buttons.append([InlineKeyboardButton("⬅️ Back", callback_data="main_menu")])
|
||||
back_keyboard = InlineKeyboardMarkup(keyboard_buttons)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=profile_text,
|
||||
reply_markup=back_keyboard,
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_spend_points_menu(query, user_id: int, player: dict, data: list = None):
|
||||
"""Show menu for spending attribute points."""
|
||||
await query.answer()
|
||||
unspent = player.get('unspent_points', 0)
|
||||
|
||||
if unspent <= 0:
|
||||
await query.answer("You have no points to spend!", show_alert=False)
|
||||
return
|
||||
|
||||
text = f"⭐ <b>Spend Stat Points</b>\n\n"
|
||||
text += f"Available Points: <b>{unspent}</b>\n\n"
|
||||
text += f"Current Stats:\n"
|
||||
text += f"❤️ Max HP: {player['max_hp']} (+10 per point)\n"
|
||||
text += f"⚡ Max Stamina: {player['max_stamina']} (+5 per point)\n"
|
||||
text += f"💪 Strength: {player['strength']} (+1 per point)\n"
|
||||
text += f"🏃 Agility: {player['agility']} (+1 per point)\n"
|
||||
text += f"💚 Endurance: {player['endurance']} (+1 per point)\n"
|
||||
text += f"🧠 Intellect: {player['intellect']} (+1 per point)\n\n"
|
||||
text += f"💡 Choose wisely! Each point matters."
|
||||
|
||||
keyboard = keyboards.spend_points_keyboard()
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(query, text=text, reply_markup=keyboard)
|
||||
|
||||
|
||||
async def handle_spend_point(query, user_id: int, player: dict, data: list):
|
||||
"""Spend a stat point on a specific attribute."""
|
||||
stat_name = data[1]
|
||||
unspent = player.get('unspent_points', 0)
|
||||
|
||||
if unspent <= 0:
|
||||
await query.answer("You have no points to spend!", show_alert=False)
|
||||
return
|
||||
|
||||
# Map stat names to updates
|
||||
stat_mapping = {
|
||||
'max_hp': ('max_hp', 10, '❤️ Max HP'),
|
||||
'max_stamina': ('max_stamina', 5, '⚡ Max Stamina'),
|
||||
'strength': ('strength', 1, '💪 Strength'),
|
||||
'agility': ('agility', 1, '🏃 Agility'),
|
||||
'endurance': ('endurance', 1, '💚 Endurance'),
|
||||
'intellect': ('intellect', 1, '🧠 Intellect'),
|
||||
}
|
||||
|
||||
if stat_name not in stat_mapping:
|
||||
await query.answer("Invalid stat!", show_alert=False)
|
||||
return
|
||||
|
||||
db_field, increase, display_name = stat_mapping[stat_name]
|
||||
new_value = player[db_field] + increase
|
||||
new_unspent = unspent - 1
|
||||
|
||||
from .api_client import api_client
|
||||
await api_client.update_player(user_id, {
|
||||
db_field: new_value,
|
||||
'unspent_points': new_unspent
|
||||
})
|
||||
|
||||
# Update local player data
|
||||
player[db_field] = new_value
|
||||
player['unspent_points'] = new_unspent
|
||||
|
||||
await query.answer(f"+{increase} {display_name}!", show_alert=False)
|
||||
|
||||
# Refresh the spend points menu
|
||||
text = f"⭐ <b>Spend Stat Points</b>\n\n"
|
||||
text += f"Available Points: <b>{new_unspent}</b>\n\n"
|
||||
text += f"Current Stats:\n"
|
||||
text += f"❤️ Max HP: {player['max_hp']} (+10 per point)\n"
|
||||
text += f"⚡ Max Stamina: {player['max_stamina']} (+5 per point)\n"
|
||||
text += f"💪 Strength: {player['strength']} (+1 per point)\n"
|
||||
text += f"🏃 Agility: {player['agility']} (+1 per point)\n"
|
||||
text += f"💚 Endurance: {player['endurance']} (+1 per point)\n"
|
||||
text += f"🧠 Intellect: {player['intellect']} (+1 per point)\n\n"
|
||||
text += f"💡 Choose wisely! Each point matters."
|
||||
|
||||
keyboard = keyboards.spend_points_keyboard()
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(query, text=text, reply_markup=keyboard)
|
||||
@@ -1,119 +0,0 @@
|
||||
"""
|
||||
Global Wandering Enemy Spawn Manager
|
||||
Runs periodically to spawn/despawn enemies based on location danger levels.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from typing import Dict, List
|
||||
from bot import database
|
||||
from data.npcs import (
|
||||
LOCATION_SPAWNS,
|
||||
LOCATION_DANGER,
|
||||
get_random_npc_for_location,
|
||||
get_wandering_enemy_chance
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Configuration
|
||||
SPAWN_CHECK_INTERVAL = 120 # Check every 2 minutes
|
||||
ENEMY_LIFETIME = 600 # Enemies live for 10 minutes
|
||||
MAX_ENEMIES_PER_LOCATION = {
|
||||
0: 0, # Safe zones - no wandering enemies
|
||||
1: 1, # Low danger - max 1 enemy
|
||||
2: 2, # Medium danger - max 2 enemies
|
||||
3: 3, # High danger - max 3 enemies
|
||||
4: 4, # Extreme danger - max 4 enemies
|
||||
}
|
||||
|
||||
|
||||
def get_danger_level(location_id: str) -> int:
|
||||
"""Get danger level for a location."""
|
||||
danger_data = LOCATION_DANGER.get(location_id, (0, 0.0, 0.0))
|
||||
return danger_data[0]
|
||||
|
||||
|
||||
async def spawn_manager_loop():
|
||||
"""
|
||||
Main spawn manager loop.
|
||||
Runs continuously, checking spawn conditions every SPAWN_CHECK_INTERVAL seconds.
|
||||
"""
|
||||
logger.info("🎲 Spawn Manager started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(SPAWN_CHECK_INTERVAL)
|
||||
|
||||
# Clean up expired enemies first
|
||||
despawned_count = await database.cleanup_expired_wandering_enemies()
|
||||
if despawned_count > 0:
|
||||
logger.info(f"🧹 Cleaned up {despawned_count} expired wandering enemies")
|
||||
|
||||
# Process each location
|
||||
spawned_count = 0
|
||||
for location_id, spawn_table in LOCATION_SPAWNS.items():
|
||||
if not spawn_table:
|
||||
continue # Skip locations with no spawns
|
||||
|
||||
# Get danger level and max enemies for this location
|
||||
danger_level = get_danger_level(location_id)
|
||||
max_enemies = MAX_ENEMIES_PER_LOCATION.get(danger_level, 0)
|
||||
|
||||
if max_enemies == 0:
|
||||
continue # Skip safe zones
|
||||
|
||||
# Check current enemy count
|
||||
current_count = await database.get_wandering_enemy_count_in_location(location_id)
|
||||
|
||||
if current_count >= max_enemies:
|
||||
continue # Location is at capacity
|
||||
|
||||
# Calculate spawn chance based on wandering_enemy_chance
|
||||
spawn_chance = get_wandering_enemy_chance(location_id)
|
||||
|
||||
# Attempt to spawn enemies up to max capacity
|
||||
for _ in range(max_enemies - current_count):
|
||||
if random.random() < spawn_chance:
|
||||
# Spawn an enemy
|
||||
npc_id = get_random_npc_for_location(location_id)
|
||||
if npc_id:
|
||||
await database.spawn_wandering_enemy(
|
||||
npc_id=npc_id,
|
||||
location_id=location_id,
|
||||
lifetime_seconds=ENEMY_LIFETIME
|
||||
)
|
||||
spawned_count += 1
|
||||
logger.info(f"👹 Spawned {npc_id} at {location_id} (current: {current_count + 1}/{max_enemies})")
|
||||
|
||||
if spawned_count > 0:
|
||||
logger.info(f"✨ Spawn cycle complete: {spawned_count} enemies spawned")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in spawn manager loop: {e}", exc_info=True)
|
||||
# Continue running even if there's an error
|
||||
await asyncio.sleep(10)
|
||||
|
||||
|
||||
async def start_spawn_manager():
|
||||
"""Start the spawn manager as a background task."""
|
||||
asyncio.create_task(spawn_manager_loop())
|
||||
logger.info("🎮 Spawn Manager initialized")
|
||||
|
||||
|
||||
async def get_spawn_stats() -> Dict:
|
||||
"""Get statistics about current spawns (for debugging/monitoring)."""
|
||||
all_enemies = await database.get_all_active_wandering_enemies()
|
||||
|
||||
# Count by location
|
||||
location_counts = {}
|
||||
for enemy in all_enemies:
|
||||
loc = enemy['location_id']
|
||||
location_counts[loc] = location_counts.get(loc, 0) + 1
|
||||
|
||||
return {
|
||||
"total_active": len(all_enemies),
|
||||
"by_location": location_counts,
|
||||
"enemies": all_enemies
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"""
|
||||
Status effect utilities for display and management.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
def stack_status_effects(effects: list) -> dict:
|
||||
"""
|
||||
Stack status effects by name, summing damage and counting stacks.
|
||||
|
||||
Args:
|
||||
effects: List of dicts with keys: effect_name, effect_icon, damage_per_tick, ticks_remaining
|
||||
|
||||
Returns:
|
||||
Dict with keys: effect_name -> {icon, total_damage, stacks, min_ticks, effects: [list of effect dicts]}
|
||||
"""
|
||||
stacked = defaultdict(lambda: {
|
||||
'icon': '',
|
||||
'total_damage': 0,
|
||||
'stacks': 0,
|
||||
'min_ticks': float('inf'),
|
||||
'max_ticks': 0,
|
||||
'effects': []
|
||||
})
|
||||
|
||||
for effect in effects:
|
||||
name = effect['effect_name']
|
||||
stacked[name]['icon'] = effect['effect_icon']
|
||||
stacked[name]['total_damage'] += effect.get('damage_per_tick', 0)
|
||||
stacked[name]['stacks'] += 1
|
||||
stacked[name]['min_ticks'] = min(stacked[name]['min_ticks'], effect['ticks_remaining'])
|
||||
stacked[name]['max_ticks'] = max(stacked[name]['max_ticks'], effect['ticks_remaining'])
|
||||
stacked[name]['effects'].append(effect)
|
||||
|
||||
return dict(stacked)
|
||||
|
||||
|
||||
def get_status_summary(effects: list, in_combat: bool = False) -> str:
|
||||
"""
|
||||
Generate compact status summary for display in menus.
|
||||
|
||||
Args:
|
||||
effects: List of status effect dicts
|
||||
in_combat: If True, show "turns" instead of "cycles"
|
||||
|
||||
Returns:
|
||||
String like "Statuses: 🩸 (-4), ☣️ (-3)" or empty string if no effects
|
||||
"""
|
||||
if not effects:
|
||||
return ""
|
||||
|
||||
stacked = stack_status_effects(effects)
|
||||
|
||||
if not stacked:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
for name, data in stacked.items():
|
||||
if data['total_damage'] > 0:
|
||||
parts.append(f"{data['icon']} (-{data['total_damage']})")
|
||||
else:
|
||||
parts.append(f"{data['icon']}")
|
||||
|
||||
return "Statuses: " + ", ".join(parts)
|
||||
|
||||
|
||||
def get_status_details(effects: list, in_combat: bool = False) -> str:
|
||||
"""
|
||||
Generate detailed status display for profile menu.
|
||||
|
||||
Args:
|
||||
effects: List of status effect dicts
|
||||
in_combat: If True, show "turns" instead of "cycles"
|
||||
|
||||
Returns:
|
||||
Multi-line string with detailed effect info
|
||||
"""
|
||||
if not effects:
|
||||
return "No active status effects."
|
||||
|
||||
stacked = stack_status_effects(effects)
|
||||
|
||||
lines = []
|
||||
for name, data in stacked.items():
|
||||
# Build effect line
|
||||
effect_line = f"{data['icon']} {name.replace('_', ' ').title()}"
|
||||
|
||||
# Add damage info
|
||||
if data['total_damage'] > 0:
|
||||
effect_line += f": -{data['total_damage']} HP/{'turn' if in_combat else 'cycle'}"
|
||||
|
||||
# Add tick info
|
||||
if data['stacks'] == 1:
|
||||
tick_unit = 'turn' if in_combat else 'cycle'
|
||||
tick_count = data['min_ticks']
|
||||
effect_line += f" ({tick_count} {tick_unit}{'s' if tick_count != 1 else ''} left)"
|
||||
else:
|
||||
tick_unit = 'turns' if in_combat else 'cycles'
|
||||
if data['min_ticks'] == data['max_ticks']:
|
||||
effect_line += f" (×{data['stacks']}, {data['min_ticks']} {tick_unit} left)"
|
||||
else:
|
||||
effect_line += f" (×{data['stacks']}, {data['min_ticks']}-{data['max_ticks']} {tick_unit} left)"
|
||||
|
||||
lines.append(effect_line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def calculate_status_damage(effects: list) -> int:
|
||||
"""
|
||||
Calculate total damage from all status effects.
|
||||
|
||||
Args:
|
||||
effects: List of status effect dicts
|
||||
|
||||
Returns:
|
||||
Total damage per tick
|
||||
"""
|
||||
return sum(effect.get('damage_per_tick', 0) for effect in effects)
|
||||
128
bot/utils.py
128
bot/utils.py
@@ -1,128 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Utility functions and decorators for the bot.
|
||||
"""
|
||||
import os
|
||||
import functools
|
||||
import logging
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_progress_bar(current: int, maximum: int, length: int = 10, filled_char: str = "█", empty_char: str = "░") -> str:
|
||||
"""
|
||||
Create a visual progress bar.
|
||||
|
||||
Args:
|
||||
current: Current value
|
||||
maximum: Maximum value
|
||||
length: Length of the bar in characters (default 10)
|
||||
filled_char: Character for filled portion (default █)
|
||||
empty_char: Character for empty portion (default ░)
|
||||
|
||||
Returns:
|
||||
String representation of progress bar
|
||||
|
||||
Examples:
|
||||
>>> create_progress_bar(75, 100)
|
||||
"███████░░░"
|
||||
>>> create_progress_bar(0, 100)
|
||||
"░░░░░░░░░░"
|
||||
>>> create_progress_bar(100, 100)
|
||||
"██████████"
|
||||
"""
|
||||
if maximum <= 0:
|
||||
return empty_char * length
|
||||
|
||||
percentage = min(1.0, max(0.0, current / maximum))
|
||||
filled_length = int(length * percentage)
|
||||
empty_length = length - filled_length
|
||||
|
||||
return filled_char * filled_length + empty_char * empty_length
|
||||
|
||||
|
||||
def format_stat_bar(label: str, emoji: str, current: int, maximum: int, bar_length: int = 10, label_width: int = 7) -> str:
|
||||
"""
|
||||
Format a stat (HP, Stamina, etc.) with visual progress bar.
|
||||
Uses right-aligned label format to avoid alignment issues with Telegram's proportional font.
|
||||
|
||||
Args:
|
||||
label: Stat label (e.g., "HP", "Stamina", "Your HP")
|
||||
emoji: Emoji to display (e.g., "❤️", "⚡", "🐕")
|
||||
current: Current value
|
||||
maximum: Maximum value
|
||||
bar_length: Length of the progress bar
|
||||
label_width: Not used, kept for backwards compatibility
|
||||
|
||||
Returns:
|
||||
Formatted string with bar on left, label on right
|
||||
|
||||
Examples:
|
||||
>>> format_stat_bar("HP", "❤️", 75, 100)
|
||||
"███████░░░ 75% (75/100) ❤️ HP"
|
||||
>>> format_stat_bar("Stamina", "⚡", 50, 100)
|
||||
"█████░░░░░ 50% (50/100) ⚡ Stamina"
|
||||
"""
|
||||
bar = create_progress_bar(current, maximum, bar_length)
|
||||
percentage = int((current / maximum * 100)) if maximum > 0 else 0
|
||||
|
||||
# Right-aligned format: bar first, then stats, then emoji + label
|
||||
# This way bars are always left-aligned regardless of label length
|
||||
if emoji:
|
||||
return f"{bar} {percentage}% ({current}/{maximum}) {emoji} {label}"
|
||||
else:
|
||||
# If no emoji provided, just use label
|
||||
return f"{bar} {percentage}% ({current}/{maximum}) {label}"
|
||||
|
||||
|
||||
|
||||
def get_admin_ids():
|
||||
"""Get the list of admin user IDs from environment variable."""
|
||||
admin_ids_str = os.getenv("ADMIN_IDS", "")
|
||||
if not admin_ids_str:
|
||||
logger.warning("ADMIN_IDS not set in .env file. No admins configured.")
|
||||
return set()
|
||||
|
||||
try:
|
||||
# Parse comma-separated list of IDs
|
||||
admin_ids = set(int(id.strip()) for id in admin_ids_str.split(",") if id.strip())
|
||||
return admin_ids
|
||||
except ValueError as e:
|
||||
logger.error(f"Error parsing ADMIN_IDS: {e}")
|
||||
return set()
|
||||
|
||||
|
||||
def admin_only(func):
|
||||
"""
|
||||
Decorator that restricts command to admin users only.
|
||||
|
||||
Usage:
|
||||
@admin_only
|
||||
async def my_admin_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
...
|
||||
"""
|
||||
@functools.wraps(func)
|
||||
async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs):
|
||||
user_id = update.effective_user.id
|
||||
admin_ids = get_admin_ids()
|
||||
|
||||
if user_id not in admin_ids:
|
||||
await update.message.reply_html(
|
||||
"🚫 <b>Access Denied</b>\n\n"
|
||||
"This command is restricted to administrators only."
|
||||
)
|
||||
logger.warning(f"User {user_id} attempted to use admin command: {func.__name__}")
|
||||
return
|
||||
|
||||
# User is admin, execute the command
|
||||
return await func(update, context, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Check if a user ID is an admin."""
|
||||
admin_ids = get_admin_ids()
|
||||
return user_id in admin_ids
|
||||
87
main.py
87
main.py
@@ -1,87 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from telegram import Update
|
||||
from telegram.ext import Application, CommandHandler, CallbackQueryHandler
|
||||
|
||||
from bot import database, handlers
|
||||
from bot import background_tasks
|
||||
|
||||
# Enable logging
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
|
||||
)
|
||||
# Quieten down the HTTPX logger, which is very verbose
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A global event to signal shutdown
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
"""Gracefully handle shutdown signals."""
|
||||
logger.info("Shutdown signal received. Shutting down gracefully...")
|
||||
shutdown_event.set()
|
||||
|
||||
async def main() -> None:
|
||||
"""Start the bot and wait for a shutdown signal."""
|
||||
load_dotenv()
|
||||
TOKEN = os.getenv("TELEGRAM_TOKEN")
|
||||
|
||||
if not TOKEN or TOKEN == "YOUR_TELEGRAM_BOT_TOKEN_HERE":
|
||||
logger.error("TELEGRAM_TOKEN is not set! Please edit your .env file.")
|
||||
return
|
||||
|
||||
await database.create_tables()
|
||||
|
||||
application = Application.builder().token(TOKEN).build()
|
||||
|
||||
application.add_handler(CommandHandler("start", handlers.start))
|
||||
application.add_handler(CommandHandler("map", handlers.export_map))
|
||||
application.add_handler(CommandHandler("spawns", handlers.spawn_stats))
|
||||
application.add_handler(CallbackQueryHandler(handlers.button_handler))
|
||||
|
||||
async with application:
|
||||
await application.start()
|
||||
await application.updater.start_polling(allowed_updates=Update.ALL_TYPES)
|
||||
logger.info("Bot is running and polling for updates...")
|
||||
|
||||
# Start the spawn manager
|
||||
from bot import spawn_manager
|
||||
await spawn_manager.start_spawn_manager()
|
||||
|
||||
# Start the background tasks
|
||||
logger.info("Starting background tasks...")
|
||||
decay_task = asyncio.create_task(background_tasks.decay_dropped_items(shutdown_event))
|
||||
stamina_task = asyncio.create_task(background_tasks.regenerate_stamina(shutdown_event))
|
||||
combat_timer_task = asyncio.create_task(background_tasks.check_combat_timers(shutdown_event))
|
||||
corpse_decay_task = asyncio.create_task(background_tasks.decay_corpses(shutdown_event))
|
||||
status_effects_task = asyncio.create_task(background_tasks.process_status_effects(shutdown_event))
|
||||
logger.info("✅ All background tasks started")
|
||||
|
||||
await shutdown_event.wait()
|
||||
|
||||
await application.updater.stop()
|
||||
await application.stop()
|
||||
|
||||
# Ensure the background tasks are also cancelled on shutdown
|
||||
logger.info("Stopping background tasks...")
|
||||
decay_task.cancel()
|
||||
stamina_task.cancel()
|
||||
combat_timer_task.cancel()
|
||||
corpse_decay_task.cancel()
|
||||
status_effects_task.cancel()
|
||||
logger.info("Bot has been shut down.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
logger.info("Main function interrupted.")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,8 +31,8 @@ export const useGameWebSocket = ({
|
||||
}: UseGameWebSocketProps): UseGameWebSocketReturn => {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
const heartbeatIntervalRef = useRef<number | null>(null);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
const reconnectDelay = 3000; // 3 seconds
|
||||
@@ -40,14 +40,14 @@ export const useGameWebSocket = ({
|
||||
// Get WebSocket URL based on current environment
|
||||
const getWebSocketUrl = useCallback(() => {
|
||||
const API_BASE = import.meta.env.VITE_API_URL || (
|
||||
import.meta.env.PROD
|
||||
? 'https://api-staging.echoesoftheash.com'
|
||||
import.meta.env.PROD
|
||||
? 'https://api-staging.echoesoftheash.com'
|
||||
: 'http://localhost:8000'
|
||||
);
|
||||
|
||||
|
||||
// Remove /api suffix if present and convert http(s) to ws(s)
|
||||
const wsBase = API_BASE.replace(/\/api$/, '').replace(/^http/, 'ws');
|
||||
|
||||
|
||||
return `${wsBase}/ws/game/${token}`;
|
||||
}, [token]);
|
||||
|
||||
@@ -65,7 +65,7 @@ export const useGameWebSocket = ({
|
||||
try {
|
||||
const wsUrl = getWebSocketUrl();
|
||||
console.log('🔌 Connecting to WebSocket:', wsUrl);
|
||||
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
@@ -81,7 +81,7 @@ export const useGameWebSocket = ({
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data);
|
||||
|
||||
|
||||
// Handle heartbeat acks silently
|
||||
if (message.type === 'heartbeat_ack' || message.type === 'pong') {
|
||||
return;
|
||||
@@ -114,7 +114,7 @@ export const useGameWebSocket = ({
|
||||
console.log(
|
||||
`🔄 Reconnecting... (attempt ${reconnectAttemptsRef.current}/${maxReconnectAttempts})`
|
||||
);
|
||||
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface LoginResponse {
|
||||
token_type: string
|
||||
account: Account
|
||||
characters: Character[]
|
||||
needs_character_creation?: boolean
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
|
||||
Reference in New Issue
Block a user