Initial commit: Echoes of the Ashes - Telegram RPG Bot

This commit is contained in:
Joan
2025-10-18 19:21:19 +02:00
commit 3ab412bc09
65 changed files with 14484 additions and 0 deletions

83
.gitignore vendored Normal file
View File

@@ -0,0 +1,83 @@
# Claude Sonnet Logs - Development documentation
claude_sonnet_logs/
# Development Scripts - Migration and update scripts
dev_scripts/
# Backups - Old files and backup folders
backups/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
*.egg
.pytest_cache/
.coverage
htmlcov/
# Virtual Environment
.venv/
venv/
ENV/
env/
# Environment variables
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Database
*.db
*.sqlite
*.sqlite3
# Backup files
*.backup
*.bak
*~
*.tmp
# Logs
*.log
logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Docker
.dockerignore
# Compiled files
*.pyc
# OS
Thumbs.db
.DS_Store
# Temporary files
tmp/
temp/
# Editor backup files
*.py.backup
*.html.backup
*.js.backup
# Old/deprecated files
*_old.py
*_old.js
*_old.html

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
# Use an official Python runtime as a parent image
FROM python:3.11-slim
# Set the working directory in the container
WORKDIR /app
# Copy the requirements file into the container at /app
COPY requirements.txt .
# Install any needed packages specified in requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application's code into the container at /app
COPY . .
# Command to run the application
CMD ["python", "main.py"]

25
Dockerfile.map Normal file
View File

@@ -0,0 +1,25 @@
FROM python:3.11-slim
WORKDIR /app
# Install Docker CLI for container restart functionality
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
ca-certificates && \
curl -fsSL https://get.docker.com | sh && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Copy all application code (needed for bot module access)
COPY . .
# Install requirements (web-map and main requirements)
RUN pip install --no-cache-dir -r /app/web-map/requirements.txt && \
pip install --no-cache-dir -r /app/requirements.txt || true
WORKDIR /app/web-map
EXPOSE 8080
CMD ["python", "server_enhanced.py"]

321
README.md Normal file
View File

@@ -0,0 +1,321 @@
# Echoes of the Ashes - Telegram RPG Bot
A post-apocalyptic survival RPG Telegram bot built with Python, 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)
## 🎮 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
### Prerequisites
- Docker and Docker Compose
- Telegram Bot Token (from [@BotFather](https://t.me/botfather))
### Installation
1. Clone the repository:
```bash
cd /opt/dockers/telegram-rpg
```
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
```
3. Start the bot:
```bash
docker compose up -d --build
```
4. Check logs:
```bash
docker logs echoes_of_the_ashes_bot -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

0
bot/__init__.py Normal file
View File

495
bot/combat.py Normal file
View File

@@ -0,0 +1,495 @@
"""
Combat system logic for turn-based NPC encounters.
"""
import random
import json
import time
from typing import Dict, List, Tuple, Optional
from bot import database
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 database.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 database.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 database.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 database.get_combat(player_id)
if not combat or combat['turn'] != 'player':
return ("It's not your turn!", False, False)
player = await database.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 database.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 = 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 database.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 database.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 database.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)
})
message += f"\n{npc_def.emoji} {npc_def.name}: {new_npc_hp}/{combat['npc_max_hp']} 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 database.get_combat(player_id)
if not combat or combat['turn'] != 'npc':
return ("", False)
player = await database.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 database.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 database.update_player(player_id, {'hp': new_player_hp})
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 database.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 database.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)
})
message += f"\n❤️ Your HP: {new_player_hp}/{player['max_hp']}"
message += f"\n{npc_def.emoji} {npc_def.name}: {combat['npc_hp']}/{combat['npc_max_hp']} 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 database.get_combat(player_id)
if not combat or combat['turn'] != 'player':
return ("It's not your turn!", False, False)
player = await database.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
await database.spawn_wandering_enemy(
npc_id=combat['npc_id'],
location_id=combat['location_id'],
lifetime_seconds=600 # 10 minutes
)
await database.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 database.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.
Returns: (updated_effects, total_damage, message)
"""
total_damage = 0
messages = []
for effect in effects:
if effect['damage_per_turn'] > 0:
total_damage += effect['damage_per_turn']
if effect['name'] == 'Bleeding':
messages.append(f"🩸 Bleeding: -{effect['damage_per_turn']} HP")
elif effect['name'] == 'Infected':
messages.append(f"🦠 Infection: -{effect['damage_per_turn']} 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 database.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 database.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 database.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 database.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 database.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 database.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 database.get_player(player_id)
inventory_items = await database.get_inventory(player_id)
# Check if combat was with a wandering enemy that should respawn
combat = await database.get_combat(player_id)
if combat and combat.get('from_wandering_enemy', False):
# Respawn the enemy at the same location
await database.spawn_wandering_enemy(
npc_id=combat['npc_id'],
location_id=combat['location_id'],
lifetime_seconds=600 # 10 minutes
)
# 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 database.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 database.remove_item_from_inventory(item['id'], item['quantity'])
# Mark player as dead and end any combat
await database.update_player(player_id, {'is_dead': True, 'hp': 0})
await database.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 database.get_combat(player_id)
if not combat or combat['turn'] != 'player':
return ("It's not your turn!", False)
item_data = await database.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 database.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 database.update_player(player_id, updates)
# Remove item from inventory
if item_data['quantity'] > 1:
await database.update_inventory_item(item_db_id, item_data['quantity'] - 1)
else:
await database.remove_item_from_inventory(item_db_id, 1)
# Using an item ends your turn
await database.update_combat(player_id, {
'turn': 'npc',
'turn_started_at': time.time()
})
return (message, True)

539
bot/database.py Normal file
View File

@@ -0,0 +1,539 @@
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("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
)
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):
async with engine.connect() as conn:
result = await conn.execute(players.select().where(players.c.telegram_id == telegram_id))
row = result.first()
return row._asdict() if row else None
async def create_player(telegram_id: int, name: str):
async with engine.connect() as conn:
await conn.execute(players.insert().values(telegram_id=telegram_id, name=name))
await conn.execute(inventory.insert().values(player_id=telegram_id, item_id="tattered_rucksack", is_equipped=True))
await conn.commit()
return await get_player(telegram_id)
async def update_player(telegram_id: int, updates: dict):
async with engine.connect() as conn:
await conn.execute(players.update().where(players.c.telegram_id == telegram_id).values(**updates))
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.
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
"""
async with engine.connect() as conn:
# Get all living players who are below max stamina
result = await conn.execute(
players.select().where(
(players.c.is_dead == False) &
(players.c.stamina < players.c.max_stamina)
)
)
players_to_update = result.fetchall()
updated_count = 0
for player in players_to_update:
# Calculate stamina recovery
base_recovery = 1
endurance_bonus = player.endurance // 10 # +1 per 10 endurance
total_recovery = base_recovery + endurance_bonus
# Calculate new stamina (capped at max)
new_stamina = min(player.stamina + total_recovery, player.max_stamina)
# Only update if there's actually a change
if new_stamina > player.stamina:
await conn.execute(
players.update()
.where(players.c.telegram_id == player.telegram_id)
.values(stamina=new_stamina)
)
updated_count += 1
await conn.commit()
return updated_count
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()]

1268
bot/handlers.py Normal file

File diff suppressed because it is too large Load Diff

603
bot/keyboards.py Normal file
View File

@@ -0,0 +1,603 @@
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 database, logic
keyboard = []
location = game_world.get_location(current_location_id)
player = await database.get_player(player_id)
inventory = await database.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 import database
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 database.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 database.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 database.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 import database
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 database.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 import database
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 database.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")])
return InlineKeyboardMarkup(keyboard)
async def combat_items_keyboard(player_id: int) -> InlineKeyboardMarkup:
"""Show consumable items during combat."""
from bot import database
keyboard = []
inventory_items = await database.get_inventory(player_id)
consumables = [item for item in inventory_items if ITEMS.get(item['item_id'], {}).get('type') == 'consumable']
if consumables:
keyboard.append([InlineKeyboardButton("--- Select item to use ---", callback_data="no_op")])
for item in consumables:
item_def = ITEMS.get(item['item_id'], {})
emoji = item_def.get('emoji', '')
keyboard.append([InlineKeyboardButton(
f"{emoji} {item_def.get('name', 'Unknown')} x{item['quantity']}",
callback_data=f"combat_use_item:{item['id']}"
)])
keyboard.append([InlineKeyboardButton("⬅️ Back to Combat", callback_data="combat_back")])
return InlineKeyboardMarkup(keyboard)
def corpse_keyboard(corpse_id: int, corpse_type: str) -> InlineKeyboardMarkup:
"""Create keyboard for interacting with corpses."""
keyboard = []
if corpse_type == "player":
keyboard.append([InlineKeyboardButton("🎒 Loot Bag", callback_data=f"loot_player_corpse:{corpse_id}")])
else: # NPC corpse
keyboard.append([InlineKeyboardButton("🔪 Scavenge Corpse", callback_data=f"scavenge_npc_corpse:{corpse_id}")])
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="inspect_area")])
return InlineKeyboardMarkup(keyboard)
def player_corpse_loot_keyboard(corpse_id: int, items: list) -> InlineKeyboardMarkup:
"""Show items in a player corpse bag."""
keyboard = []
if items:
keyboard.append([InlineKeyboardButton("--- Take items ---", callback_data="no_op")])
for i, item_data in enumerate(items):
item_def = ITEMS.get(item_data['item_id'], {})
emoji = item_def.get('emoji', '')
keyboard.append([InlineKeyboardButton(
f"{emoji} Take {item_def.get('name', 'Unknown')} x{item_data['quantity']}",
callback_data=f"take_corpse_item:{corpse_id}:{i}"
)])
else:
keyboard.append([InlineKeyboardButton("--- Bag is empty ---", callback_data="no_op")])
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="inspect_area")])
return InlineKeyboardMarkup(keyboard)
def npc_corpse_scavenge_keyboard(corpse_id: int, loot_items: list) -> InlineKeyboardMarkup:
"""Show scavenging options for NPC corpse."""
keyboard = []
if loot_items:
keyboard.append([InlineKeyboardButton("--- Scavenge for materials ---", callback_data="no_op")])
for i, loot_data in enumerate(loot_items):
item_def = ITEMS.get(loot_data['item_id'], {})
emoji = item_def.get('emoji', '')
label = f"{emoji} {item_def.get('name', 'Unknown')}"
if loot_data.get('required_tool'):
tool_def = ITEMS.get(loot_data['required_tool'], {})
label += f" (needs {tool_def.get('name', 'tool')})"
keyboard.append([InlineKeyboardButton(
label,
callback_data=f"scavenge_corpse_item:{corpse_id}:{i}"
)])
else:
keyboard.append([InlineKeyboardButton("--- Nothing left to scavenge ---", callback_data="no_op")])
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="inspect_area")])
return InlineKeyboardMarkup(keyboard)
def spend_points_keyboard() -> InlineKeyboardMarkup:
"""Create keyboard for spending stat points."""
keyboard = [
[
InlineKeyboardButton("❤️ Max HP (+10)", callback_data="spend_point:max_hp"),
InlineKeyboardButton("⚡ Stamina (+5)", callback_data="spend_point:max_stamina")
],
[
InlineKeyboardButton("💪 Strength (+1)", callback_data="spend_point:strength"),
InlineKeyboardButton("🏃 Agility (+1)", callback_data="spend_point:agility")
],
[
InlineKeyboardButton("💚 Endurance (+1)", callback_data="spend_point:endurance"),
InlineKeyboardButton("🧠 Intellect (+1)", callback_data="spend_point:intellect")
],
[InlineKeyboardButton("⬅️ Back to Profile", callback_data="profile")]
]
return InlineKeyboardMarkup(keyboard)

119
bot/logic.py Normal file
View File

@@ -0,0 +1,119 @@
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 . import database
player = await database.get_player(user_id)
if not player:
return False, "Player not found."
inventory = await database.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

119
bot/spawn_manager.py Normal file
View File

@@ -0,0 +1,119 @@
"""
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
}

60
bot/utils.py Normal file
View File

@@ -0,0 +1,60 @@
"""
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 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

0
data/__init__.py Normal file
View File

71
data/items.py Normal file
View File

@@ -0,0 +1,71 @@
# Definitions for all items in the game.
# Now loaded from JSON file
import json
from pathlib import Path
def load_items():
"""Load items from JSON file"""
json_path = Path(__file__).parent.parent / 'gamedata' / 'items.json'
try:
with open(json_path, 'r') as f:
data = json.load(f)
return data['items']
except FileNotFoundError:
print(f"⚠️ Warning: {json_path} not found, using fallback items")
return _get_fallback_items()
except Exception as e:
print(f"⚠️ Warning: Error loading items from JSON: {e}")
return _get_fallback_items()
def _get_fallback_items():
"""Fallback items if JSON loading fails"""
return {
# Resources
"scrap_metal": {"name": "Scrap Metal", "weight": 0.5, "volume": 0.2, "type": "resource", "emoji": "⚙️"},
"rusty_nails": {"name": "Rusty Nails", "weight": 0.2, "volume": 0.1, "type": "resource", "emoji": "📌"},
"wood_planks": {"name": "Wood Planks", "weight": 3.0, "volume": 2.0, "type": "resource", "emoji": "🪵"},
"cloth_scraps": {"name": "Cloth Scraps", "weight": 0.1, "volume": 0.2, "type": "resource", "emoji": "🧵"},
"cloth": {"name": "Cloth", "weight": 0.1, "volume": 0.2, "type": "resource", "emoji": "🧵"},
"plastic_bottles": {"name": "Plastic Bottles", "weight": 0.05, "volume": 0.3, "type": "resource", "emoji": "🍶"},
"bone": {"name": "Bone", "weight": 0.3, "volume": 0.1, "type": "resource", "emoji": "🦴"},
"raw_meat": {"name": "Raw Meat", "weight": 0.5, "volume": 0.2, "type": "resource", "emoji": "🥩"},
"animal_hide": {"name": "Animal Hide", "weight": 0.4, "volume": 0.3, "type": "resource", "emoji": "🧤"},
"mutant_tissue": {"name": "Mutant Tissue", "weight": 0.2, "volume": 0.1, "type": "resource", "emoji": "🧬"},
"infected_tissue": {"name": "Infected Tissue", "weight": 0.2, "volume": 0.1, "type": "resource", "emoji": "☣️"},
# Consumables - Food
"stale_chocolate_bar": {"name": "Stale Chocolate Bar", "weight": 0.1, "volume": 0.1, "type": "consumable", "hp_restore": 10, "emoji": "🍫"},
"canned_beans": {"name": "Canned Beans", "weight": 0.4, "volume": 0.2, "type": "consumable", "hp_restore": 20, "stamina_restore": 5, "emoji": "🥫"},
"canned_food": {"name": "Canned Food", "weight": 0.4, "volume": 0.2, "type": "consumable", "hp_restore": 25, "stamina_restore": 5, "emoji": "🥫"},
"bottled_water": {"name": "Bottled Water", "weight": 0.5, "volume": 0.3, "type": "consumable", "stamina_restore": 10, "emoji": "💧"},
"water_bottle": {"name": "Water Bottle", "weight": 0.5, "volume": 0.3, "type": "consumable", "stamina_restore": 10, "emoji": "💧"},
"energy_bar": {"name": "Energy Bar", "weight": 0.1, "volume": 0.1, "type": "consumable", "stamina_restore": 15, "emoji": "🍫"},
"mystery_pills": {"name": "Mystery Pills", "weight": 0.05, "volume": 0.05, "type": "consumable", "hp_restore": 30, "emoji": "💊"},
# Consumables - Medical
"first_aid_kit": {"name": "First Aid Kit", "description": "A professional medical kit with bandages, antiseptic, and pain relievers.", "weight": 0.8, "volume": 0.5, "type": "consumable", "hp_restore": 50, "emoji": "🩹"},
"bandage": {"name": "Bandage", "description": "Clean cloth bandages for treating minor wounds.", "weight": 0.1, "volume": 0.1, "type": "consumable", "hp_restore": 15, "emoji": "🩹"},
"medical_supplies": {"name": "Medical Supplies", "description": "Assorted medical supplies scavenged from a clinic.", "weight": 0.6, "volume": 0.4, "type": "consumable", "hp_restore": 40, "emoji": "⚕️"},
"antibiotics": {"name": "Antibiotics", "description": "Pills that fight infections. Expired, but better than nothing.", "weight": 0.1, "volume": 0.1, "type": "consumable", "hp_restore": 20, "emoji": "💊"},
# Weapons & Tools
"tire_iron": {"name": "Tire Iron", "description": "A heavy metal tool. Makes a decent improvised weapon.", "weight": 2.0, "volume": 1.0, "type": "weapon", "slot": "hand", "damage_min": 3, "damage_max": 7, "emoji": "🔧"},
"baseball_bat": {"name": "Baseball Bat", "description": "Wooden bat with dents and bloodstains. Someone used this before you.", "weight": 1.0, "volume": 1.5, "type": "weapon", "slot": "hand", "damage_min": 2, "damage_max": 6, "emoji": ""},
"rusty_knife": {"name": "Rusty Knife", "description": "A dull, rusted blade. Better than your fists.", "weight": 0.3, "volume": 0.2, "type": "weapon", "slot": "hand", "damage_min": 2, "damage_max": 5, "emoji": "🔪"},
"knife": {"name": "Knife", "description": "A sharp survival knife in decent condition.", "weight": 0.3, "volume": 0.2, "type": "weapon", "slot": "hand", "damage_min": 3, "damage_max": 6, "emoji": "🔪"},
"rusty_pipe": {"name": "Rusty Pipe", "description": "Heavy metal pipe. Crude but effective.", "weight": 1.5, "volume": 0.8, "type": "weapon", "slot": "hand", "damage_min": 4, "damage_max": 8, "emoji": "🔩"},
# Equipment
"tattered_rucksack": {"name": "Tattered Rucksack", "description": "An old backpack with torn straps. Still functional.", "weight": 1.0, "volume": 0, "type": "equipment", "slot": "back", "capacity_weight": 10, "capacity_volume": 10, "emoji": "🎒"},
"hiking_backpack": {"name": "Hiking Backpack", "description": "A quality backpack with multiple compartments.", "weight": 1.5, "volume": 0, "type": "equipment", "slot": "back", "capacity_weight": 20, "capacity_volume": 20, "emoji": "🎒"},
"flashlight": {"name": "Flashlight", "description": "A battery-powered flashlight. Batteries low but working.", "weight": 0.3, "volume": 0.2, "type": "equipment", "slot": "tool", "emoji": "🔦"},
# Quest Items
"old_photograph": {"name": "Old Photograph", "weight": 0.01, "volume": 0.01, "type": "quest", "emoji": "📷"},
"key_ring": {"name": "Key Ring", "weight": 0.1, "volume": 0.05, "type": "quest", "emoji": "🔑"},
}
# Load items from JSON on module import
ITEMS = load_items()

57
data/models.py Normal file
View File

@@ -0,0 +1,57 @@
from dataclasses import dataclass, field
from typing import Dict, Optional
@dataclass
class Outcome:
text: str
items_reward: Dict[str, int] = field(default_factory=dict)
damage_taken: int = 0
@dataclass
class Action:
id: str
label: str
stamina_cost: int
outcomes: Dict[str, Outcome] = field(default_factory=dict)
def add_outcome(self, name: str, outcome: Outcome): self.outcomes[name] = outcome
@dataclass
class Interactable:
id: str
name: str
actions: Dict[str, Action] = field(default_factory=dict)
image_path: Optional[str] = None
def add_action(self, action: Action): self.actions[action.id] = action
def get_action(self, action_id: str) -> Optional[Action]: return self.actions.get(action_id)
@dataclass
class Location:
id: str
name: str
description: str
exits: Dict[str, str] = field(default_factory=dict)
interactables: Dict[str, Interactable] = field(default_factory=dict) # Key is now the INSTANCE_ID
image_path: Optional[str] = None
x: float = 0.0 # X coordinate for map positioning
y: float = 0.0 # Y coordinate for map positioning
def add_exit(self, direction: str, destination_id: str):
self.exits[direction] = destination_id
def add_interactable(self, instance_id: str, interactable_template: Interactable):
"""Adds an instance of an interactable template to the location."""
self.interactables[instance_id] = interactable_template
def get_interactable(self, instance_id: str) -> Optional[Interactable]:
return self.interactables.get(instance_id)
class World:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(World, cls).__new__(cls)
cls._instance.locations = {}
return cls._instance
def add_location(self, location: Location): self.locations[location.id] = location
def get_location(self, location_id: str) -> Optional[Location]: return self.locations.get(location_id)

236
data/npcs.py Normal file
View File

@@ -0,0 +1,236 @@
"""
NPC definitions for combat encounters - NOW LOADED FROM JSON
Each NPC has stats, loot tables, and combat behavior.
"""
from dataclasses import dataclass
from typing import Dict, List, Optional
import json
from pathlib import Path
@dataclass
class LootItem:
"""Item that can be dropped by NPCs"""
item_id: str
quantity_min: int
quantity_max: int
drop_chance: float # 0.0 to 1.0
@dataclass
class CorpseLoot:
"""Item that can be scavenged from a corpse"""
item_id: str
quantity_min: int
quantity_max: int
required_tool: Optional[str] = None # item_id of required tool
@dataclass
class StatusEffect:
"""Status effect data"""
name: str
duration_turns: int
damage_per_turn: int = 0 # For bleeding
stun: bool = False # Prevents action
@dataclass
class NPCDefinition:
"""Complete NPC definition"""
npc_id: str
name: str
description: str
emoji: str
# Combat stats
hp_min: int
hp_max: int
damage_min: int
damage_max: int
defense: int # Reduces incoming damage
# Rewards
xp_reward: int
loot_table: List[LootItem]
corpse_loot: List[CorpseLoot]
# Behavior
flee_chance: float # NPC's chance to flee if low HP
status_inflict_chance: float # Chance to inflict status on player
# Visuals
image_url: Optional[str] = None
death_message: str = "The enemy falls defeated."
def load_npcs_from_json():
"""Load NPCs, danger levels, and spawn tables from JSON"""
json_path = Path(__file__).parent.parent / 'gamedata' / 'npcs.json'
try:
with open(json_path, 'r') as f:
data = json.load(f)
# Convert JSON to NPCDefinition objects
npcs = {}
for npc_id, npc_data in data['npcs'].items():
# Convert loot tables
loot_table = [
LootItem(**loot) for loot in npc_data.get('loot_table', [])
]
corpse_loot = [
CorpseLoot(**loot) for loot in npc_data.get('corpse_loot', [])
]
# Create NPC definition
npcs[npc_id] = NPCDefinition(
npc_id=npc_data['npc_id'],
name=npc_data['name'],
description=npc_data['description'],
emoji=npc_data['emoji'],
hp_min=npc_data['hp_min'],
hp_max=npc_data['hp_max'],
damage_min=npc_data['damage_min'],
damage_max=npc_data['damage_max'],
defense=npc_data['defense'],
xp_reward=npc_data['xp_reward'],
loot_table=loot_table,
corpse_loot=corpse_loot,
flee_chance=npc_data['flee_chance'],
status_inflict_chance=npc_data['status_inflict_chance'],
image_url=npc_data.get('image_url'),
death_message=npc_data.get('death_message', "The enemy falls defeated.")
)
# Load danger levels - convert to tuple format (danger_level, encounter_rate, wandering_chance)
danger_levels = {}
for loc_id, danger_data in data['danger_levels'].items():
danger_levels[loc_id] = (
danger_data['danger_level'],
danger_data['encounter_rate'],
danger_data['wandering_chance']
)
# Load spawn tables - convert to list of tuples format
spawn_tables = {}
for loc_id, spawns in data['spawn_tables'].items():
spawn_tables[loc_id] = [
(spawn['npc_id'], spawn['weight']) for spawn in spawns
]
print(f"✅ Loaded {len(npcs)} NPCs, {len(danger_levels)} danger configs, {len(spawn_tables)} spawn tables from JSON")
return npcs, danger_levels, spawn_tables
except FileNotFoundError:
print(f"⚠️ Warning: {json_path} not found, using fallback NPCs")
return _get_fallback_npcs()
except Exception as e:
print(f"⚠️ Warning: Error loading NPCs from JSON: {e}")
import traceback
traceback.print_exc()
return _get_fallback_npcs()
def _get_fallback_npcs():
"""Fallback NPCs if JSON loading fails"""
npcs = {
"feral_dog": NPCDefinition(
npc_id="feral_dog",
name="Feral Dog",
description="A wild, mangy dog with desperate hunger in its eyes.",
emoji="🐕",
hp_min=15,
hp_max=25,
damage_min=3,
damage_max=7,
defense=0,
xp_reward=10,
flee_chance=0.3,
status_inflict_chance=0.15,
loot_table=[
LootItem("raw_meat", 1, 2, 0.6),
LootItem("bone", 1, 1, 0.4),
LootItem("animal_hide", 1, 1, 0.3)
],
corpse_loot=[
CorpseLoot("raw_meat", 1, 2),
CorpseLoot("bone", 1, 1),
CorpseLoot("animal_hide", 1, 1, required_tool="knife")
],
image_url=None,
death_message="The feral dog whimpers and collapses."
)
}
danger_levels = {
"start_point": (0, 0.0, 0.0),
}
spawn_tables = {
"start_point": [],
}
return npcs, danger_levels, spawn_tables
# Load on module import
NPCS, LOCATION_DANGER, LOCATION_SPAWNS = load_npcs_from_json()
# Status effects that can be applied in combat
STATUS_EFFECTS = {
"bleeding": StatusEffect(
name="Bleeding",
duration_turns=3,
damage_per_turn=2,
stun=False
),
"stunned": StatusEffect(
name="Stunned",
duration_turns=1,
damage_per_turn=0,
stun=True
),
"infected": StatusEffect(
name="Infected",
duration_turns=5,
damage_per_turn=1,
stun=False
),
}
# Helper functions
def get_danger_level(location_id: str) -> int:
"""Get danger level for a location (0-4)"""
return LOCATION_DANGER.get(location_id, (0, 0.0, 0.0))[0]
def get_location_encounter_rate(location_id: str) -> float:
"""Get base encounter rate for a location"""
return LOCATION_DANGER.get(location_id, (0, 0.0, 0.0))[1]
def get_wandering_enemy_chance(location_id: str) -> float:
"""Get chance for wandering enemy to spawn"""
return LOCATION_DANGER.get(location_id, (0, 0.0, 0.0))[2]
def get_random_npc_for_location(location_id: str) -> str:
"""
Get a random NPC ID for the given location based on spawn weights.
Returns None if no NPCs can spawn at this location.
"""
import random
spawn_table = LOCATION_SPAWNS.get(location_id, [])
if not spawn_table:
return None
# Extract NPCs and weights
npcs = [npc_id for npc_id, weight in spawn_table]
weights = [weight for npc_id, weight in spawn_table]
# Use random.choices with weights
return random.choices(npcs, weights=weights, k=1)[0] if npcs else None

31
data/travel_helpers.py Normal file
View File

@@ -0,0 +1,31 @@
"""
Shared travel/stamina calculation helpers used by both game logic and map visualization
"""
import math
def calculate_base_stamina_cost(from_location, to_location) -> int:
"""
Calculate base stamina cost for traveling between two locations.
Based purely on Euclidean distance.
This is the base cost used by:
- Map visualization (to show connection costs)
- Game logic (as starting point before player modifiers)
Args:
from_location: Location object with x, y coordinates
to_location: Location object with x, y coordinates
Returns:
int: Base stamina cost (minimum 1)
"""
# Calculate Euclidean distance
dx = to_location.x - from_location.x
dy = to_location.y - from_location.y
distance = math.sqrt(dx**2 + dy**2)
# Base cost: 3 stamina per distance unit (rounded)
# Minimum cost is 1 stamina
base_cost = max(1, int(distance * 3 + 0.5))
return base_cost

313
data/world_loader.py Normal file
View File

@@ -0,0 +1,313 @@
"""
World loader - NOW LOADS FROM JSON
Creates and connects all the game world objects from gamedata/locations.json
Uses template-based interactables from gamedata/interactables.json
"""
from .models import World, Location, Interactable, Action, Outcome
import json
from pathlib import Path
def load_interactable_templates():
"""Load interactable templates from interactables.json."""
templates = {}
json_path = Path(__file__).parent.parent / 'gamedata' / 'interactables.json'
try:
with open(json_path, 'r') as f:
data = json.load(f)
for template_id, template_data in data.get('interactables', {}).items():
templates[template_id] = template_data
print(f"📦 Loaded {len(templates)} interactable templates")
except FileNotFoundError:
print("⚠️ interactables.json not found, using inline data only")
except Exception as e:
print(f"⚠️ Error loading interactables.json: {e}")
return templates
def create_interactable_from_template(template_id, template_data, instance_data):
"""Create an Interactable object from template and instance data."""
interactable = Interactable(
id=template_id,
name=template_data.get('name', 'Unknown'),
image_path=template_data.get('image_path', '')
)
# Get actions from template
template_actions = template_data.get('actions', {})
# Get outcomes from instance (template-based format)
instance_outcomes = instance_data.get('outcomes', {})
# Build actions by merging template actions with instance outcomes
for action_id, action_template in template_actions.items():
action = Action(
id=action_template['id'],
label=action_template['label'],
stamina_cost=action_template.get('stamina_cost', 2)
)
# Get the instance-specific outcome data for this action
if action_id in instance_outcomes:
outcome_data = instance_outcomes[action_id]
# Build outcomes from the instance data
text_dict = outcome_data.get('text', {})
rewards = outcome_data.get('rewards', {})
# Add success outcome
if text_dict.get('success'):
items_reward = {}
if 'items' in rewards:
for item in rewards['items']:
items_reward[item['item_id']] = item.get('quantity', 1)
outcome = Outcome(
text=text_dict['success'],
items_reward=items_reward,
damage_taken=rewards.get('damage', 0)
)
action.add_outcome('success', outcome)
# Add failure outcome
if text_dict.get('failure'):
outcome = Outcome(
text=text_dict['failure'],
items_reward={},
damage_taken=0
)
action.add_outcome('failure', outcome)
# Add critical failure outcome
if text_dict.get('crit_failure'):
outcome = Outcome(
text=text_dict['crit_failure'],
items_reward={},
damage_taken=rewards.get('crit_damage', 0)
)
action.add_outcome('critical_failure', outcome)
interactable.add_action(action)
return interactable
def load_world() -> World:
"""Creates and connects all the game world objects from JSON."""
world = World()
# Load interactable templates first
templates = load_interactable_templates()
json_path = Path(__file__).parent.parent / 'gamedata' / 'locations.json'
try:
with open(json_path, 'r') as f:
data = json.load(f)
print(f"📍 Loading {len(data['locations'])} locations from JSON...")
# First pass: Create all locations
for loc_data in data['locations']:
location = Location(
id=loc_data['id'],
name=loc_data['name'],
description=loc_data['description'],
image_path=loc_data['image_path'],
x=loc_data.get('x', 0.0),
y=loc_data.get('y', 0.0)
)
# Add interactables using template-based format
for instance_id, instance_data in loc_data.get('interactables', {}).items():
template_id = instance_data.get('template_id')
if not template_id:
print(f"⚠️ Skipping interactable {instance_id} - no template_id")
continue
# Get template data
template_data = templates.get(template_id)
if not template_data:
print(f"⚠️ Template '{template_id}' not found for {instance_id}")
continue
# Create interactable from template + instance data
interactable = create_interactable_from_template(
template_id,
template_data,
instance_data
)
location.add_interactable(instance_id, interactable)
world.add_location(location)
# Second pass: Add connections
print(f"🔗 Adding {len(data['connections'])} connections...")
for conn in data['connections']:
from_loc = world.get_location(conn['from'])
if from_loc:
from_loc.add_exit(conn['direction'], conn['to'])
print(f"✅ World loaded successfully!")
return world
except FileNotFoundError:
print(f"⚠️ Warning: {json_path} not found, loading fallback world")
return _load_fallback_world()
except Exception as e:
print(f"⚠️ Error loading world from JSON: {e}")
import traceback
traceback.print_exc()
return _load_fallback_world()
def _load_fallback_world() -> World:
"""Fallback world with minimal locations if JSON loading fails"""
world = World()
# Create a simple fallback location
start = Location(
id="start_point",
name="🌆 Ruined Downtown Core",
description="The wind howls through skeletal skyscrapers. Debris litters the cracked asphalt.",
image_path="images/locations/downtown.png",
x=0.0,
y=0.0
)
# Add a simple interactable
rubble = Interactable(
id="rubble",
name="Pile of Rubble",
image_path="images/interactables/rubble.png"
)
search_action = Action(id="search", label="🔎 Search Rubble", stamina_cost=2)
search_action.add_outcome("success", Outcome(
text="You find some scrap metal.",
items_reward={"scrap_metal": 2}
))
search_action.add_outcome("failure", Outcome(
text="Nothing useful here."
))
rubble.add_action(search_action)
start.add_interactable("start_rubble", rubble)
world.add_location(start)
return world
def export_map_data() -> dict:
"""
Export map data for external visualization.
Returns a dictionary with locations, connections, interactables, and enemy spawns.
Can be saved as JSON for use in web-based or other visualizers.
"""
from data.npcs import LOCATION_SPAWNS, NPCS, LOCATION_DANGER, get_danger_level, get_location_encounter_rate, get_wandering_enemy_chance
map_data = {
"locations": [],
"connections": [],
"interactables": [],
"spawn_tables": {}
}
for location in game_world.locations.values():
# Get danger information
danger_level = get_danger_level(location.id)
encounter_rate = get_location_encounter_rate(location.id)
wandering_chance = get_wandering_enemy_chance(location.id)
# Add location data with image and danger info
map_data["locations"].append({
"id": location.id,
"name": location.name,
"description": location.description,
"x": location.x,
"y": location.y,
"image_path": location.image_path,
"interactable_count": len(location.interactables),
"danger_level": danger_level,
"encounter_rate": round(encounter_rate * 100, 1), # As percentage
"wandering_chance": round(wandering_chance * 100, 1) # As percentage
})
# Add interactable data with images and loot chances
for instance_id, interactable in location.interactables.items():
interactable_data = {
"instance_id": instance_id,
"location_id": location.id,
"id": interactable.id,
"name": interactable.name,
"image_path": interactable.image_path,
"actions": []
}
# Process each action and its outcomes
for action in interactable.actions.values():
action_data = {
"id": action.id,
"label": action.label,
"stamina_cost": action.stamina_cost,
"outcomes": []
}
# Add outcome information (for loot chances)
for outcome_name, outcome in action.outcomes.items():
outcome_data = {
"type": outcome_name,
"text": outcome.text,
"items": outcome.items_reward,
"damage": outcome.damage_taken
}
action_data["outcomes"].append(outcome_data)
interactable_data["actions"].append(action_data)
map_data["interactables"].append(interactable_data)
# Add connections
for direction, destination in location.exits.items():
# Calculate stamina cost based on distance between locations
dest_loc = game_world.get_location(destination)
if dest_loc:
from .travel_helpers import calculate_base_stamina_cost
stamina_cost = calculate_base_stamina_cost(location, dest_loc)
else:
stamina_cost = 5 # Fallback
map_data["connections"].append({
"from": location.id,
"to": destination,
"direction": direction,
"stamina_cost": stamina_cost
})
# Add spawn table for this location
if location.id in LOCATION_SPAWNS:
spawn_data = []
for npc_id, weight in LOCATION_SPAWNS[location.id]:
if npc_id in NPCS:
npc = NPCS[npc_id]
spawn_data.append({
"npc_id": npc_id,
"name": npc.name,
"emoji": npc.emoji,
"weight": weight,
"level_range": f"{npc.hp_min}-{npc.hp_max} HP"
})
map_data["spawn_tables"][location.id] = spawn_data
return map_data
# Create singleton world instance on module import
game_world = load_world()

70
docker-compose.yml Normal file
View File

@@ -0,0 +1,70 @@
services:
echoes_of_the_ashes_db:
image: postgres:15
container_name: echoes_of_the_ashes_db
restart: unless-stopped
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
volumes:
- echoes-postgres-data:/var/lib/postgresql/data
networks:
- default_docker
#ports:
# Optional: expose port to host for debugging with a DB client
# - "5432:5432"
echoes_of_the_ashes_bot:
build: .
container_name: echoes_of_the_ashes_bot
restart: unless-stopped
env_file:
- .env
volumes:
- ./gamedata:/app/gamedata:rw
- ./images:/app/images:ro
depends_on:
- echoes_of_the_ashes_db
networks:
- default_docker
echoes_of_the_ashes_map:
build:
context: .
dockerfile: Dockerfile.map
container_name: echoes_of_the_ashes_map
restart: unless-stopped
env_file:
- .env
volumes:
- ./gamedata:/app/gamedata:rw
- ./images:/app/images:rw
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- 8080:8080
networks:
- default_docker
- traefik
labels:
- traefik.enable=true
- traefik.http.routers.echoesoftheash-http.entrypoints=web
- traefik.http.routers.echoesoftheash-http.rule=Host(`echoesoftheash.patacuack.net`)
- traefik.http.routers.echoesoftheash-http.middlewares=https-redirect@file
- traefik.http.routers.echoesoftheash.entrypoints=websecure
- traefik.http.routers.echoesoftheash.rule=Host(`echoesoftheash.patacuack.net`)
- traefik.http.routers.echoesoftheash.tls=true
- traefik.http.routers.echoesoftheash.tls.certResolver=production
- traefik.http.services.echoesoftheash.loadbalancer.server.port=8080
volumes:
echoes-postgres-data:
name: echoes-of-the-ashes-postgres-data
networks:
default_docker:
external: true
name: default_docker
traefik:
external: true
name: traefik

144
gamedata/interactables.json Normal file
View File

@@ -0,0 +1,144 @@
{
"interactables": {
"rubble": {
"id": "rubble",
"name": "Pile of Rubble",
"description": "A scattered pile of debris and broken concrete.",
"image_path": "images/interactables/rubble.png",
"actions": {
"search": {
"id": "search",
"label": "\ud83d\udd0e Search Rubble",
"stamina_cost": 2
}
}
},
"dumpster": {
"id": "dumpster",
"name": "\ud83d\uddd1\ufe0f Dumpster",
"description": "A rusted metal dumpster, possibly containing scavenged goods.",
"image_path": "images/interactables/dumpster.png",
"actions": {
"search_dumpster": {
"id": "search_dumpster",
"label": "\ud83d\udd0e Dig Through Trash",
"stamina_cost": 2
}
}
},
"sedan": {
"id": "sedan",
"name": "\ud83d\ude97 Rusty Sedan",
"description": "An abandoned sedan with rusted doors.",
"image_path": "images/interactables/sedan.png",
"actions": {
"search_glovebox": {
"id": "search_glovebox",
"label": "\ud83d\udd0e Search Glovebox",
"stamina_cost": 1
},
"pop_trunk": {
"id": "pop_trunk",
"label": "\ud83d\udd27 Pop the Trunk",
"stamina_cost": 3
}
}
},
"house": {
"id": "house",
"name": "\ud83c\udfda\ufe0f Abandoned House",
"description": "A dilapidated house with boarded windows.",
"image_path": "images/interactables/house.png",
"actions": {
"search_house": {
"id": "search_house",
"label": "\ud83d\udd0e Search House",
"stamina_cost": 3
}
}
},
"toolshed": {
"id": "toolshed",
"name": "\ud83d\udd28 Tool Shed",
"description": "A small wooden shed, door slightly ajar.",
"image_path": "images/interactables/toolshed.png",
"actions": {
"search_shed": {
"id": "search_shed",
"label": "\ud83d\udd0e Search Shed",
"stamina_cost": 2
}
}
},
"medkit": {
"id": "medkit",
"name": "\ud83c\udfe5 Medical Supply Cabinet",
"description": "A white metal cabinet with a red cross symbol.",
"image_path": "images/interactables/medkit.png",
"actions": {
"search_medkit": {
"id": "search_medkit",
"label": "\ud83d\udd0e Search Cabinet",
"stamina_cost": 2
}
}
},
"vending": {
"id": "vending",
"name": "\ud83e\uddc3 Vending Machine",
"description": "A broken vending machine, glass shattered.",
"image_path": "images/interactables/vending.png",
"actions": {
"break_vending": {
"id": "break_vending",
"label": "\ud83d\udd28 Break Open",
"stamina_cost": 4
}
}
},
"medical_cabinet": {
"id": "medical_cabinet",
"name": "Medical Cabinet",
"description": "A white metal cabinet with a red cross symbol.",
"image_path": "images/interactables/medkit.png",
"actions": {
"search": {
"id": "search",
"label": "\ud83d\udd0e Search Cabinet",
"stamina_cost": 1
}
}
},
"storage_box": {
"id": "storage_box",
"name": "Storage Box",
"description": "A weathered storage container.",
"image_path": "images/interactables/storage_box.png",
"actions": {
"search": {
"id": "search",
"label": "\ud83d\udd0e Search Box",
"stamina_cost": 2
}
}
},
"vending_machine": {
"id": "vending_machine",
"name": "Vending Machine",
"description": "A broken vending machine, glass shattered.",
"image_path": "images/interactables/vending.png",
"actions": {
"break": {
"id": "break",
"label": "\ud83d\udd28 Break Open",
"stamina_cost": 5
},
"search": {
"id": "search",
"label": "\ud83d\udd0e Search Machine",
"stamina_cost": 2
}
}
}
}
}

275
gamedata/items.json Normal file
View File

@@ -0,0 +1,275 @@
{
"items": {
"scrap_metal": {
"name": "Scrap Metal",
"type": "resource",
"weight": 0.5,
"volume": 0.2,
"emoji": "\u2699\ufe0f"
},
"rusty_nails": {
"name": "Rusty Nails",
"weight": 0.2,
"volume": 0.1,
"type": "resource",
"emoji": "\ud83d\udccc"
},
"wood_planks": {
"name": "Wood Planks",
"weight": 3.0,
"volume": 2.0,
"type": "resource",
"emoji": "\ud83e\udeb5"
},
"cloth_scraps": {
"name": "Cloth Scraps",
"weight": 0.1,
"volume": 0.2,
"type": "resource",
"emoji": "\ud83e\uddf5"
},
"cloth": {
"name": "Cloth",
"weight": 0.1,
"volume": 0.2,
"type": "resource",
"emoji": "\ud83e\uddf5"
},
"plastic_bottles": {
"name": "Plastic Bottles",
"weight": 0.05,
"volume": 0.3,
"type": "resource",
"emoji": "\ud83c\udf76"
},
"bone": {
"name": "Bone",
"weight": 0.3,
"volume": 0.1,
"type": "resource",
"emoji": "\ud83e\uddb4"
},
"raw_meat": {
"name": "Raw Meat",
"weight": 0.5,
"volume": 0.2,
"type": "resource",
"emoji": "\ud83e\udd69"
},
"animal_hide": {
"name": "Animal Hide",
"weight": 0.4,
"volume": 0.3,
"type": "resource",
"emoji": "\ud83e\udde4"
},
"mutant_tissue": {
"name": "Mutant Tissue",
"weight": 0.2,
"volume": 0.1,
"type": "resource",
"emoji": "\ud83e\uddec"
},
"infected_tissue": {
"name": "Infected Tissue",
"weight": 0.2,
"volume": 0.1,
"type": "resource",
"emoji": "\u2623\ufe0f"
},
"stale_chocolate_bar": {
"name": "Stale Chocolate Bar",
"weight": 0.1,
"volume": 0.1,
"type": "consumable",
"hp_restore": 10,
"emoji": "\ud83c\udf6b"
},
"canned_beans": {
"name": "Canned Beans",
"weight": 0.4,
"volume": 0.2,
"type": "consumable",
"hp_restore": 20,
"stamina_restore": 5,
"emoji": "\ud83e\udd6b"
},
"canned_food": {
"name": "Canned Food",
"weight": 0.4,
"volume": 0.2,
"type": "consumable",
"hp_restore": 25,
"stamina_restore": 5,
"emoji": "\ud83e\udd6b"
},
"bottled_water": {
"name": "Bottled Water",
"weight": 0.5,
"volume": 0.3,
"type": "consumable",
"stamina_restore": 10,
"emoji": "\ud83d\udca7"
},
"water_bottle": {
"name": "Water Bottle",
"weight": 0.5,
"volume": 0.3,
"type": "consumable",
"stamina_restore": 10,
"emoji": "\ud83d\udca7"
},
"energy_bar": {
"name": "Energy Bar",
"weight": 0.1,
"volume": 0.1,
"type": "consumable",
"stamina_restore": 15,
"emoji": "\ud83c\udf6b"
},
"mystery_pills": {
"name": "Mystery Pills",
"weight": 0.05,
"volume": 0.05,
"type": "consumable",
"hp_restore": 30,
"emoji": "\ud83d\udc8a"
},
"first_aid_kit": {
"name": "First Aid Kit",
"description": "A professional medical kit with bandages, antiseptic, and pain relievers.",
"weight": 0.8,
"volume": 0.5,
"type": "consumable",
"hp_restore": 50,
"emoji": "\ud83e\ude79"
},
"bandage": {
"name": "Bandage",
"description": "Clean cloth bandages for treating minor wounds.",
"weight": 0.1,
"volume": 0.1,
"type": "consumable",
"hp_restore": 15,
"emoji": "\ud83e\ude79"
},
"medical_supplies": {
"name": "Medical Supplies",
"description": "Assorted medical supplies scavenged from a clinic.",
"weight": 0.6,
"volume": 0.4,
"type": "consumable",
"hp_restore": 40,
"emoji": "\u2695\ufe0f"
},
"antibiotics": {
"name": "Antibiotics",
"description": "Pills that fight infections. Expired, but better than nothing.",
"weight": 0.1,
"volume": 0.1,
"type": "consumable",
"hp_restore": 20,
"emoji": "\ud83d\udc8a"
},
"tire_iron": {
"name": "Tire Iron",
"description": "A heavy metal tool. Makes a decent improvised weapon.",
"weight": 2.0,
"volume": 1.0,
"type": "weapon",
"slot": "hand",
"damage_min": 3,
"damage_max": 7,
"emoji": "\ud83d\udd27"
},
"baseball_bat": {
"name": "Baseball Bat",
"description": "Wooden bat with dents and bloodstains. Someone used this before you.",
"weight": 1.0,
"volume": 1.5,
"type": "weapon",
"slot": "hand",
"damage_min": 2,
"damage_max": 6,
"emoji": "\u26be"
},
"rusty_knife": {
"name": "Rusty Knife",
"description": "A dull, rusted blade. Better than your fists.",
"weight": 0.3,
"volume": 0.2,
"type": "weapon",
"slot": "hand",
"damage_min": 2,
"damage_max": 5,
"emoji": "\ud83d\udd2a"
},
"knife": {
"name": "Knife",
"description": "A sharp survival knife in decent condition.",
"weight": 0.3,
"volume": 0.2,
"type": "weapon",
"slot": "hand",
"damage_min": 3,
"damage_max": 6,
"emoji": "\ud83d\udd2a"
},
"rusty_pipe": {
"name": "Rusty Pipe",
"description": "Heavy metal pipe. Crude but effective.",
"weight": 1.5,
"volume": 0.8,
"type": "weapon",
"slot": "hand",
"damage_min": 4,
"damage_max": 8,
"emoji": "\ud83d\udd29"
},
"tattered_rucksack": {
"name": "Tattered Rucksack",
"description": "An old backpack with torn straps. Still functional.",
"weight": 1.0,
"volume": 0,
"type": "equipment",
"slot": "back",
"capacity_weight": 10,
"capacity_volume": 10,
"emoji": "\ud83c\udf92"
},
"hiking_backpack": {
"name": "Hiking Backpack",
"description": "A quality backpack with multiple compartments.",
"weight": 1.5,
"volume": 0,
"type": "equipment",
"slot": "back",
"capacity_weight": 20,
"capacity_volume": 20,
"emoji": "\ud83c\udf92"
},
"flashlight": {
"name": "Flashlight",
"description": "A battery-powered flashlight. Batteries low but working.",
"weight": 0.3,
"volume": 0.2,
"type": "equipment",
"slot": "tool",
"emoji": "\ud83d\udd26"
},
"old_photograph": {
"name": "Old Photograph",
"weight": 0.01,
"volume": 0.01,
"type": "quest",
"emoji": "\ud83d\udcf7"
},
"key_ring": {
"name": "Key Ring",
"weight": 0.1,
"volume": 0.05,
"type": "quest",
"emoji": "\ud83d\udd11"
}
}
}

1438
gamedata/locations.json Normal file

File diff suppressed because it is too large Load Diff

465
gamedata/npcs.json Normal file
View File

@@ -0,0 +1,465 @@
{
"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.",
"emoji": "🐕",
"hp_min": 15,
"hp_max": 25,
"damage_min": 3,
"damage_max": 7,
"defense": 0,
"xp_reward": 10,
"loot_table": [
{
"item_id": "raw_meat",
"quantity_min": 1,
"quantity_max": 2,
"drop_chance": 0.6
},
{
"item_id": "animal_hide",
"quantity_min": 1,
"quantity_max": 1,
"drop_chance": 0.3
}
],
"corpse_loot": [
{
"item_id": "raw_meat",
"quantity_min": 1,
"quantity_max": 3,
"required_tool": null
},
{
"item_id": "animal_hide",
"quantity_min": 1,
"quantity_max": 1,
"required_tool": "knife"
}
],
"flee_chance": 0.3,
"status_inflict_chance": 0.15,
"image_url": "images/npcs/feral_dog.png",
"death_message": "The feral dog whimpers and collapses. Perhaps it was just hungry..."
},
"raider_scout": {
"npc_id": "raider_scout",
"name": "Raider Scout",
"description": "A lone raider wearing makeshift armor. They eye you with hostile intent.",
"emoji": "🏴‍☠️",
"hp_min": 30,
"hp_max": 45,
"damage_min": 5,
"damage_max": 12,
"defense": 2,
"xp_reward": 25,
"loot_table": [
{
"item_id": "water_bottle",
"quantity_min": 1,
"quantity_max": 1,
"drop_chance": 0.4
},
{
"item_id": "canned_food",
"quantity_min": 1,
"quantity_max": 2,
"drop_chance": 0.5
},
{
"item_id": "bandage",
"quantity_min": 1,
"quantity_max": 3,
"drop_chance": 0.6
},
{
"item_id": "rusty_pipe",
"quantity_min": 1,
"quantity_max": 1,
"drop_chance": 0.3
}
],
"corpse_loot": [
{
"item_id": "scrap_metal",
"quantity_min": 2,
"quantity_max": 4,
"required_tool": null
},
{
"item_id": "cloth",
"quantity_min": 1,
"quantity_max": 2,
"required_tool": "knife"
}
],
"flee_chance": 0.2,
"status_inflict_chance": 0.1,
"image_url": "images/npcs/raider_scout.png",
"death_message": "The raider scout falls with a final gasp. Their supplies are yours."
},
"mutant_rat": {
"npc_id": "mutant_rat",
"name": "Mutant Rat",
"description": "A grotesquely large rat, its fur patchy and eyes glowing with unnatural light.",
"emoji": "🐀",
"hp_min": 10,
"hp_max": 18,
"damage_min": 2,
"damage_max": 5,
"defense": 0,
"xp_reward": 8,
"loot_table": [
{
"item_id": "raw_meat",
"quantity_min": 1,
"quantity_max": 1,
"drop_chance": 0.4
}
],
"corpse_loot": [
{
"item_id": "raw_meat",
"quantity_min": 1,
"quantity_max": 2,
"required_tool": null
},
{
"item_id": "mutant_tissue",
"quantity_min": 1,
"quantity_max": 1,
"required_tool": "knife"
}
],
"flee_chance": 0.5,
"status_inflict_chance": 0.25,
"image_url": "images/npcs/mutant_rat.png",
"death_message": "The mutant rat squeals its last and goes still."
},
"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.",
"emoji": "🧟",
"hp_min": 35,
"hp_max": 50,
"damage_min": 6,
"damage_max": 10,
"defense": 1,
"xp_reward": 30,
"loot_table": [
{
"item_id": "medical_supplies",
"quantity_min": 1,
"quantity_max": 1,
"drop_chance": 0.2
},
{
"item_id": "antibiotics",
"quantity_min": 1,
"quantity_max": 1,
"drop_chance": 0.15
}
],
"corpse_loot": [
{
"item_id": "infected_tissue",
"quantity_min": 1,
"quantity_max": 2,
"required_tool": "knife"
},
{
"item_id": "bone",
"quantity_min": 1,
"quantity_max": 3,
"required_tool": "knife"
}
],
"flee_chance": 0.1,
"status_inflict_chance": 0.3,
"image_url": "images/npcs/infected_human.png",
"death_message": "The infected human finally finds peace in death."
},
"scavenger": {
"npc_id": "scavenger",
"name": "Hostile Scavenger",
"description": "Another survivor, but this one sees you as competition. They won't share territory.",
"emoji": "💀",
"hp_min": 25,
"hp_max": 40,
"damage_min": 4,
"damage_max": 9,
"defense": 3,
"xp_reward": 20,
"loot_table": [
{
"item_id": "water_bottle",
"quantity_min": 1,
"quantity_max": 2,
"drop_chance": 0.5
},
{
"item_id": "canned_food",
"quantity_min": 1,
"quantity_max": 2,
"drop_chance": 0.5
},
{
"item_id": "bandage",
"quantity_min": 1,
"quantity_max": 2,
"drop_chance": 0.4
},
{
"item_id": "knife",
"quantity_min": 1,
"quantity_max": 1,
"drop_chance": 0.2
},
{
"item_id": "flashlight",
"quantity_min": 1,
"quantity_max": 1,
"drop_chance": 0.3
}
],
"corpse_loot": [
{
"item_id": "cloth",
"quantity_min": 1,
"quantity_max": 3,
"required_tool": null
},
{
"item_id": "scrap_metal",
"quantity_min": 1,
"quantity_max": 2,
"required_tool": null
}
],
"flee_chance": 0.25,
"status_inflict_chance": 0.05,
"image_url": "images/npcs/scavenger.png",
"death_message": "The scavenger's struggle ends. Survival has no mercy."
}
},
"danger_levels": {
"start_point": {
"danger_level": 0,
"encounter_rate": 0.0,
"wandering_chance": 0.0
},
"gas_station": {
"danger_level": 0,
"encounter_rate": 0.0,
"wandering_chance": 0.0
},
"residential": {
"danger_level": 1,
"encounter_rate": 0.10,
"wandering_chance": 0.20
},
"park": {
"danger_level": 1,
"encounter_rate": 0.10,
"wandering_chance": 0.20
},
"clinic": {
"danger_level": 2,
"encounter_rate": 0.20,
"wandering_chance": 0.35
},
"plaza": {
"danger_level": 2,
"encounter_rate": 0.15,
"wandering_chance": 0.30
},
"warehouse": {
"danger_level": 2,
"encounter_rate": 0.18,
"wandering_chance": 0.32
},
"warehouse_interior": {
"danger_level": 2,
"encounter_rate": 0.22,
"wandering_chance": 0.40
},
"overpass": {
"danger_level": 3,
"encounter_rate": 0.30,
"wandering_chance": 0.45
},
"office_building": {
"danger_level": 3,
"encounter_rate": 0.25,
"wandering_chance": 0.40
},
"office_interior": {
"danger_level": 3,
"encounter_rate": 0.35,
"wandering_chance": 0.50
},
"subway": {
"danger_level": 4,
"encounter_rate": 0.35,
"wandering_chance": 0.50
},
"subway_tunnels": {
"danger_level": 4,
"encounter_rate": 0.45,
"wandering_chance": 0.65
}
},
"spawn_tables": {
"start_point": [],
"gas_station": [],
"residential": [
{
"npc_id": "feral_dog",
"weight": 60
},
{
"npc_id": "mutant_rat",
"weight": 40
}
],
"park": [
{
"npc_id": "feral_dog",
"weight": 50
},
{
"npc_id": "mutant_rat",
"weight": 50
}
],
"clinic": [
{
"npc_id": "infected_human",
"weight": 40
},
{
"npc_id": "mutant_rat",
"weight": 30
},
{
"npc_id": "scavenger",
"weight": 30
}
],
"plaza": [
{
"npc_id": "raider_scout",
"weight": 40
},
{
"npc_id": "scavenger",
"weight": 35
},
{
"npc_id": "feral_dog",
"weight": 25
}
],
"warehouse": [
{
"npc_id": "raider_scout",
"weight": 45
},
{
"npc_id": "scavenger",
"weight": 35
},
{
"npc_id": "mutant_rat",
"weight": 20
}
],
"warehouse_interior": [
{
"npc_id": "raider_scout",
"weight": 50
},
{
"npc_id": "scavenger",
"weight": 30
},
{
"npc_id": "mutant_rat",
"weight": 20
}
],
"overpass": [
{
"npc_id": "raider_scout",
"weight": 50
},
{
"npc_id": "infected_human",
"weight": 30
},
{
"npc_id": "scavenger",
"weight": 20
}
],
"office_building": [
{
"npc_id": "raider_scout",
"weight": 45
},
{
"npc_id": "infected_human",
"weight": 35
},
{
"npc_id": "scavenger",
"weight": 20
}
],
"office_interior": [
{
"npc_id": "infected_human",
"weight": 50
},
{
"npc_id": "raider_scout",
"weight": 30
},
{
"npc_id": "scavenger",
"weight": 20
}
],
"subway": [
{
"npc_id": "infected_human",
"weight": 50
},
{
"npc_id": "raider_scout",
"weight": 30
},
{
"npc_id": "mutant_rat",
"weight": 20
}
],
"subway_tunnels": [
{
"npc_id": "infected_human",
"weight": 60
},
{
"npc_id": "raider_scout",
"weight": 25
},
{
"npc_id": "mutant_rat",
"weight": 15
}
]
}
}

26
images/README.md Normal file
View File

@@ -0,0 +1,26 @@
# Game Images
Place your location and interactable images in this directory.
## Structure
- `locations/` - Images for different locations
- `interactables/` - Images for interactive objects
## Supported Formats
- PNG, JPG, JPEG
- Recommended size: 800x600 or similar aspect ratio
- Max file size: 10MB (Telegram limit)
## Example Files
To use images, add them to the appropriate folder and reference them in `data/world_loader.py`:
```python
location = Location(
id="start_point",
name="Ruined Downtown Core",
description="...",
image_path="images/locations/downtown.jpg"
)
```
The bot will automatically upload the image once and cache the Telegram file_id for future use.

View File

@@ -0,0 +1,2 @@
# This is a placeholder file.
# Replace with actual interactable images.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,2 @@
# This is a placeholder file.
# Replace with actual location images.

BIN
images/locations/clinic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
images/locations/park.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
images/locations/plaza.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
images/locations/subway.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

0
images/npcs/.gitkeep Normal file
View File

BIN
images/npcs/feral_dog.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
images/npcs/mutant_rat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
images/npcs/scavenger.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

154
main.py Normal file
View File

@@ -0,0 +1,154 @@
import asyncio
import logging
import signal
import os
import time
from dotenv import load_dotenv
from telegram import Update
from telegram.ext import Application, CommandHandler, CallbackQueryHandler
from bot import database, handlers
# Enable logging
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
)
# Quieten down the HTTPX logger, which is very verbose
logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
# A global event to signal shutdown
shutdown_event = asyncio.Event()
def signal_handler(sig, frame):
"""Gracefully handle shutdown signals."""
logger.info("Shutdown signal received. Shutting down gracefully...")
shutdown_event.set()
async def decay_dropped_items():
"""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:
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)
if items_removed > 0:
logger.info(f"Decayed and removed {items_removed} old items.")
async def regenerate_stamina():
"""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:
logger.info("Running stamina regeneration...")
players_updated = await database.regenerate_all_players_stamina()
if players_updated > 0:
logger.info(f"Regenerated stamina for {players_updated} players.")
async def check_combat_timers():
"""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:
# 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)
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':
logger.info(f"Player {combat['player_id']} idle in combat - auto-ending turn")
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}")
async def decay_corpses():
"""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:
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)
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.")
async def main() -> None:
"""Start the bot and wait for a shutdown signal."""
load_dotenv()
TOKEN = os.getenv("TELEGRAM_TOKEN")
if not TOKEN or TOKEN == "YOUR_TELEGRAM_BOT_TOKEN_HERE":
logger.error("TELEGRAM_TOKEN is not set! Please edit your .env file.")
return
await database.create_tables()
application = Application.builder().token(TOKEN).build()
application.add_handler(CommandHandler("start", handlers.start))
application.add_handler(CommandHandler("map", handlers.export_map))
application.add_handler(CommandHandler("spawns", handlers.spawn_stats))
application.add_handler(CallbackQueryHandler(handlers.button_handler))
async with application:
await application.start()
await application.updater.start_polling(allowed_updates=Update.ALL_TYPES)
logger.info("Bot is running and polling for updates...")
# Start the spawn manager
from bot import spawn_manager
await spawn_manager.start_spawn_manager()
# Start the background tasks
decay_task = asyncio.create_task(decay_dropped_items())
stamina_task = asyncio.create_task(regenerate_stamina())
combat_timer_task = asyncio.create_task(check_combat_timers())
corpse_decay_task = asyncio.create_task(decay_corpses())
await shutdown_event.wait()
await application.updater.stop()
await application.stop()
# Ensure the background tasks are also cancelled on shutdown
decay_task.cancel()
stamina_task.cancel()
combat_timer_task.cancel()
corpse_decay_task.cancel()
logger.info("Bot has been shut down.")
if __name__ == "__main__":
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
asyncio.run(main())
except (KeyboardInterrupt, SystemExit):
logger.info("Main function interrupted.")

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
python-telegram-bot[ext]==21.0.1
sqlalchemy[asyncio]==2.0.25
aiosqlite==0.19.0
python-dotenv==1.0.1
psycopg[binary,async]==3.1.18

202
web-map/README.md Normal file
View File

@@ -0,0 +1,202 @@
# 🗺️ Echoes of the Ashes - Interactive Map Visualizer
A web-based interactive map viewer for the RPG game world.
## Features
- **Interactive Canvas Map**: Drag, zoom, and explore the game world
- **Real-time Location Data**: Dynamically loaded from game data
- **Distance Calculations**: Shows travel distances and stamina costs
- **Location Details**: Click on locations to see full information
- **Connection Routes**: Visual representation of all travel paths
- **Statistics Dashboard**: View map statistics and metrics
- **Responsive Design**: Works on desktop and mobile devices
## Quick Start
### Option 1: Docker (Recommended)
The map server is included in the docker-compose setup:
```bash
docker compose up -d echoes_of_the_ashes_map
```
Access the map at: **http://localhost:8080**
### Option 2: Standalone Python Server
```bash
cd web-map
python server.py
```
Optional: Specify a custom port
```bash
python server.py --port 3000
```
## Features Overview
### Map Controls
- **🖱️ Pan**: Click and drag to move around the map
- **🔍 Zoom**: Use mouse wheel or zoom buttons to adjust view
- **🎯 Reset**: Return to default view
- **🏷️ Toggle Labels**: Show/hide location names
### Location Information
Click on any location node to see:
- Location name and description
- Coordinates (X, Y)
- Number of interactables
- Connected locations with distances
- Estimated stamina costs for travel
### Map Legend
- **Green Circles**: Locations
- **Blue Lines**: Travel routes
- **Orange Circle**: Selected location
- **Pink Badge**: Number of interactables at location
## Map Statistics
The dashboard shows:
- **Total Locations**: Number of places in the game world
- **Total Routes**: Number of connections between locations
- **Longest Route**: Maximum distance between connected locations
- **Average Distance**: Mean distance across all routes
## Technical Details
### Data Source
The map dynamically loads data from `/map_data.json`, which is generated from the game's world loader. The data includes:
```json
{
"locations": [
{
"id": "start_point",
"name": "🌆 Ruined Downtown Core",
"description": "...",
"x": 0.0,
"y": 0.0,
"interactable_count": 3
}
],
"connections": [
{
"from": "start_point",
"to": "gas_station",
"direction": "north",
"distance": 2.0
}
]
}
```
### Server Architecture
- **Backend**: Python HTTP server with dynamic data generation
- **Frontend**: Vanilla JavaScript with HTML5 Canvas
- **Responsive**: CSS Grid and Flexbox layout
- **Real-time**: Live data from game world loader
### Port Configuration
Default port: **8080**
To change the port, modify:
1. `docker-compose.yml`: Update the ports mapping
2. Or use `--port` flag when running standalone
## Customization
### Styling
Edit `index.html` to customize:
- Colors and gradients
- Card layouts
- Typography
- Responsive breakpoints
### Map Appearance
Edit `map.js` to customize:
- Node sizes and colors
- Line widths
- Scale factors
- Animation effects
## Production Deployment
### With Reverse Proxy (Nginx/Caddy)
```nginx
location /map {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
```
### Public Access
To make it accessible globally:
1. **Port Forward**: Open port 8080 on your firewall
2. **Domain**: Point a subdomain to your server
3. **SSL**: Use Let's Encrypt for HTTPS
Example with Caddy:
```
map.yourdomain.com {
reverse_proxy localhost:8080
}
```
## Troubleshooting
### Map Not Loading
1. Check if server is running:
```bash
docker ps | grep map
```
2. Check logs:
```bash
docker logs echoes_of_the_ashes_map
```
3. Verify port is accessible:
```bash
curl http://localhost:8080
```
### Data Not Updating
The map data is generated dynamically. If you've changed location coordinates:
1. Restart the map server
2. Hard refresh browser (Ctrl+F5)
### Connection Issues
If running in Docker, ensure the container is on the correct network and ports are properly mapped.
## Development
To modify the map visualization:
1. Edit `map.js` for canvas rendering logic
2. Edit `index.html` for layout and UI
3. Edit `server.py` for data serving logic
The server auto-loads changes - just refresh your browser!
## License
Part of the Echoes of the Ashes RPG project.

1160
web-map/editor.html Normal file

File diff suppressed because it is too large Load Diff

470
web-map/editor.js Normal file
View File

@@ -0,0 +1,470 @@
// Map Editor JavaScript
let currentLocations = [];
let availableNPCs = [];
let selectedLocationId = null;
let canvas, ctx;
let scale = 50;
let offsetX = 0;
let offsetY = 0;
// Check authentication on load
window.addEventListener('DOMContentLoaded', async () => {
const response = await fetch('/api/check-auth');
const data = await response.json();
if (data.authenticated) {
showEditor();
} else {
document.getElementById('loginContainer').style.display = 'flex';
}
});
// Login form handler
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({password})
});
const data = await response.json();
if (data.success) {
showEditor();
} else {
showError(data.message);
}
} catch (error) {
showError('Login failed: ' + error.message);
}
});
function showError(message) {
const errorDiv = document.getElementById('loginError');
errorDiv.textContent = message;
errorDiv.style.display = 'block';
}
function showSuccess(message) {
const successDiv = document.getElementById('saveSuccess');
successDiv.textContent = message;
successDiv.style.display = 'block';
setTimeout(() => {
successDiv.style.display = 'none';
}, 3000);
}
async function showEditor() {
document.getElementById('loginContainer').style.display = 'none';
document.getElementById('editorContainer').style.display = 'grid';
// Initialize canvas
canvas = document.getElementById('editorCanvas');
ctx = canvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// Load data
await loadLocations();
await loadAvailableNPCs();
// Draw map
drawMap();
// Canvas click handler
canvas.addEventListener('click', handleCanvasClick);
}
function resizeCanvas() {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
drawMap();
}
async function loadLocations() {
try {
const response = await fetch('/api/editor/locations');
const data = await response.json();
currentLocations = data.locations;
renderLocationList();
} catch (error) {
console.error('Failed to load locations:', error);
}
}
async function loadAvailableNPCs() {
try {
const response = await fetch('/api/editor/available-npcs');
const data = await response.json();
availableNPCs = data.npcs;
} catch (error) {
console.error('Failed to load NPCs:', error);
}
}
function renderLocationList() {
const list = document.getElementById('locationList');
list.innerHTML = '';
currentLocations.forEach(location => {
const item = document.createElement('div');
item.className = 'location-item';
if (location.id === selectedLocationId) {
item.classList.add('active');
}
item.innerHTML = `
<div class="location-item-name">${location.name}</div>
<div class="location-item-coords">📍 (${location.x}, ${location.y}) | Danger: ${location.danger_level}</div>
`;
item.onclick = () => selectLocation(location.id);
list.appendChild(item);
});
}
async function selectLocation(locationId) {
selectedLocationId = locationId;
renderLocationList();
// Load full location details
try {
const response = await fetch(`/api/editor/location/${locationId}`);
const location = await response.json();
populateForm(location);
} catch (error) {
console.error('Failed to load location details:', error);
}
drawMap();
}
function populateForm(location) {
document.getElementById('noSelectionMessage').classList.add('hidden');
document.getElementById('propertiesForm').classList.remove('hidden');
document.getElementById('locationId').value = location.id;
document.getElementById('locationName').value = location.name;
document.getElementById('locationDescription').value = location.description;
document.getElementById('locationX').value = location.x;
document.getElementById('locationY').value = location.y;
document.getElementById('dangerLevel').value = location.danger_level;
document.getElementById('encounterRate').value = location.encounter_rate;
document.getElementById('wanderingChance').value = location.wandering_chance;
document.getElementById('imagePath').value = location.image_path || '';
// Update image preview
updateImagePreview(location.image_path);
// Render spawn list
renderSpawnList(location.spawn_npcs);
}
function updateImagePreview(imagePath) {
const preview = document.getElementById('imagePreview');
if (imagePath) {
preview.innerHTML = `<img src="/${imagePath}" alt="Location image">`;
} else {
preview.innerHTML = '<span>No image</span>';
}
}
function renderSpawnList(spawns) {
const list = document.getElementById('spawnList');
list.innerHTML = '';
spawns.forEach((spawn, index) => {
const item = document.createElement('div');
item.className = 'spawn-item';
item.innerHTML = `
<div class="spawn-item-info">
<div class="spawn-item-name">${spawn.emoji} ${spawn.name}</div>
<div class="spawn-item-weight">Weight: ${spawn.weight}</div>
</div>
<button class="btn btn-remove" onclick="removeSpawn(${index})">Remove</button>
`;
list.appendChild(item);
});
}
function drawMap() {
ctx.fillStyle = '#0f0f1e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw grid
ctx.strokeStyle = '#1a1a3e';
ctx.lineWidth = 1;
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
// Draw vertical lines
for (let x = centerX % scale; x < canvas.width; x += scale) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
// Draw horizontal lines
for (let y = centerY % scale; y < canvas.height; y += scale) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
// Draw axes
ctx.strokeStyle = '#3a3a6a';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(centerX, 0);
ctx.lineTo(centerX, canvas.height);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, centerY);
ctx.lineTo(canvas.width, centerY);
ctx.stroke();
// Draw locations
currentLocations.forEach(location => {
const screenX = centerX + location.x * scale;
const screenY = centerY - location.y * scale;
// Danger level colors
const dangerColors = ['#4caf50', '#8bc34a', '#ffa726', '#ff5722', '#d32f2f'];
const color = dangerColors[location.danger_level] || '#9e9e9e';
// Draw location circle
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(screenX, screenY, 15, 0, Math.PI * 2);
ctx.fill();
// Highlight selected
if (location.id === selectedLocationId) {
ctx.strokeStyle = '#ffa726';
ctx.lineWidth = 3;
ctx.stroke();
}
// Draw label
ctx.fillStyle = '#e0e0e0';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(location.name, screenX, screenY + 30);
});
}
function handleCanvasClick(event) {
const rect = canvas.getBoundingClientRect();
const clickX = event.clientX - rect.left;
const clickY = event.clientY - rect.top;
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
// Check if clicked on existing location
for (const location of currentLocations) {
const screenX = centerX + location.x * scale;
const screenY = centerY - location.y * scale;
const distance = Math.sqrt(Math.pow(clickX - screenX, 2) + Math.pow(clickY - screenY, 2));
if (distance < 15) {
selectLocation(location.id);
return;
}
}
// Clicked on empty space - create new location
const worldX = ((clickX - centerX) / scale).toFixed(1);
const worldY = (-(clickY - centerY) / scale).toFixed(1);
if (confirm(`Create new location at (${worldX}, ${worldY})?`)) {
createLocationAt(parseFloat(worldX), parseFloat(worldY));
}
}
function createLocationAt(x, y) {
const newId = 'location_' + Date.now();
const newLocation = {
id: newId,
name: 'New Location',
description: 'Enter description...',
image_path: '',
x: x,
y: y,
danger_level: 0,
encounter_rate: 0.0,
wandering_chance: 0.0,
spawn_npcs: []
};
currentLocations.push(newLocation);
selectedLocationId = newId;
renderLocationList();
populateForm(newLocation);
drawMap();
}
function createNewLocation() {
createLocationAt(0, 0);
}
async function saveLocation() {
const locationData = {
id: document.getElementById('locationId').value,
name: document.getElementById('locationName').value,
description: document.getElementById('locationDescription').value,
x: parseFloat(document.getElementById('locationX').value),
y: parseFloat(document.getElementById('locationY').value),
danger_level: parseInt(document.getElementById('dangerLevel').value),
encounter_rate: parseFloat(document.getElementById('encounterRate').value),
wandering_chance: parseFloat(document.getElementById('wanderingChance').value),
image_path: document.getElementById('imagePath').value,
spawn_npcs: getCurrentSpawns()
};
try {
const response = await fetch('/api/editor/location', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(locationData)
});
const data = await response.json();
if (data.success) {
showSuccess('Location saved successfully!');
await loadLocations();
drawMap();
} else {
alert('Failed to save: ' + data.error);
}
} catch (error) {
alert('Failed to save location: ' + error.message);
}
}
function getCurrentSpawns() {
const spawns = [];
const spawnItems = document.querySelectorAll('.spawn-item');
spawnItems.forEach(item => {
const nameDiv = item.querySelector('.spawn-item-name');
const weightDiv = item.querySelector('.spawn-item-weight');
if (nameDiv && weightDiv) {
const text = nameDiv.textContent;
const npcName = text.substring(text.indexOf(' ') + 1).trim();
const weight = parseInt(weightDiv.textContent.replace('Weight: ', ''));
// Find NPC ID by name
const npc = availableNPCs.find(n => n.name === npcName);
if (npc) {
spawns.push({npc_id: npc.id, weight: weight});
}
}
});
return spawns;
}
function showAddSpawnModal() {
const modal = document.getElementById('addSpawnModal');
const list = document.getElementById('npcSelectList');
list.innerHTML = '';
availableNPCs.forEach(npc => {
const item = document.createElement('div');
item.className = 'npc-select-item';
item.innerHTML = `
<div><strong>${npc.emoji} ${npc.name}</strong></div>
<div style="font-size: 0.85em; opacity: 0.7;">
HP: ${npc.hp_range[0]}-${npc.hp_range[1]} |
DMG: ${npc.damage_range[0]}-${npc.damage_range[1]} |
XP: ${npc.xp_reward}
</div>
`;
item.onclick = () => addSpawn(npc);
list.appendChild(item);
});
modal.style.display = 'flex';
}
function closeAddSpawnModal() {
document.getElementById('addSpawnModal').style.display = 'none';
}
function addSpawn(npc) {
const weight = prompt(`Enter spawn weight for ${npc.name}:`, '50');
if (weight && !isNaN(weight)) {
const list = document.getElementById('spawnList');
const item = document.createElement('div');
item.className = 'spawn-item';
item.innerHTML = `
<div class="spawn-item-info">
<div class="spawn-item-name">${npc.emoji} ${npc.name}</div>
<div class="spawn-item-weight">Weight: ${weight}</div>
</div>
<button class="btn btn-remove" onclick="this.parentElement.remove()">Remove</button>
`;
list.appendChild(item);
}
closeAddSpawnModal();
}
function removeSpawn(index) {
const spawnItems = document.querySelectorAll('.spawn-item');
if (spawnItems[index]) {
spawnItems[index].remove();
}
}
async function uploadImage() {
const fileInput = document.getElementById('imageUpload');
const file = fileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
try {
const response = await fetch('/api/editor/upload-image', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
document.getElementById('imagePath').value = data.image_path;
updateImagePreview(data.image_path);
showSuccess(data.message);
} else {
alert('Upload failed: ' + data.error);
}
} catch (error) {
alert('Upload failed: ' + error.message);
}
}
async function logout() {
try {
await fetch('/api/logout', {method: 'POST'});
window.location.reload();
} catch (error) {
console.error('Logout failed:', error);
}
}

3025
web-map/editor_enhanced.js Normal file

File diff suppressed because it is too large Load Diff

869
web-map/index.html Normal file
View File

@@ -0,0 +1,869 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Echoes of the Ashes - Interactive World Map</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #e0e0e0;
overflow: hidden;
}
.container {
display: grid;
grid-template-columns: 1fr 450px;
height: 100vh;
gap: 0;
}
.map-section {
display: flex;
flex-direction: column;
background: #0f0f1e;
border-right: 2px solid #2a2a4a;
}
.header {
padding: 20px 30px;
background: linear-gradient(135deg, #2a2a4a 0%, #1a1a3e 100%);
border-bottom: 2px solid #3a3a6a;
}
h1 {
font-size: 2em;
margin-bottom: 5px;
color: #ffa726;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
}
.subtitle {
font-size: 0.9em;
opacity: 0.7;
}
.bot-link {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 8px 16px;
background: linear-gradient(135deg, #0088cc 0%, #006699 100%);
color: #ffffff;
text-decoration: none;
border-radius: 20px;
font-size: 0.9em;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 136, 204, 0.3);
}
.bot-link:hover {
background: linear-gradient(135deg, #00a0e6 0%, #0088cc 100%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 136, 204, 0.5);
}
.bot-link:active {
transform: translateY(0);
}
.bot-link-icon {
font-size: 1.2em;
}
.canvas-wrapper {
flex: 1;
position: relative;
overflow: hidden;
}
#mapCanvas {
width: 100%;
height: 100%;
cursor: grab;
}
#mapCanvas:active {
cursor: grabbing;
}
.controls {
position: absolute;
top: 20px;
right: 20px;
background: rgba(42, 42, 74, 0.9);
padding: 15px;
border-radius: 10px;
border: 1px solid #3a3a6a;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
}
.controls button {
display: block;
width: 40px;
height: 40px;
margin: 5px 0;
background: #3a3a6a;
border: none;
border-radius: 5px;
color: #ffa726;
font-size: 1.2em;
cursor: pointer;
transition: all 0.2s;
}
.controls button:hover {
background: #ffa726;
color: #1a1a3e;
transform: scale(1.1);
}
.info-panel {
display: flex;
flex-direction: column;
background: #16162e;
overflow-y: auto;
}
.info-section {
padding: 20px;
border-bottom: 1px solid #2a2a4a;
}
.info-section h2 {
color: #ffa726;
margin-bottom: 15px;
font-size: 1.3em;
display: flex;
align-items: center;
gap: 10px;
}
.info-section h3 {
color: #80cbc4;
margin: 15px 0 10px 0;
font-size: 1.1em;
}
.location-image {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 10px;
margin-bottom: 15px;
border: 2px solid #3a3a6a;
}
.image-placeholder {
width: 100%;
aspect-ratio: 4 / 3;
background: linear-gradient(135deg, #2a2a4a 0%, #1a1a3e 100%);
border-radius: 10px;
margin-bottom: 15px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #3a3a6a;
color: #5a5a7a;
font-size: 3em;
}
.interactable-image,
.enemy-image {
width: 60px;
height: 45px;
object-fit: cover;
border-radius: 5px;
border: 2px solid #3a3a6a;
}
.interactable-image-placeholder,
.enemy-image-placeholder {
width: 60px;
height: 45px;
background: linear-gradient(135deg, #2a2a4a 0%, #1a1a3e 100%);
border-radius: 5px;
border: 2px solid #3a3a6a;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5em;
color: #5a5a7a;
}
.description {
padding: 10px;
background: rgba(42, 42, 74, 0.3);
border-radius: 5px;
margin-bottom: 15px;
font-size: 0.95em;
line-height: 1.5;
}
.connections {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 10px;
}
.connection-item {
background: rgba(42, 42, 74, 0.5);
padding: 8px 12px;
border-radius: 5px;
font-size: 0.9em;
border-left: 3px solid #80cbc4;
}
.interactable-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.interactable-card {
background: rgba(42, 42, 74, 0.3);
padding: 15px;
border-radius: 8px;
border: 1px solid #3a3a6a;
}
.interactable-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.interactable-icon {
flex-shrink: 0;
}
.interactable-name {
flex: 1;
font-weight: bold;
color: #ffa726;
}
.action-item {
background: rgba(58, 58, 106, 0.3);
padding: 10px;
border-radius: 5px;
margin: 8px 0;
font-size: 0.85em;
}
.action-header {
font-weight: bold;
color: #80cbc4;
margin-bottom: 5px;
}
.outcome-item {
padding: 5px 10px;
margin: 3px 0;
border-left: 2px solid #5a5a7a;
padding-left: 10px;
}
.outcome-success {
border-left-color: #4caf50;
}
.outcome-failure {
border-left-color: #ff9800;
}
.outcome-critical {
border-left-color: #f44336;
}
.enemy-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.enemy-card {
background: rgba(74, 42, 42, 0.3);
padding: 12px;
border-radius: 8px;
border: 1px solid #6a3a3a;
}
.enemy-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.enemy-icon {
flex-shrink: 0;
}
.enemy-emoji {
font-size: 1.2em;
}
.enemy-name {
flex: 1;
font-weight: bold;
color: #ff5252;
}
.enemy-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-top: 8px;
font-size: 0.85em;
}
.stat-item {
background: rgba(58, 58, 106, 0.3);
padding: 5px 10px;
border-radius: 3px;
}
.legend {
padding: 15px;
background: rgba(42, 42, 74, 0.3);
border-radius: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 0;
font-size: 0.9em;
}
.legend-color {
width: 30px;
height: 20px;
border-radius: 3px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
margin-top: 15px;
}
.stat-card {
background: rgba(42, 42, 74, 0.5);
padding: 15px;
border-radius: 8px;
text-align: center;
border: 1px solid #3a3a6a;
}
.stat-value {
font-size: 2em;
font-weight: bold;
color: #ffa726;
display: block;
margin-bottom: 5px;
}
.stat-label {
font-size: 0.85em;
opacity: 0.8;
}
.no-data {
padding: 20px;
text-align: center;
opacity: 0.5;
font-style: italic;
}
/* Image modal/lightbox styles */
.image-modal {
display: none;
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.95);
cursor: pointer;
}
.image-modal.active {
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.image-modal-content {
max-width: 90%;
max-height: 90vh;
object-fit: contain;
border: 3px solid #ffa726;
border-radius: 10px;
box-shadow: 0 0 30px rgba(255, 167, 38, 0.5);
animation: zoomIn 0.2s ease;
}
@keyframes zoomIn {
from { transform: scale(0.8); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.image-modal-close {
position: absolute;
top: 20px;
right: 30px;
font-size: 40px;
color: #fff;
font-weight: bold;
cursor: pointer;
z-index: 10000;
text-shadow: 0 0 10px rgba(0, 0, 0, 0.8);
transition: all 0.2s;
}
.image-modal-close:hover {
color: #ffa726;
transform: scale(1.2);
}
.image-modal-info {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 10px 20px;
border-radius: 20px;
font-size: 0.9em;
text-align: center;
}
/* Make images clickable */
.location-image,
.interactable-image,
.enemy-image {
cursor: pointer;
transition: all 0.2s;
}
.location-image:hover,
.interactable-image:hover,
.enemy-image:hover {
transform: scale(1.05);
box-shadow: 0 0 15px rgba(255, 167, 38, 0.6);
border-color: #ffa726;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #1a1a2e;
}
::-webkit-scrollbar-thumb {
background: #3a3a6a;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #4a4a7a;
}
/* Mobile Responsive Styles */
@media (max-width: 1024px) {
.container {
grid-template-columns: 1fr 350px;
}
h1 {
font-size: 1.5em;
}
.header {
padding: 15px 20px;
}
}
@media (max-width: 768px) {
.container {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
height: 100vh;
}
.map-section {
border-right: none;
min-height: 50vh;
}
.info-panel {
max-height: 50vh;
border-top: 2px solid #2a2a4a;
}
h1 {
font-size: 1.3em;
}
.subtitle {
font-size: 0.8em;
}
.header {
padding: 12px 15px;
}
.bot-link {
font-size: 0.85em;
padding: 6px 12px;
margin-top: 8px;
}
.controls {
top: 10px;
right: 10px;
padding: 10px;
}
.controls button {
width: 35px;
height: 35px;
font-size: 1em;
}
.info-section {
padding: 15px;
}
.info-section h2 {
font-size: 1.1em;
}
.info-section h3 {
font-size: 1em;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.stat-card {
padding: 10px;
}
.stat-value {
font-size: 1.5em;
}
.connections {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
body {
overflow: auto;
}
.container {
display: flex;
flex-direction: column;
height: auto;
min-height: 100vh;
}
.map-section {
height: 60vh;
min-height: 400px;
}
.info-panel {
max-height: none;
height: auto;
}
h1 {
font-size: 1.2em;
}
.header {
padding: 10px 12px;
}
.bot-link {
font-size: 0.8em;
padding: 6px 12px;
margin-top: 6px;
gap: 6px;
}
.bot-link-icon {
font-size: 1em;
}
.controls {
top: 8px;
right: 8px;
padding: 8px;
}
.controls button {
width: 32px;
height: 32px;
font-size: 0.9em;
margin: 3px 0;
}
.info-section {
padding: 12px;
}
.info-section h2 {
font-size: 1em;
margin-bottom: 10px;
}
.info-section h3 {
font-size: 0.95em;
margin: 10px 0 8px 0;
}
.description {
padding: 8px;
font-size: 0.9em;
}
.connection-item {
padding: 6px 10px;
font-size: 0.85em;
}
.interactable-card,
.enemy-card {
padding: 10px;
}
.action-item {
padding: 8px;
font-size: 0.8em;
}
.enemy-stats {
grid-template-columns: 1fr;
gap: 5px;
}
.stat-item {
font-size: 0.85em;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.stat-card {
padding: 8px;
}
.stat-value {
font-size: 1.3em;
}
.stat-label {
font-size: 0.8em;
}
.legend-item {
font-size: 0.85em;
}
/* Modal adjustments for mobile */
.image-modal-content {
max-width: 95%;
max-height: 80vh;
}
.image-modal-close {
top: 10px;
right: 15px;
font-size: 35px;
}
.image-modal-info {
bottom: 10px;
padding: 8px 15px;
font-size: 0.85em;
}
}
/* Landscape orientation on mobile */
@media (max-width: 768px) and (orientation: landscape) {
.container {
grid-template-columns: 1fr 300px;
grid-template-rows: 1fr;
}
.map-section {
min-height: 100vh;
}
.info-panel {
max-height: 100vh;
border-top: none;
border-left: 2px solid #2a2a4a;
}
}
/* Touch-friendly controls */
@media (hover: none) and (pointer: coarse) {
.controls button {
min-width: 40px;
min-height: 40px;
}
.location-image,
.interactable-image,
.enemy-image {
cursor: default;
}
/* Larger tap targets */
.connection-item,
.action-item,
.stat-item {
min-height: 40px;
display: flex;
align-items: center;
}
}
</style>
</head>
<body>
<div class="container">
<div class="map-section">
<div class="header">
<h1>🗺️ Interactive World Map</h1>
<p class="subtitle">Echoes of the Ashes</p>
<a href="https://t.me/echoes_of_the_ash_bot" target="_blank" rel="noopener noreferrer" class="bot-link">
<span class="bot-link-icon">🤖</span>
<span>Play on Telegram</span>
</a>
</div>
<div class="canvas-wrapper">
<canvas id="mapCanvas"></canvas>
<div class="controls">
<button id="zoomIn" title="Zoom In">+</button>
<button id="zoomOut" title="Zoom Out"></button>
<button id="resetView" title="Reset View"></button>
<button id="toggleLabels" title="Toggle Labels">🏷️</button>
</div>
</div>
</div>
<div class="info-panel">
<div class="info-section">
<h2>📍 Location Details</h2>
<div id="locationInfo">
<p class="no-data">Click on a location to see details</p>
</div>
</div>
<div class="info-section">
<h2>🎯 Interactables</h2>
<div id="interactablesInfo">
<p class="no-data">Select a location to see interactables</p>
</div>
</div>
<div class="info-section">
<h2>⚔️ Enemy Encounters</h2>
<div id="enemiesInfo">
<p class="no-data">Select a location to see possible enemies</p>
</div>
</div>
<div class="info-section">
<h2>📊 Map Statistics</h2>
<div id="statsInfo">
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value" id="totalLocations">-</span>
<span class="stat-label">Locations</span>
</div>
<div class="stat-card">
<span class="stat-value" id="totalConnections">-</span>
<span class="stat-label">Routes</span>
</div>
<div class="stat-card">
<span class="stat-value" id="totalInteractables">-</span>
<span class="stat-label">Interactables</span>
</div>
<div class="stat-card">
<span class="stat-value" id="totalEnemies">-</span>
<span class="stat-label">Enemy Types</span>
</div>
</div>
</div>
</div>
<div class="info-section">
<h2>🗺️ Legend</h2>
<div class="legend">
<div class="legend-item">
<div class="legend-color" style="background: #4fc3f7;"></div>
<span>Safe Zone</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #ffa726;"></div>
<span>Low Danger</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #ff7043;"></div>
<span>Medium Danger</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #e53935;"></div>
<span>High Danger</span>
</div>
</div>
</div>
</div>
</div>
<!-- Image Modal/Lightbox -->
<div id="imageModal" class="image-modal">
<span class="image-modal-close">&times;</span>
<img id="modalImage" class="image-modal-content" alt="Full size image">
<div class="image-modal-info" id="modalInfo">Click anywhere to close</div>
</div>
<script src="map.js"></script>
</body>
</html>

603
web-map/map.js Normal file
View File

@@ -0,0 +1,603 @@
// Map visualization with pan, zoom, and interactive features
let canvas, ctx;
let mapData = { locations: [], connections: [], interactables: [], spawn_tables: {} };
let viewOffset = { x: 0, y: 0 };
let viewScale = 1.0;
let isDragging = false;
let lastMouse = { x: 0, y: 0 };
let showLabels = true;
let selectedLocation = null;
// Visual settings
const gridSize = 100; // pixels per game unit
const nodeRadius = 12;
const colors = {
background: '#0a0a1a',
grid: '#1a1a2e',
connection: '#3a3a6a',
nodeSafe: '#4fc3f7',
nodeLowDanger: '#ffa726',
nodeMediumDanger: '#ff7043',
nodeHighDanger: '#e53935',
nodeSelected: '#00ff88',
text: '#ffffff',
label: '#e0e0e0'
};
// Initialize
window.addEventListener('load', async () => {
canvas = document.getElementById('mapCanvas');
ctx = canvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// Load map data
await loadMapData();
// Event listeners
canvas.addEventListener('mousedown', onMouseDown);
canvas.addEventListener('mousemove', onMouseMove);
canvas.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('mouseleave', onMouseUp);
canvas.addEventListener('wheel', onWheel);
canvas.addEventListener('click', onClick);
// Touch event listeners for mobile
canvas.addEventListener('touchstart', onTouchStart, { passive: false });
canvas.addEventListener('touchmove', onTouchMove, { passive: false });
canvas.addEventListener('touchend', onTouchEnd);
document.getElementById('zoomIn').addEventListener('click', () => zoom(1.2));
document.getElementById('zoomOut').addEventListener('click', () => zoom(0.8));
document.getElementById('resetView').addEventListener('click', resetView);
document.getElementById('toggleLabels').addEventListener('click', toggleLabels);
// Image modal setup
setupImageModal();
draw();
});
function resizeCanvas() {
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
draw();
}
async function loadMapData() {
try {
const response = await fetch('/map_data.json');
mapData = await response.json();
centerView();
updateStatistics();
draw();
} catch (error) {
console.error('Failed to load map data:', error);
}
}
function updateStatistics() {
document.getElementById('totalLocations').textContent = mapData.locations.length;
document.getElementById('totalConnections').textContent = mapData.connections.length;
document.getElementById('totalInteractables').textContent = mapData.interactables.length;
const uniqueEnemies = new Set();
Object.values(mapData.spawn_tables).forEach(enemies => {
enemies.forEach(enemy => uniqueEnemies.add(enemy.npc_id));
});
document.getElementById('totalEnemies').textContent = uniqueEnemies.size;
}
function worldToScreen(x, y) {
return {
x: (x * gridSize * viewScale) + viewOffset.x + canvas.width / 2,
y: (-y * gridSize * viewScale) + viewOffset.y + canvas.height / 2
};
}
function screenToWorld(sx, sy) {
return {
x: ((sx - canvas.width / 2 - viewOffset.x) / gridSize) / viewScale,
y: -((sy - canvas.height / 2 - viewOffset.y) / gridSize) / viewScale
};
}
function centerView() {
if (mapData.locations.length === 0) return;
// Calculate bounds
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
mapData.locations.forEach(loc => {
minX = Math.min(minX, loc.x);
maxX = Math.max(maxX, loc.x);
minY = Math.min(minY, loc.y);
maxY = Math.max(maxY, loc.y);
});
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
viewOffset.x = -centerX * gridSize * viewScale;
viewOffset.y = centerY * gridSize * viewScale;
}
function resetView() {
viewScale = 1.0;
centerView();
draw();
}
function toggleLabels() {
showLabels = !showLabels;
draw();
}
function zoom(factor) {
const oldScale = viewScale;
viewScale *= factor;
viewScale = Math.max(0.3, Math.min(3.0, viewScale));
const scaleDiff = viewScale - oldScale;
viewOffset.x -= (canvas.width / 2) * scaleDiff / oldScale;
viewOffset.y -= (canvas.height / 2) * scaleDiff / oldScale;
draw();
}
function onMouseDown(e) {
isDragging = true;
lastMouse = { x: e.clientX, y: e.clientY };
canvas.style.cursor = 'grabbing';
}
function onMouseMove(e) {
if (isDragging) {
const dx = e.clientX - lastMouse.x;
const dy = e.clientY - lastMouse.y;
viewOffset.x += dx;
viewOffset.y += dy;
lastMouse = { x: e.clientX, y: e.clientY };
draw();
}
}
function onMouseUp() {
isDragging = false;
canvas.style.cursor = 'grab';
}
// Touch event handlers for mobile
let lastTouch = { x: 0, y: 0 };
let touchStartTime = 0;
let lastTouchDistance = 0;
function onTouchStart(e) {
e.preventDefault();
if (e.touches.length === 1) {
// Single touch - pan
isDragging = true;
const touch = e.touches[0];
lastTouch = { x: touch.clientX, y: touch.clientY };
lastMouse = { x: touch.clientX, y: touch.clientY };
touchStartTime = Date.now();
} else if (e.touches.length === 2) {
// Two touches - pinch zoom
isDragging = false;
const touch1 = e.touches[0];
const touch2 = e.touches[1];
lastTouchDistance = Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2)
);
}
}
function onTouchMove(e) {
e.preventDefault();
if (e.touches.length === 1 && isDragging) {
// Single touch - pan
const touch = e.touches[0];
const dx = touch.clientX - lastTouch.x;
const dy = touch.clientY - lastTouch.y;
viewOffset.x += dx;
viewOffset.y += dy;
lastTouch = { x: touch.clientX, y: touch.clientY };
draw();
} else if (e.touches.length === 2) {
// Two touches - pinch zoom
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const newDistance = Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2)
);
if (lastTouchDistance > 0) {
const zoomFactor = newDistance / lastTouchDistance;
// Calculate center point between two touches
const centerX = (touch1.clientX + touch2.clientX) / 2;
const centerY = (touch1.clientY + touch2.clientY) / 2;
const rect = canvas.getBoundingClientRect();
const mouseX = centerX - rect.left;
const mouseY = centerY - rect.top;
const worldBefore = screenToWorld(mouseX, mouseY);
zoom(zoomFactor);
const worldAfter = screenToWorld(mouseX, mouseY);
viewOffset.x += (worldAfter.x - worldBefore.x) * gridSize * viewScale;
viewOffset.y -= (worldAfter.y - worldBefore.y) * gridSize * viewScale;
}
lastTouchDistance = newDistance;
draw();
}
}
function onTouchEnd(e) {
if (e.touches.length === 0) {
isDragging = false;
lastTouchDistance = 0;
// Check if it was a quick tap (< 200ms) for click action
const touchDuration = Date.now() - touchStartTime;
if (touchDuration < 200 && e.changedTouches.length === 1) {
const touch = e.changedTouches[0];
// Simulate click event
const clickEvent = {
clientX: touch.clientX,
clientY: touch.clientY
};
onClick(clickEvent);
}
} else if (e.touches.length === 1) {
// One finger remaining, reset for pan
const touch = e.touches[0];
lastTouch = { x: touch.clientX, y: touch.clientY };
isDragging = true;
lastTouchDistance = 0;
}
}
function onWheel(e) {
e.preventDefault();
const zoomSpeed = 0.001;
const delta = -e.deltaY * zoomSpeed;
const factor = 1 + delta;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const worldBefore = screenToWorld(mouseX, mouseY);
zoom(factor);
const worldAfter = screenToWorld(mouseX, mouseY);
viewOffset.x += (worldAfter.x - worldBefore.x) * gridSize * viewScale;
viewOffset.y -= (worldAfter.y - worldBefore.y) * gridSize * viewScale;
draw();
}
function onClick(e) {
if (isDragging) return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const worldPos = screenToWorld(mouseX, mouseY);
// Find clicked location
for (const location of mapData.locations) {
const dist = Math.sqrt(
Math.pow(location.x - worldPos.x, 2) +
Math.pow(location.y - worldPos.y, 2)
);
if (dist < 0.3) { // Click threshold
selectedLocation = location;
showLocationInfo(location);
draw();
return;
}
}
}
function getDangerLevel(locationId) {
const spawns = mapData.spawn_tables[locationId];
if (!spawns || spawns.length === 0) return 'safe';
// Calculate average enemy strength
let totalXP = 0;
spawns.forEach(enemy => {
totalXP += enemy.xp_reward * (enemy.spawn_weight / 100);
});
if (totalXP < 20) return 'low';
if (totalXP < 40) return 'medium';
return 'high';
}
function getNodeColor(locationId) {
const danger = getDangerLevel(locationId);
switch (danger) {
case 'safe': return colors.nodeSafe;
case 'low': return colors.nodeLowDanger;
case 'medium': return colors.nodeMediumDanger;
case 'high': return colors.nodeHighDanger;
default: return colors.nodeSafe;
}
}
function draw() {
// Clear canvas
ctx.fillStyle = colors.background;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw grid
drawGrid();
// Draw connections
ctx.lineWidth = 2 * viewScale;
mapData.connections.forEach(conn => {
const from = mapData.locations.find(l => l.id === conn.from);
const to = mapData.locations.find(l => l.id === conn.to);
if (from && to) {
const fromScreen = worldToScreen(from.x, from.y);
const toScreen = worldToScreen(to.x, to.y);
ctx.strokeStyle = colors.connection;
ctx.beginPath();
ctx.moveTo(fromScreen.x, fromScreen.y);
ctx.lineTo(toScreen.x, toScreen.y);
ctx.stroke();
// Draw distance label
if (showLabels && viewScale > 0.6) {
const midX = (fromScreen.x + toScreen.x) / 2;
const midY = (fromScreen.y + toScreen.y) / 2;
const stamina = Math.ceil(conn.distance * 3);
ctx.fillStyle = 'rgba(42, 42, 74, 0.9)';
ctx.fillRect(midX - 20, midY - 10, 40, 20);
ctx.fillStyle = colors.label;
ctx.font = `${10 * viewScale}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${stamina}`, midX, midY);
}
}
});
// Draw locations
mapData.locations.forEach(location => {
const screen = worldToScreen(location.x, location.y);
const radius = nodeRadius * viewScale;
// Node circle
const isSelected = selectedLocation && selectedLocation.id === location.id;
ctx.fillStyle = isSelected ? colors.nodeSelected : getNodeColor(location.id);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = isSelected ? 3 * viewScale : 2 * viewScale;
ctx.beginPath();
ctx.arc(screen.x, screen.y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Interactable indicator
if (location.interactable_count > 0) {
ctx.fillStyle = '#ffd700';
ctx.font = `${8 * viewScale}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(location.interactable_count, screen.x, screen.y);
}
// Label
if (showLabels) {
ctx.fillStyle = colors.label;
ctx.font = `${12 * viewScale}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(location.name, screen.x, screen.y + radius + 5);
}
});
}
function drawGrid() {
ctx.strokeStyle = colors.grid;
ctx.lineWidth = 1;
const step = gridSize * viewScale;
const startX = (viewOffset.x % step) - step;
const startY = (viewOffset.y % step) - step;
for (let x = startX; x < canvas.width; x += step) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
for (let y = startY; y < canvas.height; y += step) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
}
function showLocationInfo(location) {
// Location details
const locationHTML = `
${location.image_path ?
`<img src="/${location.image_path}" class="location-image" onerror="this.style.display='none';" />` :
`<div class="image-placeholder">🗺️</div>`
}
<h3>${location.name}</h3>
<div class="description">${location.description}</div>
<p><strong>Coordinates:</strong> (${location.x}, ${location.y})</p>
<p><strong>Interactables:</strong> ${location.interactable_count}</p>
<h3>🧭 Connections</h3>
<div class="connections">
${mapData.connections
.filter(c => c.from === location.id)
.map(c => {
const dest = mapData.locations.find(l => l.id === c.to);
const stamina = Math.ceil(c.distance * 3);
return `<div class="connection-item">
<strong>${c.direction.toUpperCase()}</strong><br>
${dest ? dest.name : c.to}<br>
${stamina}⚡ stamina
</div>`;
})
.join('')
}
</div>
`;
document.getElementById('locationInfo').innerHTML = locationHTML;
// Add click handler to location image
setTimeout(() => {
const locationImg = document.querySelector('.location-image');
if (locationImg) {
locationImg.addEventListener('click', () => openImageModal(locationImg.src, location.name));
}
}, 0);
// Interactables
const locationInteractables = mapData.interactables.filter(i => i.location_id === location.id);
if (locationInteractables.length > 0) {
const interactablesHTML = `
<div class="interactable-list">
${locationInteractables.map(inter => `
<div class="interactable-card">
<div class="interactable-header">
<div class="interactable-icon">
${inter.image_path ?
`<img src="/${inter.image_path}" class="interactable-image" onerror="this.outerHTML='<div class=\\'interactable-image-placeholder\\'>📦</div>';" />` :
`<div class="interactable-image-placeholder">📦</div>`
}
</div>
<div class="interactable-name">${inter.name}</div>
</div>
${inter.actions.map(action => `
<div class="action-item">
<div class="action-header">${action.label} (${action.stamina_cost}⚡)</div>
${action.outcomes.map(outcome => `
<div class="outcome-item outcome-${outcome.type}">
<strong>${outcome.type}:</strong> ${outcome.text}
${Object.keys(outcome.items).length > 0 ?
`<br>Items: ${Object.entries(outcome.items).map(([id, qty]) => `${id} x${qty}`).join(', ')}` : ''}
${outcome.damage > 0 ? `<br>⚠️ Damage: ${outcome.damage} HP` : ''}
</div>
`).join('')}
</div>
`).join('')}
</div>
`).join('')}
</div>
`;
document.getElementById('interactablesInfo').innerHTML = interactablesHTML;
// Add click handlers to interactable images
setTimeout(() => {
document.querySelectorAll('.interactable-image').forEach(img => {
img.addEventListener('click', () => openImageModal(img.src, img.alt || 'Interactable'));
});
}, 0);
} else {
document.getElementById('interactablesInfo').innerHTML = '<p class="no-data">No interactables at this location</p>';
}
// Enemies
const enemies = mapData.spawn_tables[location.id];
if (enemies && enemies.length > 0) {
const enemiesHTML = `
<p><strong>Encounter Rate:</strong> ${enemies[0].encounter_rate}% when traveling</p>
<div class="enemy-list">
${enemies.map(enemy => `
<div class="enemy-card">
<div class="enemy-header">
<div class="enemy-icon">
${enemy.image_url ?
`<img src="/${enemy.image_url}" class="enemy-image" onerror="this.outerHTML='<div class=\\'enemy-image-placeholder\\'>${enemy.emoji}</div>';" />` :
`<div class="enemy-image-placeholder">${enemy.emoji}</div>`
}
</div>
<div class="enemy-name">${enemy.name}</div>
<div>${enemy.spawn_chance}%</div>
</div>
<div class="enemy-stats">
<div class="stat-item">❤️ HP: ${enemy.hp_range[0]}-${enemy.hp_range[1]}</div>
<div class="stat-item">⚔️ DMG: ${enemy.damage_range[0]}-${enemy.damage_range[1]}</div>
<div class="stat-item">⭐ XP: ${enemy.xp_reward}</div>
<div class="stat-item">🎲 Weight: ${enemy.spawn_weight}</div>
</div>
</div>
`).join('')}
</div>
`;
document.getElementById('enemiesInfo').innerHTML = enemiesHTML;
// Add click handlers to enemy images
setTimeout(() => {
document.querySelectorAll('.enemy-image').forEach(img => {
img.addEventListener('click', () => openImageModal(img.src, img.alt || 'Enemy'));
});
}, 0);
} else {
document.getElementById('enemiesInfo').innerHTML = '<p class="no-data">✅ Safe zone - no enemies spawn here</p>';
}
}
// Image Modal Functions
function setupImageModal() {
const modal = document.getElementById('imageModal');
const closeBtn = document.querySelector('.image-modal-close');
// Close modal when clicking close button, backdrop, or pressing Escape
closeBtn.addEventListener('click', closeImageModal);
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeImageModal();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.classList.contains('active')) {
closeImageModal();
}
});
}
function openImageModal(imageSrc, imageTitle) {
const modal = document.getElementById('imageModal');
const modalImg = document.getElementById('modalImage');
const modalInfo = document.getElementById('modalInfo');
modalImg.src = imageSrc;
modalImg.alt = imageTitle;
modalInfo.textContent = imageTitle + ' - Click anywhere to close';
modal.classList.add('active');
// Prevent body scrolling when modal is open
document.body.style.overflow = 'hidden';
}
function closeImageModal() {
const modal = document.getElementById('imageModal');
modal.classList.remove('active');
// Re-enable body scrolling
document.body.style.overflow = 'auto';
}

3
web-map/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
flask==3.0.0
flask-cors==4.0.0
werkzeug==3.0.1

117
web-map/server.py Normal file
View File

@@ -0,0 +1,117 @@
from http.server import HTTPServer, SimpleHTTPRequestHandler
import os
import sys
import json
from pathlib import Path
# Get the directory of this script
SCRIPT_DIR = Path(__file__).parent.resolve()
class MapServerHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
# Set the directory to serve files from
super().__init__(*args, directory=str(SCRIPT_DIR), **kwargs)
def end_headers(self):
# Add CORS headers to allow access from anywhere
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
self.send_header('Access-Control-Allow-Headers', '*')
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
super().end_headers()
def do_GET(self):
# Handle map_data.json request
if self.path == '/map_data.json':
try:
# Try to load from parent directory's data module
sys.path.insert(0, str(SCRIPT_DIR.parent))
from data.world_loader import export_map_data
map_data = export_map_data()
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(map_data, indent=2).encode())
return
except Exception as e:
print(f"Error generating map data: {e}")
# Fall back to static file if it exists
map_file = SCRIPT_DIR / 'map_data.json'
if map_file.exists():
with open(map_file, 'r') as f:
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
self.wfile.write(f.read().encode())
return
self.send_error(500, f"Failed to generate map data: {str(e)}")
return
# Handle image requests from parent images directory
if self.path.startswith('/images/'):
try:
# Construct path to image in parent directory
image_path = SCRIPT_DIR.parent / self.path.lstrip('/')
if image_path.exists() and image_path.is_file():
# Determine content type based on file extension
content_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml'
}
ext = image_path.suffix.lower()
content_type = content_types.get(ext, 'application/octet-stream')
with open(image_path, 'rb') as f:
self.send_response(200)
self.send_header('Content-type', content_type)
self.end_headers()
self.wfile.write(f.read())
return
else:
self.send_error(404, f"Image not found: {self.path}")
return
except Exception as e:
print(f"Error serving image {self.path}: {e}")
self.send_error(500, f"Failed to serve image: {str(e)}")
return
# Serve other files normally
return super().do_GET()
def run_server(port=8080):
server_address = ('', port)
httpd = HTTPServer(server_address, MapServerHandler)
print(f"""
╔════════════════════════════════════════════╗
║ Echoes of the Ashes - Map Server ║
╚════════════════════════════════════════════╝
🗺️ Map server running on:
→ http://localhost:{port}
→ http://0.0.0.0:{port}
📊 Serving from: {SCRIPT_DIR}
Press Ctrl+C to stop the server
""")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n\n👋 Shutting down server...")
httpd.shutdown()
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='Run the RPG map visualization server')
parser.add_argument('--port', type=int, default=8080, help='Port to run the server on (default: 8080)')
args = parser.parse_args()
run_server(args.port)

1097
web-map/server_enhanced.py Normal file

File diff suppressed because it is too large Load Diff

View File