13 Commits

137 changed files with 14117 additions and 10202 deletions

View File

@@ -3,11 +3,9 @@ stages:
- build-desktop
variables:
# Cache configuration
npm_config_cache: "$CI_PROJECT_DIR/.npm"
ELECTRON_CACHE: "$CI_PROJECT_DIR/.cache/electron"
# Cache node_modules and electron cache
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
@@ -28,30 +26,53 @@ build:web:
- pwa/dist/
expire_in: 1 hour
rules:
- if: '$CI_COMMIT_TAG' # Run on tags
- if: '$CI_COMMIT_BRANCH == "main"' # Run on main branch
- if: '$CI_COMMIT_BRANCH == "develop"' # Run on develop branch
# Build Linux AppImage and .deb
build:linux:
stage: build-desktop
image: electronuserland/builder:wine
dependencies:
- build:web
script:
- cd pwa
- npm ci
- npm run electron:build:linux
artifacts:
paths:
- pwa/dist-electron/*.AppImage
- pwa/dist-electron/*.deb
expire_in: 1 week
rules:
- if: '$CI_COMMIT_TAG' # Only run on tags
- if: '$CI_COMMIT_TAG'
tags:
- docker
# # Build Linux AppImage and .deb
# build:linux:
# stage: build-desktop
# image: electronuserland/builder:wine
# dependencies:
# - build:web
# script:
# - cd pwa
# - npm ci
# - npm run electron:build:linux
# - echo "=== AppImage size ==="
# - ls -lh dist-electron/*.AppImage
# - du -h dist-electron/*.AppImage
# artifacts:
# paths:
# - pwa/dist-electron/*.AppImage
# expire_in: 1 week
# name: "linux-appimage-$CI_COMMIT_TAG"
# rules:
# - if: '$CI_COMMIT_TAG'
# tags:
# - docker
# # Build Linux .deb (separate job to avoid size limits)
# build:linux-deb:
# stage: build-desktop
# image: electronuserland/builder:wine
# dependencies:
# - build:web
# script:
# - cd pwa
# - npm ci
# - npm run electron:build:linux
# artifacts:
# paths:
# - pwa/dist-electron/*.deb
# expire_in: 1 week
# name: "linux-deb-$CI_COMMIT_TAG"
# rules:
# - if: '$CI_COMMIT_TAG'
# tags:
# - docker
# Build Windows executable
build:windows:
stage: build-desktop
@@ -62,60 +83,17 @@ build:windows:
- cd pwa
- npm ci
- npm run electron:build:win
# Show file sizes
- echo "=== Build artifacts ==="
- ls -lh dist-electron/*.exe || echo "No .exe files found"
- echo "=== Total size ==="
- du -sh dist-electron/
artifacts:
paths:
- pwa/dist-electron/*.exe
- pwa/dist-electron/*.msi
expire_in: 1 week
name: "windows-installer-$CI_COMMIT_TAG"
rules:
- if: '$CI_COMMIT_TAG' # Only run on tags
- if: '$CI_COMMIT_TAG'
tags:
- docker
# Build macOS (requires macOS runner - optional)
# Uncomment if you have a macOS runner available
# build:mac:
# stage: build-desktop
# dependencies:
# - build:web
# script:
# - cd pwa
# - npm ci
# - npm run electron:build:mac
# artifacts:
# paths:
# - pwa/dist-electron/*.dmg
# expire_in: 1 week
# rules:
# - if: '$CI_COMMIT_TAG'
# tags:
# - macos
# Manual job to test builds without tags
build:manual:
stage: build-desktop
image: electronuserland/builder:wine
script:
- cd pwa
- npm ci
- npm run electron:build:linux
artifacts:
paths:
- pwa/dist-electron/
expire_in: 1 day
when: manual
tags:
- docker
# Test job to verify CI is working (no tags required)
test:ci:
stage: build-web
image: alpine:latest
script:
- echo "CI is working!"
- echo "Project directory:"
- ls -la
- echo "PWA directory:"
- ls -la pwa/ || echo "PWA directory not found"
rules:
- if: '$CI_PIPELINE_SOURCE == "push"' # Run on every push

View File

@@ -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
View File

@@ -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.
![Python](https://img.shields.io/badge/python-3.11-blue)
![Telegram Bot API](https://img.shields.io/badge/telegram--bot--api-21.0.1-blue)
![PostgreSQL](https://img.shields.io/badge/postgresql-15-blue)
![Docker](https://img.shields.io/badge/docker-compose-blue)
![React](https://img.shields.io/badge/react-18-blue)
![FastAPI](https://img.shields.io/badge/fastapi-0.104-green)
## 🌐 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

View File

@@ -4,7 +4,7 @@ Loads and manages game items from JSON without bot dependencies.
"""
import json
from pathlib import Path
from typing import Dict, Any, Optional
from typing import Dict, Any, Optional, Union
from dataclasses import dataclass
@@ -12,8 +12,8 @@ from dataclasses import dataclass
class Item:
"""Represents a game item"""
id: str
name: str
description: str
name: Union[str, Dict[str, str]]
description: Union[str, Dict[str, str]]
type: str
image_path: str = ""
emoji: str = "📦"

View File

@@ -12,7 +12,7 @@ import logging
from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import *
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string
from .. import database as db
from ..items import ItemsManager
from .. import game_logic
@@ -226,6 +226,11 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
"encumbrance": item_def.encumbrance,
"weapon_effects": item_def.weapon_effects if hasattr(item_def, 'weapon_effects') else {}
}
else:
logger.error(f"❌ Item definition not found for equipped item: {inv_item['item_id']} (slot: {slot})")
else:
logger.warning(f"⚠️ Inventory item not found for equipped slot: {slot} (ID: {item_data['item_id']})")
if slot not in equipment:
equipment[slot] = None

View File

@@ -3,11 +3,18 @@ Helper utilities for game calculations and common operations.
Contains distance calculations, stamina costs, capacity calculations, etc.
"""
import math
from typing import Tuple, List, Dict, Any
from typing import Tuple, List, Dict, Any, Union
from .. import database as db
from ..items import ItemsManager
def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> str:
"""Helper to safely get string from i18n object or string."""
if isinstance(value, dict):
return value.get(lang) or value.get('en') or str(value)
return str(value)
def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float:
"""
Calculate distance between two points using Euclidean distance.
@@ -182,7 +189,7 @@ async def reduce_armor_durability(player_id: int, damage_taken: int, items_manag
# which cascades to the inventory row.
broken_armor.append({
'name': armor['item_def'].name,
'name': get_locale_string(armor['item_def'].name),
'emoji': getattr(armor['item_def'], 'emoji', '🛡️')
})
@@ -214,7 +221,7 @@ async def consume_tool_durability(user_id: int, tools: list, inventory: list, it
'unique_item_id': inv_item['unique_item_id'],
'item_id': inv_item['item_id'],
'durability': unique_item['durability'],
'name': item_def.name,
'name': get_locale_string(item_def.name),
'emoji': getattr(item_def, 'emoji', '🔧')
})

View File

@@ -4,14 +4,14 @@ Loads game data from JSON files without bot dependencies.
"""
import json
from pathlib import Path
from typing import Dict, List, Any, Optional
from typing import Dict, List, Any, Optional, Union
from dataclasses import dataclass, field
@dataclass
class Outcome:
"""Represents an outcome of an action"""
text: str
text: Union[str, Dict[str, str]]
items_reward: Dict[str, int] = field(default_factory=dict)
damage_taken: int = 0
@@ -20,7 +20,7 @@ class Outcome:
class Action:
"""Represents an action that can be performed on an interactable"""
id: str
label: str
label: Union[str, Dict[str, str]]
stamina_cost: int = 2
outcomes: Dict[str, Outcome] = field(default_factory=dict)
@@ -32,7 +32,7 @@ class Action:
class Interactable:
"""Represents an interactable object"""
id: str
name: str
name: Union[str, Dict[str, str]]
image_path: str = ""
actions: List[Action] = field(default_factory=list)
@@ -52,8 +52,8 @@ class Exit:
class Location:
"""Represents a location in the game world"""
id: str
name: str
description: str
name: Union[str, Dict[str, str]]
description: Union[str, Dict[str, str]]
image_path: str = ""
exits: Dict[str, str] = field(default_factory=dict) # direction -> destination_id
exit_stamina: Dict[str, int] = field(default_factory=dict) # direction -> stamina_cost

View File

View File

@@ -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
)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -2,114 +2,192 @@
"interactables": {
"rubble": {
"id": "rubble",
"name": "🧱 Pile of Rubble",
"description": "A scattered pile of debris and broken concrete.",
"name": {
"en": "🧱 Pile of Rubble",
"es": "🧱 Pila de escombros"
},
"description": {
"en": "A scattered pile of debris and broken concrete.",
"es": "Una pila de escombros y cemento roto."
},
"image_path": "images/interactables/rubble.webp",
"actions": {
"search": {
"id": "search",
"label": "\ud83d\udd0e Search Rubble",
"label": {
"en": "🔎 Search Rubble",
"es": "🔎 Buscar en los escombros"
},
"stamina_cost": 2
}
}
},
"dumpster": {
"id": "dumpster",
"name": "\ud83d\uddd1\ufe0f Dumpster",
"description": "A rusted metal dumpster, possibly containing scavenged goods.",
"name": {
"en": "🗑️ Dumpster",
"es": "🗑️ Contenedor de basura"
},
"description": {
"en": "A rusted metal dumpster, possibly containing scavenged goods.",
"es": "Un contenedor de basura de metal oxidado, posiblemente conteniendo bienes robados."
},
"image_path": "images/interactables/dumpster.webp",
"actions": {
"search_dumpster": {
"id": "search_dumpster",
"label": "\ud83d\udd0e Dig Through Trash",
"label": {
"en": "🔎 Dig Through Trash",
"es": "🔎 Buscar en la basura"
},
"stamina_cost": 2
}
}
},
"sedan": {
"id": "sedan",
"name": "\ud83d\ude97 Rusty Sedan",
"description": "An abandoned sedan with rusted doors.",
"name": {
"en": "🚗 Rusty Sedan",
"es": "🚗 Sedán oxidado"
},
"description": {
"en": "An abandoned sedan with rusted doors.",
"es": "Un sedán abandonado con puertas oxidadas."
},
"image_path": "images/interactables/sedan.webp",
"actions": {
"search_glovebox": {
"id": "search_glovebox",
"label": "\ud83d\udd0e Search Glovebox",
"label": {
"en": "🔎 Search Glovebox",
"es": "🔎 Buscar en la guantera"
},
"stamina_cost": 1
},
"pop_trunk": {
"id": "pop_trunk",
"label": "\ud83d\udd27 Pop the Trunk",
"label": {
"en": "🔧 Pop the Trunk",
"es": "🔧 Forzar el maletero"
},
"stamina_cost": 3
}
}
},
"house": {
"id": "house",
"name": "\ud83c\udfda\ufe0f Abandoned House",
"description": "A dilapidated house with boarded windows.",
"name": {
"en": "🏚️ Abandoned House",
"es": "🏚️ Casa abandonada"
},
"description": {
"en": "A dilapidated house with boarded windows.",
"es": "Una casa abandonada con ventanas tapadas."
},
"image_path": "images/interactables/house.webp",
"actions": {
"search_house": {
"id": "search_house",
"label": "\ud83d\udd0e Search House",
"label": {
"en": "🔎 Search House",
"es": "🔎 Buscar en la casa"
},
"stamina_cost": 3
}
}
},
"toolshed": {
"id": "toolshed",
"name": "\ud83d\udd28 Tool Shed",
"description": "A small wooden shed, door slightly ajar.",
"name": {
"en": "🔨 Tool Shed",
"es": "🔨 Almacén de herramientas"
},
"description": {
"en": "A small wooden shed, door slightly ajar.",
"es": "Un pequeño almacén de madera, la puerta está ligeramente abierta."
},
"image_path": "images/interactables/toolshed.webp",
"actions": {
"search_shed": {
"id": "search_shed",
"label": "\ud83d\udd0e Search Shed",
"label": {
"en": "🔎 Search Shed",
"es": "🔎 Buscar en el almacén"
},
"stamina_cost": 2
}
}
},
"medkit": {
"id": "medkit",
"name": "\ud83c\udfe5 Medical Supply Cabinet",
"description": "A white metal cabinet with a red cross symbol.",
"name": {
"en": "🏥 Medical Supply Cabinet",
"es": "🏥 Armario de suministros médicos"
},
"description": {
"en": "A white metal cabinet with a red cross symbol.",
"es": "Un armario de metal blanco con un símbolo de cruz roja."
},
"image_path": "images/interactables/medkit.webp",
"actions": {
"search_medkit": {
"id": "search_medkit",
"label": "\ud83d\udd0e Search Cabinet",
"label": {
"en": "🔎 Search Cabinet",
"es": "🔎 Buscar en el armario"
},
"stamina_cost": 2
}
}
},
"storage_box": {
"id": "storage_box",
"name": "📦 Storage Box",
"description": "A weathered storage container.",
"name": {
"en": "📦 Storage Box",
"es": "📦 Caja de almacenamiento"
},
"description": {
"en": "A weathered storage container.",
"es": "Un contenedor de almacenamiento desgastado."
},
"image_path": "images/interactables/storage_box.webp",
"actions": {
"search": {
"id": "search",
"label": "\ud83d\udd0e Search Box",
"label": {
"en": "🔎 Search Box",
"es": "🔎 Buscar en la caja"
},
"stamina_cost": 2
}
}
},
"vending_machine": {
"id": "vending_machine",
"name": "\ud83e\uddc3 Vending Machine",
"description": "A broken vending machine, glass shattered.",
"name": {
"en": "🧃 Vending Machine",
"es": "🧃 Máquina expendedora"
},
"description": {
"en": "A broken vending machine, glass shattered.",
"es": "Una máquina expendedora rota, el vidrio está roto."
},
"image_path": "images/interactables/vending.webp",
"actions": {
"break": {
"id": "break",
"label": "\ud83d\udd28 Break Open",
"label": {
"en": "🔨 Break Open",
"es": "🔨 Forzar la máquina"
},
"stamina_cost": 5
},
"search": {
"id": "search",
"label": "\ud83d\udd0e Search Machine",
"label": {
"en": "🔎 Search Machine",
"es": "🔎 Buscar en la máquina"
},
"stamina_cost": 2
}
}

View File

@@ -1,48 +1,78 @@
{
"items": {
"scrap_metal": {
"name": "Scrap Metal",
"name": {
"en": "Scrap Metal",
"es": "Metal desechado"
},
"type": "resource",
"weight": 0.5,
"volume": 0.2,
"emoji": "\u2699\ufe0f",
"emoji": "⚙️",
"image_path": "images/items/scrap_metal.webp",
"description": "A raw material used for crafting and upgrades."
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"rusty_nails": {
"name": "Rusty Nails",
"name": {
"en": "Rusty Nails",
"es": "Clavos oxidados"
},
"weight": 0.2,
"volume": 0.1,
"type": "resource",
"emoji": "\ud83d\udccc",
"emoji": "📌",
"image_path": "images/items/rusty_nails.webp",
"description": "A raw material used for crafting and upgrades."
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"wood_planks": {
"name": "Wood Planks",
"name": {
"en": "Wood Planks",
"es": "Tablillas de madera"
},
"weight": 3.0,
"volume": 2.0,
"type": "resource",
"emoji": "\ud83e\udeb5",
"emoji": "🪵",
"image_path": "images/items/wood_planks.webp",
"description": "A raw material used for crafting and upgrades."
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"cloth_scraps": {
"name": "Cloth Scraps",
"name": {
"en": "Cloth Scraps",
"es": "Ramas de tela"
},
"weight": 0.1,
"volume": 0.2,
"type": "resource",
"emoji": "\ud83e\uddf5",
"emoji": "🧵",
"image_path": "images/items/cloth_scraps.webp",
"description": "A raw material used for crafting and upgrades."
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"cloth": {
"name": "Cloth",
"name": {
"en": "Cloth",
"es": "Tela"
},
"type": "resource",
"weight": 0.1,
"volume": 0.2,
"emoji": "\ud83e\uddf5",
"description": "A raw material used for crafting and upgrades.",
"emoji": "🧵",
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
},
"image_path": "images/items/cloth.webp",
"uncraftable": true,
"uncraft_yield": [
@@ -59,187 +89,301 @@
]
},
"plastic_bottles": {
"name": "Plastic Bottles",
"name": {
"en": "Plastic Bottles",
"es": "Botellas de plástico"
},
"weight": 0.05,
"volume": 0.3,
"type": "resource",
"emoji": "\ud83c\udf76",
"emoji": "🍶",
"image_path": "images/items/plastic_bottles.webp",
"description": "A raw material used for crafting and upgrades."
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"bone": {
"name": "Bone",
"name": {
"en": "Bone",
"es": "Hueso"
},
"weight": 0.3,
"volume": 0.1,
"type": "resource",
"emoji": "\ud83e\uddb4",
"emoji": "🦴",
"image_path": "images/items/bone.webp",
"description": "A raw material used for crafting and upgrades."
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"raw_meat": {
"name": "Raw Meat",
"name": {
"en": "Raw Meat",
"es": "Carne cruda"
},
"weight": 0.5,
"volume": 0.2,
"type": "resource",
"emoji": "\ud83e\udd69",
"emoji": "🥩",
"image_path": "images/items/raw_meat.webp",
"description": "A raw material used for crafting and upgrades."
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"animal_hide": {
"name": "Animal Hide",
"name": {
"en": "Animal Hide",
"es": "Piel de animal"
},
"weight": 0.4,
"volume": 0.3,
"type": "resource",
"emoji": "\ud83e\udde4",
"emoji": "🧤",
"image_path": "images/items/animal_hide.webp",
"description": "A raw material used for crafting and upgrades."
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"mutant_tissue": {
"name": "Mutant Tissue",
"name": {
"en": "Mutant Tissue",
"es": "Piel de mutante"
},
"weight": 0.2,
"volume": 0.1,
"type": "resource",
"emoji": "\ud83e\uddec",
"emoji": "🧬",
"image_path": "images/items/mutant_tissue.webp",
"description": "A raw material used for crafting and upgrades."
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"infected_tissue": {
"name": "Infected Tissue",
"name": {
"en": "Infected Tissue",
"es": "Piel infectada"
},
"weight": 0.2,
"volume": 0.1,
"type": "resource",
"emoji": "\u2623\ufe0f",
"emoji": "☣️",
"image_path": "images/items/infected_tissue.webp",
"description": "A raw material used for crafting and upgrades."
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"stale_chocolate_bar": {
"name": "Stale Chocolate Bar",
"name": {
"en": "Stale Chocolate Bar",
"es": "Barra de chocolate caducada"
},
"weight": 0.1,
"volume": 0.1,
"type": "consumable",
"hp_restore": 10,
"emoji": "\ud83c\udf6b",
"emoji": "🍫",
"image_path": "images/items/stale_chocolate_bar.webp",
"description": "Can be consumed to restore health or stamina."
"description": {
"en": "Can be consumed to restore health or stamina.",
"es": "Se puede consumir para restaurar salud o stamina."
}
},
"canned_beans": {
"name": "Canned Beans",
"name": {
"en": "Canned Beans",
"es": "Frijoles enlatados"
},
"weight": 0.4,
"volume": 0.2,
"type": "consumable",
"hp_restore": 20,
"stamina_restore": 5,
"emoji": "\ud83e\udd6b",
"emoji": "🥫",
"image_path": "images/items/canned_beans.webp",
"description": "Can be consumed to restore health or stamina."
"description": {
"en": "Can be consumed to restore health or stamina.",
"es": "Se puede consumir para restaurar salud o stamina."
}
},
"canned_food": {
"name": "Canned Food",
"name": {
"en": "Canned Food",
"es": "Comida enlatada"
},
"weight": 0.4,
"volume": 0.2,
"type": "consumable",
"hp_restore": 25,
"stamina_restore": 5,
"emoji": "\ud83e\udd6b",
"emoji": "🥫",
"image_path": "images/items/canned_food.webp",
"description": "Can be consumed to restore health or stamina."
"description": {
"en": "Can be consumed to restore health or stamina.",
"es": "Se puede consumir para restaurar salud o stamina."
}
},
"bottled_water": {
"name": "Bottled Water",
"name": {
"en": "Bottled Water",
"es": "Agua embotellada"
},
"weight": 0.5,
"volume": 0.3,
"type": "consumable",
"stamina_restore": 10,
"emoji": "\ud83d\udca7",
"emoji": "💧",
"image_path": "images/items/bottled_water.webp",
"description": "Can be consumed to restore health or stamina."
"description": {
"en": "Can be consumed to restore health or stamina.",
"es": "Se puede consumir para restaurar salud o stamina."
}
},
"water_bottle": {
"name": "Water Bottle",
"name": {
"en": "Water Bottle",
"es": "Botella de agua"
},
"weight": 0.5,
"volume": 0.3,
"type": "consumable",
"stamina_restore": 10,
"emoji": "\ud83d\udca7",
"emoji": "💧",
"image_path": "images/items/water_bottle.webp",
"description": "Can be consumed to restore health or stamina."
"description": {
"en": "Can be consumed to restore health or stamina.",
"es": "Se puede consumir para restaurar salud o stamina."
}
},
"energy_bar": {
"name": "Energy Bar",
"name": {
"en": "Energy Bar",
"es": "Barra de energía"
},
"weight": 0.1,
"volume": 0.1,
"type": "consumable",
"stamina_restore": 15,
"emoji": "\ud83c\udf6b",
"emoji": "🍫",
"image_path": "images/items/energy_bar.webp",
"description": "Can be consumed to restore health or stamina."
"description": {
"en": "Can be consumed to restore health or stamina.",
"es": "Se puede consumir para restaurar salud o stamina."
}
},
"mystery_pills": {
"name": "Mystery Pills",
"name": {
"en": "Mystery Pills",
"es": "Píldoras misteriosas"
},
"weight": 0.05,
"volume": 0.05,
"type": "consumable",
"hp_restore": 30,
"emoji": "\ud83d\udc8a",
"emoji": "💊",
"image_path": "images/items/mystery_pills.webp",
"description": "Can be consumed to restore health or stamina."
"description": {
"en": "Can be consumed to restore health or stamina.",
"es": "Se puede consumir para restaurar salud o stamina."
}
},
"first_aid_kit": {
"name": "First Aid Kit",
"description": "A professional medical kit with bandages, antiseptic, and pain relievers.",
"name": {
"en": "First Aid Kit",
"es": "Kit de primeros auxilios"
},
"description": {
"en": "A professional medical kit with bandages, antiseptic, and pain relievers.",
"es": "Un kit médico profesional con vendajes, antisépticos y analgésicos."
},
"weight": 0.8,
"volume": 0.5,
"type": "consumable",
"hp_restore": 50,
"emoji": "\ud83e\ude79",
"emoji": "🩹",
"image_path": "images/items/first_aid_kit.webp"
},
"bandage": {
"name": "Bandage",
"description": "Clean cloth bandages for treating minor wounds. Can stop bleeding.",
"name": {
"en": "Bandage",
"es": "Vendaje"
},
"description": {
"en": "Clean cloth bandages for treating minor wounds. Can stop bleeding.",
"es": "Vendajes limpios de tela para tratar heridas menores. Pueden detener la sangrado."
},
"weight": 0.1,
"volume": 0.1,
"type": "consumable",
"hp_restore": 15,
"treats": "Bleeding",
"emoji": "\ud83e\ude79",
"emoji": "🩹",
"image_path": "images/items/bandage.webp"
},
"medical_supplies": {
"name": "Medical Supplies",
"description": "Assorted medical supplies scavenged from a clinic.",
"name": {
"en": "Medical Supplies",
"es": "Suministros médicos"
},
"description": {
"en": "Assorted medical supplies scavenged from a clinic.",
"es": "Suministros médicos diversos robados de una clínica."
},
"weight": 0.6,
"volume": 0.4,
"type": "consumable",
"hp_restore": 40,
"emoji": "\u2695\ufe0f",
"emoji": "⚕️",
"image_path": "images/items/medical_supplies.webp"
},
"antibiotics": {
"name": "Antibiotics",
"description": "Pills that fight infections. Expired, but better than nothing.",
"name": {
"en": "Antibiotics",
"es": "Antibióticos"
},
"description": {
"en": "Pills that fight infections. Expired, but better than nothing.",
"es": "Píldoras que combaten las infecciones. Caducadas, pero mejor que nada."
},
"weight": 0.1,
"volume": 0.1,
"type": "consumable",
"hp_restore": 20,
"treats": "Infected",
"emoji": "\ud83d\udc8a",
"emoji": "💊",
"image_path": "images/items/antibiotics.webp"
},
"rad_pills": {
"name": "Rad Pills",
"description": "Anti-radiation medication. Helps flush radioactive particles from the body.",
"name": {
"en": "Rad Pills",
"es": "Píldoras de radiación"
},
"description": {
"en": "Anti-radiation medication. Helps flush radioactive particles from the body.",
"es": "Medicamento antirradiación. Ayuda a eliminar partículas radiactivas del cuerpo."
},
"weight": 0.05,
"volume": 0.05,
"type": "consumable",
"hp_restore": 5,
"treats": "Radiation",
"emoji": "\u2622\ufe0f",
"emoji": "☢️",
"image_path": "images/items/rad_pills.webp"
},
"tire_iron": {
"name": "Tire Iron",
"description": "A heavy metal tool. Makes a decent improvised weapon.",
"name": {
"en": "Tire Iron",
"es": "Herramienta de neumático"
},
"description": {
"en": "A heavy metal tool. Makes a decent improvised weapon.",
"es": "Un herramienta de metal pesado. Sirve como un buen arma improvisada."
},
"weight": 2.0,
"volume": 1.0,
"type": "weapon",
@@ -252,17 +396,23 @@
"damage_min": 3,
"damage_max": 5
},
"emoji": "\ud83d\udd27",
"emoji": "🔧",
"image_path": "images/items/tire_iron.webp"
},
"baseball_bat": {
"name": "Baseball Bat",
"description": "Wooden bat with dents and bloodstains. Someone used this before you.",
"name": {
"en": "Baseball Bat",
"es": "Bate de béisbol"
},
"description": {
"en": "Wooden bat with dents and bloodstains. Someone used this before you.",
"es": "Bate de béisbol con dientes y manchas de sangre. Alguien lo usó antes que tú."
},
"weight": 1.0,
"volume": 1.5,
"type": "weapon",
"slot": "hand",
"emoji": "\u26be",
"emoji": "",
"image_path": "images/items/baseball_bat.webp",
"stats": {
"damage_min": 5,
@@ -270,8 +420,14 @@
}
},
"rusty_knife": {
"name": "Rusty Knife",
"description": "A dull, rusted blade. Better than your fists.",
"name": {
"en": "Rusty Knife",
"es": "Navaja oxidada"
},
"description": {
"en": "A dull, rusted blade. Better than your fists.",
"es": "Una navaja desgastada y oxidada. Mejor que tus puños."
},
"weight": 0.3,
"volume": 0.2,
"type": "weapon",
@@ -296,12 +452,18 @@
"damage_min": 2,
"damage_max": 5
},
"emoji": "\ud83d\udd2a",
"emoji": "🔪",
"image_path": "images/items/rusty_knife.webp"
},
"knife": {
"name": "Knife",
"description": "A sharp survival knife in decent condition.",
"name": {
"en": "Knife",
"es": ""
},
"description": {
"en": "A sharp survival knife in decent condition.",
"es": ""
},
"weight": 0.3,
"volume": 0.2,
"type": "weapon",
@@ -379,17 +541,23 @@
"duration": 3
}
},
"emoji": "\ud83d\udd2a",
"emoji": "🔪",
"image_path": "images/items/knife.webp"
},
"rusty_pipe": {
"name": "Rusty Pipe",
"description": "Heavy metal pipe. Crude but effective.",
"name": {
"en": "Rusty Pipe",
"es": ""
},
"description": {
"en": "Heavy metal pipe. Crude but effective.",
"es": ""
},
"weight": 1.5,
"volume": 0.8,
"type": "weapon",
"slot": "hand",
"emoji": "\ud83d\udd29",
"emoji": "🔩",
"image_path": "images/items/rusty_pipe.webp",
"stats": {
"damage_min": 5,
@@ -397,8 +565,14 @@
}
},
"tattered_rucksack": {
"name": "Tattered Rucksack",
"description": "An old backpack with torn straps. Still functional.",
"name": {
"en": "Tattered Rucksack",
"es": ""
},
"description": {
"en": "An old backpack with torn straps. Still functional.",
"es": ""
},
"weight": 1.0,
"volume": 0.5,
"type": "backpack",
@@ -434,12 +608,18 @@
"weight_capacity": 10,
"volume_capacity": 10
},
"emoji": "\ud83c\udf92",
"emoji": "🎒",
"image_path": "images/items/tattered_rucksack.webp"
},
"hiking_backpack": {
"name": "Hiking Backpack",
"description": "A quality backpack with multiple compartments.",
"name": {
"en": "Hiking Backpack",
"es": ""
},
"description": {
"en": "A quality backpack with multiple compartments.",
"es": ""
},
"weight": 1.5,
"volume": 0.7,
"type": "backpack",
@@ -464,17 +644,23 @@
"weight_capacity": 20,
"volume_capacity": 20
},
"emoji": "\ud83c\udf92",
"emoji": "🎒",
"image_path": "images/items/hiking_backpack.webp"
},
"flashlight": {
"name": "Flashlight",
"description": "A battery-powered flashlight. Batteries low but working.",
"name": {
"en": "Flashlight",
"es": ""
},
"description": {
"en": "A battery-powered flashlight. Batteries low but working.",
"es": ""
},
"weight": 0.3,
"volume": 0.2,
"type": "tool",
"slot": "tool",
"emoji": "\ud83d\udd26",
"emoji": "🔦",
"image_path": "images/items/flashlight.webp",
"stats": {
"damage_min": 5,
@@ -482,26 +668,44 @@
}
},
"old_photograph": {
"name": "Old Photograph",
"name": {
"en": "Old Photograph",
"es": ""
},
"weight": 0.01,
"volume": 0.01,
"type": "quest",
"emoji": "\ud83d\udcf7",
"emoji": "📷",
"image_path": "images/items/old_photograph.webp",
"description": "A useful old photograph."
"description": {
"en": "A useful old photograph.",
"es": ""
}
},
"key_ring": {
"name": "Key Ring",
"name": {
"en": "Key Ring",
"es": ""
},
"weight": 0.1,
"volume": 0.05,
"type": "quest",
"emoji": "\ud83d\udd11",
"emoji": "🔑",
"image_path": "images/items/key_ring.webp",
"description": "A useful key ring."
"description": {
"en": "A useful key ring.",
"es": ""
}
},
"makeshift_spear": {
"name": "Makeshift Spear",
"description": "A crude spear made from a sharpened stick and scrap metal.",
"name": {
"en": "Makeshift Spear",
"es": ""
},
"description": {
"en": "A crude spear made from a sharpened stick and scrap metal.",
"es": ""
},
"weight": 1.2,
"volume": 2.0,
"type": "weapon",
@@ -541,12 +745,18 @@
"damage_min": 4,
"damage_max": 7
},
"emoji": "\u2694\ufe0f",
"emoji": "⚔️",
"image_path": "images/items/makeshift_spear.webp"
},
"reinforced_bat": {
"name": "Reinforced Bat",
"description": "A wooden bat wrapped with scrap metal and nails. Brutal.",
"name": {
"en": "Reinforced Bat",
"es": ""
},
"description": {
"en": "A wooden bat wrapped with scrap metal and nails. Brutal.",
"es": ""
},
"weight": 1.8,
"volume": 1.5,
"type": "weapon",
@@ -592,12 +802,18 @@
"duration": 1
}
},
"emoji": "\ud83c\udff8",
"emoji": "🏸",
"image_path": "images/items/reinforced_bat.webp"
},
"leather_vest": {
"name": "Leather Vest",
"description": "A makeshift vest crafted from leather scraps. Provides basic protection.",
"name": {
"en": "Leather Vest",
"es": ""
},
"description": {
"en": "A makeshift vest crafted from leather scraps. Provides basic protection.",
"es": ""
},
"weight": 1.5,
"volume": 1.0,
"type": "armor",
@@ -637,12 +853,18 @@
"armor": 3,
"hp_bonus": 10
},
"emoji": "\ud83e\uddba",
"emoji": "🦺",
"image_path": "images/items/leather_vest.webp"
},
"cloth_bandana": {
"name": "Cloth Bandana",
"description": "A simple cloth head covering. Keeps the sun and dust out.",
"name": {
"en": "Cloth Bandana",
"es": ""
},
"description": {
"en": "A simple cloth head covering. Keeps the sun and dust out.",
"es": ""
},
"weight": 0.1,
"volume": 0.1,
"type": "clothing",
@@ -669,12 +891,18 @@
"stats": {
"armor": 1
},
"emoji": "\ud83e\udde3",
"emoji": "🧣",
"image_path": "images/items/cloth_bandana.webp"
},
"sturdy_boots": {
"name": "Sturdy Boots",
"description": "Reinforced boots for traversing the wasteland.",
"name": {
"en": "Sturdy Boots",
"es": ""
},
"description": {
"en": "Reinforced boots for traversing the wasteland.",
"es": ""
},
"weight": 1.0,
"volume": 0.8,
"type": "clothing",
@@ -714,12 +942,18 @@
"armor": 2,
"stamina_bonus": 5
},
"emoji": "\ud83e\udd7e",
"emoji": "🥾",
"image_path": "images/items/sturdy_boots.webp"
},
"padded_pants": {
"name": "Padded Pants",
"description": "Pants reinforced with extra padding for protection.",
"name": {
"en": "Padded Pants",
"es": ""
},
"description": {
"en": "Pants reinforced with extra padding for protection.",
"es": ""
},
"weight": 0.8,
"volume": 0.6,
"type": "armor",
@@ -755,12 +989,18 @@
"armor": 2,
"hp_bonus": 5
},
"emoji": "\ud83d\udc56",
"emoji": "👖",
"image_path": "images/items/padded_pants.webp"
},
"reinforced_pack": {
"name": "Reinforced Pack",
"description": "A custom-built backpack with metal frame and extra pockets.",
"name": {
"en": "Reinforced Pack",
"es": ""
},
"description": {
"en": "A custom-built backpack with metal frame and extra pockets.",
"es": ""
},
"weight": 2.0,
"volume": 0.9,
"type": "backpack",
@@ -839,12 +1079,18 @@
"weight_capacity": 30,
"volume_capacity": 30
},
"emoji": "\ud83c\udf92",
"emoji": "🎒",
"image_path": "images/items/reinforced_pack.webp"
},
"hammer": {
"name": "Hammer",
"description": "A basic tool for crafting and repairs. Essential for any survivor.",
"name": {
"en": "Hammer",
"es": ""
},
"description": {
"en": "A basic tool for crafting and repairs. Essential for any survivor.",
"es": ""
},
"weight": 0.8,
"volume": 0.4,
"type": "tool",
@@ -872,12 +1118,18 @@
}
],
"repair_percentage": 30,
"emoji": "\ud83d\udd28",
"emoji": "🔨",
"image_path": "images/items/hammer.webp"
},
"screwdriver": {
"name": "Screwdriver",
"description": "A flathead screwdriver. Useful for repairs and scavenging.",
"name": {
"en": "Screwdriver",
"es": ""
},
"description": {
"en": "A flathead screwdriver. Useful for repairs and scavenging.",
"es": ""
},
"weight": 0.2,
"volume": 0.2,
"type": "tool",
@@ -905,7 +1157,7 @@
}
],
"repair_percentage": 25,
"emoji": "\ud83e\ude9b",
"emoji": "🪛",
"image_path": "images/items/screwdriver.webp",
"stats": {
"damage_min": 5,

View File

@@ -2,8 +2,14 @@
"locations": [
{
"id": "start_point",
"name": "\ud83c\udf06 Ruined Downtown Core",
"description": "The wind howls through skeletal skyscrapers. Debris litters the cracked asphalt. You sense danger, but also opportunity.",
"name": {
"en": "🌆 Ruined Downtown Core",
"es": "🌆 Centro de la ciudad destruido"
},
"description": {
"en": "The wind howls through skeletal skyscrapers. Debris litters the cracked asphalt. You sense danger, but also opportunity.",
"es": "El viento ruge a través de los esqueléticos rascacielos. El desastre llena el asfalto roto. Sientes el peligro, pero también la oportunidad."
},
"image_path": "images/locations/downtown.webp",
"x": 0,
"y": 0,
@@ -33,10 +39,19 @@
"stamina_cost": 2,
"success_rate": 0.5,
"text": {
"crit_failure": "You disturb a nest of rats! They bite you! (-8 HP)",
"crit_failure": {
"en": "You disturb a nest of rats! They bite you!",
"es": "Te topas con una colmena de ratones. Te muerden!"
},
"crit_success": "",
"failure": "Just rotting garbage. Nothing useful.",
"success": "Despite the smell, you find some [Plastic Bottles] and [Cloth Scraps]."
"failure": {
"en": "Just rotting garbage. Nothing useful.",
"es": "Solo escombros rotos. Nada útil."
},
"success": {
"en": "Despite the smell, you find some [Plastic Bottles] and [Cloth Scraps].",
"es": "A pesar del olor, encuentras algunos [Botellas de plástico] y [Ramas de tela]."
}
}
}
},
@@ -64,8 +79,14 @@
"text": {
"crit_failure": "",
"crit_success": "",
"failure": "The trunk is rusted shut. You can't get it open.",
"success": "With a great heave, you pry the trunk open and find a [Tire Iron]!"
"failure": {
"en": "The trunk is rusted shut. You can't get it open.",
"es": "El maletero está oxidado. No puedes abrirlo."
},
"success": {
"en": "With a great heave, you pry the trunk open and find a [Tire Iron]!",
"es": "Con un gran esfuerzo, pruebas el maletero y encuentras una [Herramienta de neumáticos]!"
}
}
},
"search_glovebox": {
@@ -88,8 +109,14 @@
"text": {
"crit_failure": "",
"crit_success": "",
"failure": "The glovebox is empty except for dust and old receipts.",
"success": "You find a half-eaten [Stale Chocolate Bar]."
"failure": {
"en": "The glovebox is empty except for dust and old receipts.",
"es": ""
},
"success": {
"en": "You find a half-eaten [Stale Chocolate Bar].",
"es": ""
}
}
}
},
@@ -99,8 +126,14 @@
},
{
"id": "gas_station",
"name": "\u26fd\ufe0f Abandoned Gas Station",
"description": "The smell of stale gasoline hangs in the air. A rusty sedan sits by the pumps, its door ajar. Behind the station, you spot a small tool shed with a workbench.",
"name": {
"en": "⛽️ Abandoned Gas Station",
"es": "⛽️ Gasolinera abandonada"
},
"description": {
"en": "The smell of stale gasoline hangs in the air. A rusty sedan sits by the pumps, its door ajar. Behind the station, you spot a small tool shed with a workbench.",
"es": "El olor a gasolina se suspende en el aire. Un sedán oxidado está en los surtidores, su puerta está abierta. Por detrás de la gasolinera, ves un pequeño almacén de herramientas con una mesa de trabajo."
},
"image_path": "images/locations/gas_station.webp",
"x": 0,
"y": 2,
@@ -141,10 +174,22 @@
]
},
"text": {
"success": "You find some cloth scraps and plastic in the glovebox.",
"failure": "The glovebox is empty except for old papers.",
"crit_success": "You find scrap metal from the dashboard!",
"crit_failure": "The glovebox is jammed shut."
"success": {
"en": "You find some cloth scraps and plastic in the glovebox.",
"es": ""
},
"failure": {
"en": "The glovebox is empty except for old papers.",
"es": ""
},
"crit_success": {
"en": "You find scrap metal from the dashboard!",
"es": ""
},
"crit_failure": {
"en": "The glovebox is jammed shut.",
"es": ""
}
}
},
"pop_trunk": {
@@ -176,10 +221,22 @@
]
},
"text": {
"success": "You force the trunk open and find scrap metal and plastic.",
"failure": "The trunk is rusted shut.",
"crit_success": "The trunk contains tools!",
"crit_failure": "You cut your hand on rusty metal! (-5 HP)"
"success": {
"en": "You force the trunk open and find scrap metal and plastic.",
"es": ""
},
"failure": {
"en": "The trunk is rusted shut.",
"es": ""
},
"crit_success": {
"en": "The trunk contains tools!",
"es": ""
},
"crit_failure": {
"en": "You cut your hand on rusty metal! (-5 HP)",
"es": ""
}
}
}
}
@@ -216,10 +273,22 @@
]
},
"text": {
"success": "You find scrap metal and cloth in the storage box.",
"failure": "The storage box is mostly empty.",
"crit_success": "You discover tools inside!",
"crit_failure": "Just oil stains and rust."
"success": {
"en": "You find scrap metal and cloth in the storage box.",
"es": ""
},
"failure": {
"en": "The storage box is mostly empty.",
"es": ""
},
"crit_success": {
"en": "You discover tools inside!",
"es": ""
},
"crit_failure": {
"en": "Just oil stains and rust.",
"es": ""
}
}
}
}
@@ -228,8 +297,14 @@
},
{
"id": "residential",
"name": "\ud83c\udfd8\ufe0f Residential Street",
"description": "A quiet suburban street lined with abandoned homes. Most are boarded up, but a few doors hang open, creaking in the wind.",
"name": {
"en": "🏘️ Residential Street",
"es": "🏘️ Calle residencial"
},
"description": {
"en": "A quiet suburban street lined with abandoned homes. Most are boarded up, but a few doors hang open, creaking in the wind.",
"es": "Una tranquila calle suburbana llena de casas abandonadas. La mayoría están tapiadas, pero algunas puertas están abiertas, movidas por el viento."
},
"image_path": "images/locations/residential.webp",
"x": 3,
"y": 0,
@@ -264,10 +339,19 @@
"stamina_cost": 3,
"success_rate": 0.5,
"text": {
"crit_failure": "The floor collapses beneath you! (-10 HP)",
"crit_failure": {
"en": "The floor collapses beneath you! (-10 HP)",
"es": ""
},
"crit_success": "",
"failure": "The house has already been thoroughly looted. Nothing remains.",
"success": "You find some useful supplies: [Canned Beans], [Bottled Water], and [Cloth Scraps]!"
"failure": {
"en": "The house has already been thoroughly looted. Nothing remains.",
"es": ""
},
"success": {
"en": "You find some useful supplies: [Canned Beans], [Bottled Water], and [Cloth Scraps]!",
"es": ""
}
}
}
},
@@ -277,8 +361,14 @@
},
{
"id": "clinic",
"name": "\ud83c\udfe5 Old Clinic",
"description": "A small medical clinic, its windows shattered. The waiting room is a mess of overturned chairs and scattered papers. The examination rooms might still have supplies.",
"name": {
"en": "🏥 Old Clinic",
"es": "🏥 Clínica abandonada"
},
"description": {
"en": "A small medical clinic, its windows shattered. The waiting room is a mess of overturned chairs and scattered papers. The examination rooms might still have supplies.",
"es": "Una pequeña clínica médica, sus ventanas están rotas. El salón de espera es un desastre de sillas invertidas y papeles dispersos. Las habitaciones de examen pueden todavía tener suministros."
},
"image_path": "images/locations/clinic.webp",
"x": 2,
"y": 3,
@@ -310,8 +400,14 @@
"text": {
"crit_failure": "",
"crit_success": "",
"failure": "The cabinet is empty. Someone got here first.",
"success": "Jackpot! You find a [First Aid Kit] and some [Bandages]!"
"failure": {
"en": "The cabinet is empty. Someone got here first.",
"es": ""
},
"success": {
"en": "Jackpot! You find a [First Aid Kit] and some [Bandages]!",
"es": ""
}
}
}
},
@@ -321,8 +417,14 @@
},
{
"id": "plaza",
"name": "\ud83c\udfec Shopping Plaza",
"description": "A strip mall with broken storefronts. Most shops have been thoroughly ransacked, but you might find something if you search carefully.",
"name": {
"en": "🏬 Shopping Plaza",
"es": "🏬 Plaza de comercio"
},
"description": {
"en": "A strip mall with broken storefronts. Most shops have been thoroughly ransacked, but you might find something if you search carefully.",
"es": "Una plaza de comercio con vitrinas rotas. La mayoría de las tiendas han sido despojadas, pero puedes encontrar algo si buscas con cuidado."
},
"image_path": "images/locations/plaza.webp",
"x": -2.5,
"y": 0,
@@ -359,10 +461,22 @@
]
},
"text": {
"success": "You smash the vending machine and grab bottles and scrap.",
"failure": "The machine is too sturdy to break.",
"crit_success": "Packaged food falls out!",
"crit_failure": "Glass cuts your arm! (-10 HP)"
"success": {
"en": "You smash the vending machine and grab bottles and scrap.",
"es": ""
},
"failure": {
"en": "The machine is too sturdy to break.",
"es": ""
},
"crit_success": {
"en": "Packaged food falls out!",
"es": ""
},
"crit_failure": {
"en": "Glass cuts your arm! (-10 HP)",
"es": ""
}
}
},
"search": {
@@ -389,10 +503,22 @@
]
},
"text": {
"success": "You find a plastic bottle at the bottom.",
"failure": "Nothing left to scavenge.",
"crit_success": "A snack is wedged in the dispenser!",
"crit_failure": "Already picked clean."
"success": {
"en": "You find a plastic bottle at the bottom.",
"es": ""
},
"failure": {
"en": "Nothing left to scavenge.",
"es": ""
},
"crit_success": {
"en": "A snack is wedged in the dispenser!",
"es": ""
},
"crit_failure": {
"en": "Already picked clean.",
"es": ""
}
}
}
}
@@ -429,10 +555,22 @@
]
},
"text": {
"success": "You dig through rubble and find scrap metal and cloth.",
"failure": "Just broken concrete and dust.",
"crit_success": "A tool was buried in the debris!",
"crit_failure": "Sharp debris cuts you! (-5 HP)"
"success": {
"en": "You dig through rubble and find scrap metal and cloth.",
"es": ""
},
"failure": {
"en": "Just broken concrete and dust.",
"es": ""
},
"crit_success": {
"en": "A tool was buried in the debris!",
"es": ""
},
"crit_failure": {
"en": "Sharp debris cuts you! (-5 HP)",
"es": ""
}
}
}
}
@@ -441,8 +579,14 @@
},
{
"id": "park",
"name": "\ud83c\udf33 Suburban Park",
"description": "An overgrown park with rusted playground equipment. Nature is slowly reclaiming this space. A maintenance shed sits at the far end.",
"name": {
"en": "🌳 Suburban Park",
"es": "🌳 Parque suburbano"
},
"description": {
"en": "An overgrown park with rusted playground equipment. Nature is slowly reclaiming this space. A maintenance shed sits at the far end.",
"es": "Un parque suburbano deshabitado con equipos de juegos oxidados. La naturaleza está reclamando este espacio. Un almacén de mantenimiento se encuentra al final."
},
"image_path": "images/locations/park.webp",
"x": -1,
"y": -2,
@@ -484,8 +628,14 @@
"text": {
"crit_failure": "",
"crit_success": "",
"failure": "The shed has been picked clean. Only empty shelves remain.",
"success": "You find some tools: [Rusty Nails], [Wood Planks], and a [Flashlight]!"
"failure": {
"en": "The shed has been picked clean. Only empty shelves remain.",
"es": ""
},
"success": {
"en": "You find some tools: [Rusty Nails], [Wood Planks], and a [Flashlight]!",
"es": ""
}
}
}
},
@@ -495,8 +645,14 @@
},
{
"id": "overpass",
"name": "\ud83d\udee3\ufe0f Highway Overpass",
"description": "A concrete overpass spanning the cracked highway below. Abandoned vehicles litter the road. This is a good vantage point to survey the area.",
"name": {
"en": "🛣️ Highway Overpass",
"es": "🛣️ Puesto de carretera"
},
"description": {
"en": "A concrete overpass spanning the cracked highway below. Abandoned vehicles litter the road. This is a good vantage point to survey the area.",
"es": "Un puesto de carretera de cemento que atraviesa la carretera rota por debajo. Vehículos abandonados se desvanecen por la carretera. Este es un buen punto de vista para examinar el área."
},
"x": 1.0,
"y": 4.5,
"image_path": "images/locations/overpass.webp",
@@ -510,8 +666,14 @@
"crit_success_chance": 0.1,
"crit_failure_chance": 0.1,
"text": {
"success": "You find a half-eaten [Stale Chocolate Bar].",
"failure": "The glovebox is empty except for dust and old receipts.",
"success": {
"en": "You find a half-eaten [Stale Chocolate Bar].",
"es": ""
},
"failure": {
"en": "The glovebox is empty except for dust and old receipts.",
"es": ""
},
"crit_success": "",
"crit_failure": ""
},
@@ -534,8 +696,14 @@
"crit_success_chance": 0.1,
"crit_failure_chance": 0.1,
"text": {
"success": "With a great heave, you pry the trunk open and find a [Tire Iron]!",
"failure": "The trunk is rusted shut. You can't get it open.",
"success": {
"en": "With a great heave, you pry the trunk open and find a [Tire Iron]!",
"es": ""
},
"failure": {
"en": "The trunk is rusted shut. You can't get it open.",
"es": ""
},
"crit_success": "",
"crit_failure": ""
},
@@ -563,8 +731,14 @@
"crit_success_chance": 0.1,
"crit_failure_chance": 0.1,
"text": {
"success": "You find a half-eaten [Stale Chocolate Bar].",
"failure": "The glovebox is empty except for dust and old receipts.",
"success": {
"en": "You find a half-eaten [Stale Chocolate Bar].",
"es": ""
},
"failure": {
"en": "The glovebox is empty except for dust and old receipts.",
"es": ""
},
"crit_success": "",
"crit_failure": ""
},
@@ -587,8 +761,14 @@
"crit_success_chance": 0.1,
"crit_failure_chance": 0.1,
"text": {
"success": "With a great heave, you pry the trunk open and find a [Tire Iron]!",
"failure": "The trunk is rusted shut. You can't get it open.",
"success": {
"en": "With a great heave, you pry the trunk open and find a [Tire Iron]!",
"es": ""
},
"failure": {
"en": "The trunk is rusted shut. You can't get it open.",
"es": ""
},
"crit_success": "",
"crit_failure": ""
},
@@ -611,8 +791,14 @@
},
{
"id": "warehouse",
"name": "\ud83c\udfed Warehouse District",
"description": "Rows of industrial warehouses stretch before you. Metal doors creak in the wind. The loading docks are littered with debris and abandoned cargo.",
"name": {
"en": "🏭 Warehouse District",
"es": ""
},
"description": {
"en": "Rows of industrial warehouses stretch before you. Metal doors creak in the wind. The loading docks are littered with debris and abandoned cargo.",
"es": ""
},
"image_path": "images/locations/warehouse.webp",
"x": 4,
"y": -1.5,
@@ -642,10 +828,19 @@
"stamina_cost": 2,
"success_rate": 0.5,
"text": {
"crit_failure": "You disturb a nest of rats! They bite you! (-8 HP)",
"crit_failure": {
"en": "You disturb a nest of rats! They bite you! (-8 HP)",
"es": ""
},
"crit_success": "",
"failure": "Just rotting garbage. Nothing useful.",
"success": "Despite the smell, you find some [Plastic Bottles] and [Cloth Scraps]."
"failure": {
"en": "Just rotting garbage. Nothing useful.",
"es": ""
},
"success": {
"en": "Despite the smell, you find some [Plastic Bottles] and [Cloth Scraps].",
"es": ""
}
}
}
},
@@ -683,8 +878,14 @@
"text": {
"crit_failure": "",
"crit_success": "",
"failure": "The shed has been picked clean. Only empty shelves remain.",
"success": "You find some tools: [Rusty Nails], [Wood Planks], and a [Flashlight]!"
"failure": {
"en": "The shed has been picked clean. Only empty shelves remain.",
"es": ""
},
"success": {
"en": "You find some tools: [Rusty Nails], [Wood Planks], and a [Flashlight]!",
"es": ""
}
}
}
},
@@ -694,8 +895,14 @@
},
{
"id": "warehouse_interior",
"name": "\ud83d\udce6 Warehouse Interior",
"description": "Inside the warehouse, towering shelves cast long shadows. Scattered crates and pallets suggest this was once a distribution center. The back office door hangs open.",
"name": {
"en": "📦 Warehouse Interior",
"es": ""
},
"description": {
"en": "Inside the warehouse, towering shelves cast long shadows. Scattered crates and pallets suggest this was once a distribution center. The back office door hangs open.",
"es": ""
},
"image_path": "images/locations/warehouse_interior.webp",
"x": 4.5,
"y": -2,
@@ -709,8 +916,14 @@
"crit_success_chance": 0,
"crit_failure_chance": 0,
"text": {
"success": "You successfully \ud83d\udd0e search box.",
"failure": "You failed to \ud83d\udd0e search box.",
"success": {
"en": "You successfully 🔎 search box.",
"es": ""
},
"failure": {
"en": "You failed to 🔎 search box.",
"es": ""
},
"crit_success": "",
"crit_failure": ""
},
@@ -738,8 +951,14 @@
},
{
"id": "subway",
"name": "\ud83d\ude87 Subway Station Entrance",
"description": "Stairs descend into darkness. The entrance to an abandoned subway station yawns before you. Emergency lighting flickers somewhere below.",
"name": {
"en": "🚇 Subway Station Entrance",
"es": ""
},
"description": {
"en": "Stairs descend into darkness. The entrance to an abandoned subway station yawns before you. Emergency lighting flickers somewhere below.",
"es": ""
},
"image_path": "images/locations/subway.webp",
"x": -4,
"y": -0.5,
@@ -775,10 +994,22 @@
"stamina_cost": 2,
"success_rate": 0.55,
"text": {
"crit_failure": "Debris shifts and hits your leg! (-4 HP)",
"crit_success": "You uncover a tool buried deep!",
"failure": "Just concrete chunks.",
"success": "You sift through rubble and find scrap metal."
"crit_failure": {
"en": "Debris shifts and hits your leg! (-4 HP)",
"es": ""
},
"crit_success": {
"en": "You uncover a tool buried deep!",
"es": ""
},
"failure": {
"en": "Just concrete chunks.",
"es": ""
},
"success": {
"en": "You sift through rubble and find scrap metal.",
"es": ""
}
}
}
},
@@ -810,10 +1041,22 @@
"stamina_cost": 5,
"success_rate": 0.6,
"text": {
"crit_failure": "The machine topples on you! (-12 HP)",
"crit_success": "Food packages tumble out!",
"failure": "The machine won't budge.",
"success": "You bash open the vending machine and grab bottles."
"crit_failure": {
"en": "The machine topples on you! (-12 HP)",
"es": ""
},
"crit_success": {
"en": "Food packages tumble out!",
"es": ""
},
"failure": {
"en": "The machine won't budge.",
"es": ""
},
"success": {
"en": "You bash open the vending machine and grab bottles.",
"es": ""
}
}
},
"search": {
@@ -834,10 +1077,22 @@
"stamina_cost": 2,
"success_rate": 0.4,
"text": {
"crit_failure": "Nothing here.",
"crit_success": "A bottle still rolls out!",
"failure": "Completely empty.",
"success": "You find a bottle in the machine's slot."
"crit_failure": {
"en": "Nothing here.",
"es": ""
},
"crit_success": {
"en": "A bottle still rolls out!",
"es": ""
},
"failure": {
"en": "Completely empty.",
"es": ""
},
"success": {
"en": "You find a bottle in the machine's slot.",
"es": ""
}
}
}
},
@@ -847,8 +1102,14 @@
},
{
"id": "subway_tunnels",
"name": "\ud83d\ude8a Subway Tunnels",
"description": "Dark subway tunnels stretch into blackness. Flickering emergency lights cast eerie shadows. The third rail is dead, but you should still watch your step.",
"name": {
"en": "🚊 Subway Tunnels",
"es": ""
},
"description": {
"en": "Dark subway tunnels stretch into blackness. Flickering emergency lights cast eerie shadows. The third rail is dead, but you should still watch your step.",
"es": ""
},
"image_path": "images/locations/subway_tunnels.webp",
"x": -4.5,
"y": -1,
@@ -880,10 +1141,22 @@
]
},
"text": {
"success": "You find scrap metal in the tunnel debris.",
"failure": "Just rocks and dirt.",
"crit_success": "A maintenance tool was left behind!",
"crit_failure": "You stumble and hit the wall! (-6 HP)"
"success": {
"en": "You find scrap metal in the tunnel debris.",
"es": ""
},
"failure": {
"en": "Just rocks and dirt.",
"es": ""
},
"crit_success": {
"en": "A maintenance tool was left behind!",
"es": ""
},
"crit_failure": {
"en": "You stumble and hit the wall! (-6 HP)",
"es": ""
}
}
}
}
@@ -892,8 +1165,14 @@
},
{
"id": "office_building",
"name": "\ud83c\udfe2 Office Building",
"description": "A five-story office building with shattered windows. The lobby is trashed, but the stairs appear intact. You can hear the wind whistling through the upper floors.",
"name": {
"en": "🏢 Office Building",
"es": ""
},
"description": {
"en": "A five-story office building with shattered windows. The lobby is trashed, but the stairs appear intact. You can hear the wind whistling through the upper floors.",
"es": ""
},
"image_path": "images/locations/office_building.webp",
"x": 3.5,
"y": 4,
@@ -924,10 +1203,22 @@
"crit_items": []
},
"text": {
"success": "You find scrap metal and cloth in the lobby debris.",
"failure": "Just broken furniture and papers.",
"crit_success": "You discover useful materials!",
"crit_failure": "Glass cuts your hand! (-5 HP)"
"success": {
"en": "You find scrap metal and cloth in the lobby debris.",
"es": ""
},
"failure": {
"en": "Just broken furniture and papers.",
"es": ""
},
"crit_success": {
"en": "You discover useful materials!",
"es": ""
},
"crit_failure": {
"en": "Glass cuts your hand! (-5 HP)",
"es": ""
}
}
}
}
@@ -936,8 +1227,14 @@
},
{
"id": "office_interior",
"name": "\ud83d\udcbc Office Floors",
"description": "Cubicles stretch across the floor. Papers scatter in the breeze from broken windows. Filing cabinets stand open, already ransacked. A corner office looks promising.",
"name": {
"en": "💼 Office Floors",
"es": ""
},
"description": {
"en": "Cubicles stretch across the floor. Papers scatter in the breeze from broken windows. Filing cabinets stand open, already ransacked. A corner office looks promising.",
"es": ""
},
"image_path": "images/locations/office_interior.webp",
"x": 4,
"y": 4.5,
@@ -974,10 +1271,22 @@
]
},
"text": {
"success": "You find cloth and bottles in desk drawers.",
"failure": "Everything's been picked through already.",
"crit_success": "Someone left food in their desk!",
"crit_failure": "Just old paperwork."
"success": {
"en": "You find cloth and bottles in desk drawers.",
"es": ""
},
"failure": {
"en": "Everything's been picked through already.",
"es": ""
},
"crit_success": {
"en": "Someone left food in their desk!",
"es": ""
},
"crit_failure": {
"en": "Just old paperwork.",
"es": ""
}
}
}
}
@@ -986,8 +1295,14 @@
},
{
"id": "location_1760791397492",
"name": "Subway Section A",
"description": "A shady dimly lit subway section. All you can see are abandoned train tracks and some garbage lying around. ",
"name": {
"en": "Subway Section A",
"es": ""
},
"description": {
"en": "A shady dimly lit subway section. All you can see are abandoned train tracks and some garbage lying around. ",
"es": ""
},
"image_path": "images/locations/subway_section_a.jpg",
"x": -5,
"y": -2,
@@ -1019,10 +1334,22 @@
]
},
"text": {
"success": "You dig through the garbage and find scrap metal.",
"failure": "Just rotting trash.",
"crit_success": "A tool was discarded here!",
"crit_failure": "You step on sharp debris! (-5 HP)"
"success": {
"en": "You dig through the garbage and find scrap metal.",
"es": ""
},
"failure": {
"en": "Just rotting trash.",
"es": ""
},
"crit_success": {
"en": "A tool was discarded here!",
"es": ""
},
"crit_failure": {
"en": "You step on sharp debris! (-5 HP)",
"es": ""
}
}
}
}

View File

@@ -2,8 +2,14 @@
"npcs": {
"feral_dog": {
"npc_id": "feral_dog",
"name": "Feral Dog",
"description": "A wild, mangy dog with desperate hunger in its eyes. Its ribs are visible beneath matted fur.",
"name": {
"en": "Feral Dog",
"es": "Perro feroz"
},
"description": {
"en": "A wild, mangy dog with desperate hunger in its eyes. Its ribs are visible beneath matted fur.",
"es": "Un perro salvaje, desgarrado, con hambre desesperada en sus ojos. Sus huesos están visibles bajo el pelo despeinado."
},
"emoji": "🐕",
"hp_min": 15,
"hp_max": 25,
@@ -46,8 +52,14 @@
},
"raider_scout": {
"npc_id": "raider_scout",
"name": "Raider Scout",
"description": "A lone raider wearing makeshift armor. They eye you with hostile intent.",
"name": {
"en": "Raider Scout",
"es": ""
},
"description": {
"en": "A lone raider wearing makeshift armor. They eye you with hostile intent.",
"es": ""
},
"emoji": "🏴‍☠️",
"hp_min": 30,
"hp_max": 45,
@@ -102,8 +114,14 @@
},
"mutant_rat": {
"npc_id": "mutant_rat",
"name": "Mutant Rat",
"description": "A grotesquely large rat, its fur patchy and eyes glowing with unnatural light.",
"name": {
"en": "Mutant Rat",
"es": ""
},
"description": {
"en": "A grotesquely large rat, its fur patchy and eyes glowing with unnatural light.",
"es": ""
},
"emoji": "🐀",
"hp_min": 10,
"hp_max": 18,
@@ -140,8 +158,14 @@
},
"infected_human": {
"npc_id": "infected_human",
"name": "Infected Human",
"description": "Once human, now something else. Their movements are jerky and their skin shows signs of advanced infection.",
"name": {
"en": "Infected Human",
"es": ""
},
"description": {
"en": "Once human, now something else. Their movements are jerky and their skin shows signs of advanced infection.",
"es": ""
},
"emoji": "🧟",
"hp_min": 35,
"hp_max": 50,
@@ -184,8 +208,14 @@
},
"scavenger": {
"npc_id": "scavenger",
"name": "Hostile Scavenger",
"description": "Another survivor, but this one sees you as competition. They won't share territory.",
"name": {
"en": "Hostile Scavenger",
"es": ""
},
"description": {
"en": "Another survivor, but this one sees you as competition. They won't share territory.",
"es": ""
},
"emoji": "💀",
"hp_min": 25,
"hp_max": 40,
@@ -264,23 +294,23 @@
},
"residential": {
"danger_level": 1,
"encounter_rate": 0.10,
"wandering_chance": 0.20
"encounter_rate": 0.1,
"wandering_chance": 0.2
},
"park": {
"danger_level": 1,
"encounter_rate": 0.10,
"wandering_chance": 0.20
"encounter_rate": 0.1,
"wandering_chance": 0.2
},
"clinic": {
"danger_level": 2,
"encounter_rate": 0.20,
"encounter_rate": 0.2,
"wandering_chance": 0.35
},
"plaza": {
"danger_level": 2,
"encounter_rate": 0.15,
"wandering_chance": 0.30
"wandering_chance": 0.3
},
"warehouse": {
"danger_level": 2,
@@ -290,27 +320,27 @@
"warehouse_interior": {
"danger_level": 2,
"encounter_rate": 0.22,
"wandering_chance": 0.40
"wandering_chance": 0.4
},
"overpass": {
"danger_level": 3,
"encounter_rate": 0.30,
"encounter_rate": 0.3,
"wandering_chance": 0.45
},
"office_building": {
"danger_level": 3,
"encounter_rate": 0.25,
"wandering_chance": 0.40
"wandering_chance": 0.4
},
"office_interior": {
"danger_level": 3,
"encounter_rate": 0.35,
"wandering_chance": 0.50
"wandering_chance": 0.5
},
"subway": {
"danger_level": 4,
"encounter_rate": 0.35,
"wandering_chance": 0.50
"wandering_chance": 0.5
},
"subway_tunnels": {
"danger_level": 4,
@@ -468,4 +498,4 @@
}
]
}
}
}

View File

@@ -1,105 +0,0 @@
extends Control
@onready var token_input = $VBoxContainer/HBoxContainer/TokenInput
@onready var status_label = $VBoxContainer/ConnectionStatusLabel
@onready var location_name_label = $VBoxContainer/LocationNameLabel
@onready var location_image = $VBoxContainer/LocationImage
@onready var location_desc_label = $VBoxContainer/LocationDescriptionLabel
@onready var log_label = $VBoxContainer/LogLabel
var socket = WebSocketPeer.new()
var http_request : HTTPRequest
var is_connected_to_host = false
func _ready():
log_message("Godot PoC Started")
http_request = HTTPRequest.new()
add_child(http_request)
http_request.request_completed.connect(_on_image_request_completed)
func _process(delta):
socket.poll()
var state = socket.get_ready_state()
if state == WebSocketPeer.STATE_OPEN:
if not is_connected_to_host:
is_connected_to_host = true
status_label.text = "Status: Connected"
log_message("WebSocket Connected!")
while socket.get_available_packet_count():
var packet = socket.get_packet()
var data = packet.get_string_from_utf8()
var json = JSON.new()
var error = json.parse(data)
if error == OK:
handle_message(json.get_data())
else:
log_message("Error parsing JSON: " + data)
elif state == WebSocketPeer.STATE_CLOSED:
if is_connected_to_host:
is_connected_to_host = false
status_label.text = "Status: Disconnected"
log_message("WebSocket Disconnected")
func _on_connect_button_pressed():
var token = token_input.text.strip_edges()
if token == "":
log_message("Please enter a token.")
return
var url = "wss://api-staging.echoesoftheash.com/ws/game/" + token
log_message("Connecting to: " + url)
var err = socket.connect_to_url(url)
if err != OK:
log_message("Error connecting to URL: " + str(err))
else:
status_label.text = "Status: Connecting..."
func handle_message(msg):
# log_message("Received: " + str(msg.get("type")))
if msg.get("type") == "location_update":
var data = msg.get("data", {})
var location = data.get("location", {})
if location:
update_location_ui(location)
func update_location_ui(location):
location_name_label.text = location.get("name", "Unknown Location")
location_desc_label.text = location.get("description", "")
var image_url = location.get("image_url", "")
if image_url != "":
fetch_image(image_url)
func fetch_image(url):
if url.begins_with("/"):
url = "https://api-staging.echoesoftheash.com" + url
log_message("Fetching image: " + url)
http_request.cancel_request()
http_request.request(url)
func _on_image_request_completed(result, response_code, headers, body):
if result == HTTPRequest.RESULT_SUCCESS:
var image = Image.new()
var error = image.load_png_from_buffer(body)
if error != OK:
error = image.load_jpg_from_buffer(body)
if error != OK:
error = image.load_webp_from_buffer(body)
if error == OK:
var texture = ImageTexture.create_from_image(image)
location_image.texture = texture
else:
log_message("Failed to load image texture")
else:
log_message("Failed to fetch image. Code: " + str(response_code))
func log_message(text):
print(text)
log_label.text += text + "\n"

View File

@@ -1,69 +0,0 @@
[gd_scene load_steps=2 format=3 uid="uid://c8q7y6x5z4w3"]
[ext_resource type="Script" path="res://Main.gd" id="1_m4i3n"]
[node name="Main" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_m4i3n")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="HeaderLabel" type="Label" parent="VBoxContainer"]
layout_mode = 2
text = "Echoes of the Ashes - Godot PoC"
horizontal_alignment = 1
[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="TokenInput" type="LineEdit" parent="VBoxContainer/HBoxContainer"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Enter Auth Token"
[node name="ConnectButton" type="Button" parent="VBoxContainer/HBoxContainer"]
layout_mode = 2
text = "Connect via WebSocket"
[node name="ConnectionStatusLabel" type="Label" parent="VBoxContainer"]
layout_mode = 2
text = "Status: Disconnected"
[node name="HSeparator" type="HSeparator" parent="VBoxContainer"]
layout_mode = 2
[node name="LocationNameLabel" type="Label" parent="VBoxContainer"]
layout_mode = 2
horizontal_alignment = 1
[node name="LocationImage" type="TextureRect" parent="VBoxContainer"]
custom_minimum_size = Vector2(0, 300)
layout_mode = 2
expand_mode = 1
stretch_mode = 5
[node name="LocationDescriptionLabel" type="RichTextLabel" parent="VBoxContainer"]
layout_mode = 2
fit_content = true
[node name="HSeparator2" type="HSeparator" parent="VBoxContainer"]
layout_mode = 2
[node name="LogLabel" type="RichTextLabel" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
text = "Logs will appear here...
"
[connection signal="pressed" from="VBoxContainer/HBoxContainer/ConnectButton" to="." method="_on_connect_button_pressed"]

View File

@@ -1 +0,0 @@
<svg height="128" width="128" xmlns="http://www.w3.org/2000/svg"><rect fill="#364966" height="128" width="128" rx="20" ry="20"/><path d="M64 16 L16 112 L112 112 Z" fill="#ffffff"/></svg>

Before

Width:  |  Height:  |  Size: 187 B

View File

@@ -1,13 +0,0 @@
config_version=5
[application]
config/name="Echoes of the Ashes PoC"
config/features=PackedStringArray("4.5", "Forward Plus")
run/main_scene="res://Main.tscn"
config/icon="res://icon.svg"
[display]
window/size/viewport_width=1280
window/size/viewport_height=720

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 678 KiB

After

Width:  |  Height:  |  Size: 678 KiB

View File

Before

Width:  |  Height:  |  Size: 881 KiB

After

Width:  |  Height:  |  Size: 881 KiB

View File

Before

Width:  |  Height:  |  Size: 602 KiB

After

Width:  |  Height:  |  Size: 602 KiB

View File

Before

Width:  |  Height:  |  Size: 367 KiB

After

Width:  |  Height:  |  Size: 367 KiB

View File

Before

Width:  |  Height:  |  Size: 528 KiB

After

Width:  |  Height:  |  Size: 528 KiB

View File

Before

Width:  |  Height:  |  Size: 597 KiB

After

Width:  |  Height:  |  Size: 597 KiB

View File

Before

Width:  |  Height:  |  Size: 859 KiB

After

Width:  |  Height:  |  Size: 859 KiB

View File

Before

Width:  |  Height:  |  Size: 661 KiB

After

Width:  |  Height:  |  Size: 661 KiB

View File

Before

Width:  |  Height:  |  Size: 597 KiB

After

Width:  |  Height:  |  Size: 597 KiB

View File

Before

Width:  |  Height:  |  Size: 758 KiB

After

Width:  |  Height:  |  Size: 758 KiB

View File

Before

Width:  |  Height:  |  Size: 552 KiB

After

Width:  |  Height:  |  Size: 552 KiB

View File

Before

Width:  |  Height:  |  Size: 677 KiB

After

Width:  |  Height:  |  Size: 677 KiB

View File

Before

Width:  |  Height:  |  Size: 804 KiB

After

Width:  |  Height:  |  Size: 804 KiB

View File

Before

Width:  |  Height:  |  Size: 507 KiB

After

Width:  |  Height:  |  Size: 507 KiB

View File

Before

Width:  |  Height:  |  Size: 538 KiB

After

Width:  |  Height:  |  Size: 538 KiB

View File

Before

Width:  |  Height:  |  Size: 947 KiB

After

Width:  |  Height:  |  Size: 947 KiB

View File

Before

Width:  |  Height:  |  Size: 698 KiB

After

Width:  |  Height:  |  Size: 698 KiB

View File

Before

Width:  |  Height:  |  Size: 822 KiB

After

Width:  |  Height:  |  Size: 822 KiB

View File

Before

Width:  |  Height:  |  Size: 535 KiB

After

Width:  |  Height:  |  Size: 535 KiB

View File

Before

Width:  |  Height:  |  Size: 961 KiB

After

Width:  |  Height:  |  Size: 961 KiB

View File

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 353 KiB

View File

Before

Width:  |  Height:  |  Size: 905 KiB

After

Width:  |  Height:  |  Size: 905 KiB

View File

Before

Width:  |  Height:  |  Size: 980 KiB

After

Width:  |  Height:  |  Size: 980 KiB

View File

Before

Width:  |  Height:  |  Size: 803 KiB

After

Width:  |  Height:  |  Size: 803 KiB

View File

Before

Width:  |  Height:  |  Size: 864 KiB

After

Width:  |  Height:  |  Size: 864 KiB

View File

Before

Width:  |  Height:  |  Size: 694 KiB

After

Width:  |  Height:  |  Size: 694 KiB

View File

Before

Width:  |  Height:  |  Size: 777 KiB

After

Width:  |  Height:  |  Size: 777 KiB

View File

Before

Width:  |  Height:  |  Size: 696 KiB

After

Width:  |  Height:  |  Size: 696 KiB

View File

Before

Width:  |  Height:  |  Size: 636 KiB

After

Width:  |  Height:  |  Size: 636 KiB

View File

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 571 KiB

View File

Before

Width:  |  Height:  |  Size: 860 KiB

After

Width:  |  Height:  |  Size: 860 KiB

View File

Before

Width:  |  Height:  |  Size: 648 KiB

After

Width:  |  Height:  |  Size: 648 KiB

View File

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 571 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 338 KiB

View File

Before

Width:  |  Height:  |  Size: 898 KiB

After

Width:  |  Height:  |  Size: 898 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

Before

Width:  |  Height:  |  Size: 933 KiB

After

Width:  |  Height:  |  Size: 933 KiB

View File

Before

Width:  |  Height:  |  Size: 460 KiB

After

Width:  |  Height:  |  Size: 460 KiB

View File

Before

Width:  |  Height:  |  Size: 749 KiB

After

Width:  |  Height:  |  Size: 749 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

57
images-source/make_webp.sh Executable file
View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -e
# Script to convert original images to optimized WebP format
# Run this script from the images-source directory
# Source files: ./ (current directory)
# Output files: ../images/
SOURCE_DIR="."
OUTPUT_DIR="../images"
ITEM_SIZE="256x256"
echo "🔄 Starting image conversion..."
echo " Source: $SOURCE_DIR"
echo " Output: $OUTPUT_DIR"
echo ""
for category in items locations npcs interactables; do
src="$SOURCE_DIR/$category"
out="$OUTPUT_DIR/$category"
if [[ ! -d "$src" ]]; then
echo "⚠️ Skipping $category (source not found)"
continue
fi
mkdir -p "$out"
echo "📂 Processing $category..."
find "$src" -maxdepth 1 -type f \( -iname "*.png" -o -iname "*.jpg" -o -iname "*.jpeg" \) | while read -r img; do
filename="${img##*/}"
base="${filename%.*}"
out_file="$out/$base.webp"
if [[ -f "$out_file" ]]; then
echo " ✔ Exists: $base.webp"
continue
fi
if [[ "$category" == "items" ]]; then
# Special processing for items: remove white background and resize
echo " ➜ Converting item: $filename"
tmp="/tmp/${base}_clean.png"
convert "$img" -fuzz 10% -transparent white -resize "$ITEM_SIZE" "$tmp"
cwebp "$tmp" -q 85 -o "$out_file" >/dev/null
rm "$tmp"
else
# Standard conversion for other categories
echo " ➜ Converting: $filename"
cwebp "$img" -q 85 -o "$out_file" >/dev/null
fi
done
done
echo ""
echo "✨ Done! WebP files generated in $OUTPUT_DIR"

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Some files were not shown because too many files have changed in this diff Show More