Initial commit: Echoes of the Ashes - Telegram RPG Bot
83
.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 🎮 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
495
bot/combat.py
Normal 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
@@ -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
603
bot/keyboards.py
Normal 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
@@ -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
@@ -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
@@ -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
71
data/items.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
465
gamedata/npcs.json
Normal 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
@@ -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.
|
||||
2
images/interactables/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# This is a placeholder file.
|
||||
# Replace with actual interactable images.
|
||||
BIN
images/interactables/dumpster.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
images/interactables/house.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
images/interactables/medkit.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
images/interactables/rubble.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
images/interactables/sedan.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
images/interactables/toolshed.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
images/interactables/vending.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
2
images/locations/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# This is a placeholder file.
|
||||
# Replace with actual location images.
|
||||
BIN
images/locations/clinic.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
images/locations/downtown.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
images/locations/gas_station.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
images/locations/office_building.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
images/locations/office_interior.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
images/locations/overpass.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
images/locations/park.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
images/locations/plaza.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
images/locations/residential.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
images/locations/subway.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
images/locations/subway_section_a.jpg
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
images/locations/subway_tunnels.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
images/locations/warehouse.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
images/locations/warehouse_interior.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
0
images/npcs/.gitkeep
Normal file
BIN
images/npcs/feral_dog.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
images/npcs/infected_human.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
images/npcs/mutant_rat.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
images/npcs/raider_scout.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
images/npcs/scavenger.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
154
main.py
Normal 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
@@ -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
@@ -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
470
web-map/editor.js
Normal 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
869
web-map/index.html
Normal 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">×</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
@@ -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
@@ -0,0 +1,3 @@
|
||||
flask==3.0.0
|
||||
flask-cors==4.0.0
|
||||
werkzeug==3.0.1
|
||||
117
web-map/server.py
Normal 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)
|
||||