Compare commits
43 Commits
v0.1.2-tes
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d38d4cc288 | ||
|
|
a725ae5836 | ||
|
|
bba5d1d9dd | ||
|
|
70dc35b4b2 | ||
|
|
8820cd897e | ||
|
|
596a3ce010 | ||
|
|
05136e9708 | ||
|
|
b82f3c4855 | ||
|
|
2e9a833a1a | ||
|
|
6f1e8c56f2 | ||
|
|
0e0ac10b20 | ||
|
|
c9d180379a | ||
|
|
ff9472048d | ||
|
|
dae6e6df2d | ||
|
|
d791fcec7e | ||
|
|
e6e1d3f312 | ||
|
|
eb75ee5b33 | ||
|
|
dcfc91b82b | ||
|
|
fb92f28a69 | ||
|
|
539377e63d | ||
|
|
173d6c9117 | ||
|
|
ccf9ba3e28 | ||
|
|
1b7ffd614d | ||
|
|
e6747b1d05 | ||
|
|
0b0a23f500 | ||
|
|
7f42fd6b7f | ||
|
|
f986fa18a0 | ||
|
|
2875e72b20 | ||
|
|
dc438ae4c1 | ||
|
|
ea594f80c6 | ||
|
|
ee55c5f887 | ||
|
|
2766b4035f | ||
|
|
592f38827e | ||
|
|
8b31011334 | ||
|
|
2a861079bd | ||
|
|
6ea93d5fdd | ||
|
|
c539798dd4 | ||
|
|
f87c5fde6e | ||
|
|
592591cb92 | ||
|
|
0d7133cc0e | ||
|
|
7274e2af30 | ||
|
|
e16352c5d3 | ||
|
|
e5029c558b |
124
.gitlab-ci.yml
@@ -3,11 +3,9 @@ stages:
|
||||
- build-desktop
|
||||
|
||||
variables:
|
||||
# Cache configuration
|
||||
npm_config_cache: "$CI_PROJECT_DIR/.npm"
|
||||
ELECTRON_CACHE: "$CI_PROJECT_DIR/.cache/electron"
|
||||
|
||||
# Cache node_modules and electron cache
|
||||
cache:
|
||||
key: ${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
@@ -28,30 +26,53 @@ build:web:
|
||||
- pwa/dist/
|
||||
expire_in: 1 hour
|
||||
rules:
|
||||
- if: '$CI_COMMIT_TAG' # Run on tags
|
||||
- if: '$CI_COMMIT_BRANCH == "main"' # Run on main branch
|
||||
- if: '$CI_COMMIT_BRANCH == "develop"' # Run on develop branch
|
||||
|
||||
# Build Linux AppImage and .deb
|
||||
build:linux:
|
||||
stage: build-desktop
|
||||
image: electronuserland/builder:wine
|
||||
dependencies:
|
||||
- build:web
|
||||
script:
|
||||
- cd pwa
|
||||
- npm ci
|
||||
- npm run electron:build:linux
|
||||
artifacts:
|
||||
paths:
|
||||
- pwa/dist-electron/*.AppImage
|
||||
- pwa/dist-electron/*.deb
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: '$CI_COMMIT_TAG' # Only run on tags
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
tags:
|
||||
- docker
|
||||
|
||||
# # Build Linux AppImage and .deb
|
||||
# build:linux:
|
||||
# stage: build-desktop
|
||||
# image: electronuserland/builder:wine
|
||||
# dependencies:
|
||||
# - build:web
|
||||
# script:
|
||||
# - cd pwa
|
||||
# - npm ci
|
||||
# - npm run electron:build:linux
|
||||
# - echo "=== AppImage size ==="
|
||||
# - ls -lh dist-electron/*.AppImage
|
||||
# - du -h dist-electron/*.AppImage
|
||||
# artifacts:
|
||||
# paths:
|
||||
# - pwa/dist-electron/*.AppImage
|
||||
# expire_in: 1 week
|
||||
# name: "linux-appimage-$CI_COMMIT_TAG"
|
||||
# rules:
|
||||
# - if: '$CI_COMMIT_TAG'
|
||||
# tags:
|
||||
# - docker
|
||||
|
||||
# # Build Linux .deb (separate job to avoid size limits)
|
||||
# build:linux-deb:
|
||||
# stage: build-desktop
|
||||
# image: electronuserland/builder:wine
|
||||
# dependencies:
|
||||
# - build:web
|
||||
# script:
|
||||
# - cd pwa
|
||||
# - npm ci
|
||||
# - npm run electron:build:linux
|
||||
# artifacts:
|
||||
# paths:
|
||||
# - pwa/dist-electron/*.deb
|
||||
# expire_in: 1 week
|
||||
# name: "linux-deb-$CI_COMMIT_TAG"
|
||||
# rules:
|
||||
# - if: '$CI_COMMIT_TAG'
|
||||
# tags:
|
||||
# - docker
|
||||
|
||||
# Build Windows executable
|
||||
build:windows:
|
||||
stage: build-desktop
|
||||
@@ -62,60 +83,17 @@ build:windows:
|
||||
- cd pwa
|
||||
- npm ci
|
||||
- npm run electron:build:win
|
||||
# Show file sizes
|
||||
- echo "=== Build artifacts ==="
|
||||
- ls -lh dist-electron/*.exe || echo "No .exe files found"
|
||||
- echo "=== Total size ==="
|
||||
- du -sh dist-electron/
|
||||
artifacts:
|
||||
paths:
|
||||
- pwa/dist-electron/*.exe
|
||||
- pwa/dist-electron/*.msi
|
||||
expire_in: 1 week
|
||||
name: "windows-installer-$CI_COMMIT_TAG"
|
||||
rules:
|
||||
- if: '$CI_COMMIT_TAG' # Only run on tags
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
tags:
|
||||
- docker
|
||||
|
||||
# Build macOS (requires macOS runner - optional)
|
||||
# Uncomment if you have a macOS runner available
|
||||
# build:mac:
|
||||
# stage: build-desktop
|
||||
# dependencies:
|
||||
# - build:web
|
||||
# script:
|
||||
# - cd pwa
|
||||
# - npm ci
|
||||
# - npm run electron:build:mac
|
||||
# artifacts:
|
||||
# paths:
|
||||
# - pwa/dist-electron/*.dmg
|
||||
# expire_in: 1 week
|
||||
# rules:
|
||||
# - if: '$CI_COMMIT_TAG'
|
||||
# tags:
|
||||
# - macos
|
||||
|
||||
# Manual job to test builds without tags
|
||||
build:manual:
|
||||
stage: build-desktop
|
||||
image: electronuserland/builder:wine
|
||||
script:
|
||||
- cd pwa
|
||||
- npm ci
|
||||
- npm run electron:build:linux
|
||||
artifacts:
|
||||
paths:
|
||||
- pwa/dist-electron/
|
||||
expire_in: 1 day
|
||||
when: manual
|
||||
tags:
|
||||
- docker
|
||||
|
||||
# Test job to verify CI is working (no tags required)
|
||||
test:ci:
|
||||
stage: build-web
|
||||
image: alpine:latest
|
||||
script:
|
||||
- echo "CI is working!"
|
||||
- echo "Project directory:"
|
||||
- ls -la
|
||||
- echo "PWA directory:"
|
||||
- ls -la pwa/ || echo "PWA directory not found"
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "push"' # Run on every push
|
||||
|
||||
56
CLAUDE.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# CLAUDE.md - Echoes of the Ash
|
||||
|
||||
## Project Overview
|
||||
- **Type**: Dark Fantasy RPG Adventure
|
||||
- **Stack**: Monorepo with Python/FastAPI backend and React/Vite/TypeScript frontend.
|
||||
- **Infrastructure**: Docker Compose (Postgres, Redis, Traefik).
|
||||
- **Primary Target**: Web (PWA + API). Electron is secondary.
|
||||
|
||||
## Commands
|
||||
|
||||
### Development & Deployment
|
||||
- **Start (Dev)**: `docker compose up -d`
|
||||
- **Apply Changes**: `docker compose build && docker compose up -d` (Required for both code and env changes)
|
||||
- **Restart API**: `docker compose restart echoes_of_the_ashes_api`
|
||||
- **View Logs**: `docker compose logs -f [service_name]` (e.g., `echoes_of_the_ashes_api`, `echoes_of_the_ashes_pwa`)
|
||||
|
||||
### Frontend (PWA)
|
||||
- **Directory**: `pwa/`
|
||||
- **Install**: `npm install`
|
||||
- **Dev Server**: `npm run dev`
|
||||
- **Build**: `npm run build`
|
||||
- **Lint**: `npm run lint`
|
||||
|
||||
### Backend (API)
|
||||
- **Directory**: `api/`
|
||||
- **Dependencies**: `requirements.txt`
|
||||
- **Manual Run**: `uvicorn main:app --reload` (Local only, relies on env vars)
|
||||
|
||||
### Testing
|
||||
- **Directory**: `tests/`
|
||||
- **Status**: Temporary/Manual scripts.
|
||||
- **Run**: `python tests/test_api.py` (Run locally or inside container depending on env access)
|
||||
|
||||
## Architecture & Code Structure
|
||||
|
||||
### Backend (`api/`)
|
||||
- **Entry**: `main.py`
|
||||
- **Routers**: `routers/` (Modular endpoints: `game_routes.py`, `combat.py`, `auth.py`, etc.)
|
||||
- **Core**: `core/` (Config, Security, WebSockets)
|
||||
- **Services**: `services/` (Models, Helpers)
|
||||
- **Pattern**:
|
||||
- Use `routers` for new features.
|
||||
- Register routers in `main.py` (auto-registration logic exists but explicit is clearer).
|
||||
- Pydantic models in `services/models.py`.
|
||||
|
||||
### Frontend (`pwa/`)
|
||||
- **Entry**: `src/main.tsx`
|
||||
- **Styling**: Standard CSS files per component (e.g., `components/Game.css`). No Tailwind/Modules.
|
||||
- **State**: Zustand stores (`src/stores/`).
|
||||
- **Translation**: i18next (`src/i18n/`).
|
||||
|
||||
## Style Guidelines
|
||||
- **Python**: PEP8 standard. No strict linter enforced.
|
||||
- **TypeScript**: Standard ESLint rules from Vite template.
|
||||
- **CSS**: Plain CSS. Keep component styles in dedicated files.
|
||||
- **Docs**: update `QUICK_REFERENCE.md` if simplified logic or architecture changes.
|
||||
17
Dockerfile
@@ -1,17 +0,0 @@
|
||||
# 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"]
|
||||
@@ -20,6 +20,7 @@ COPY data/ ./data/
|
||||
COPY gamedata/ ./gamedata/
|
||||
|
||||
# Copy migration scripts
|
||||
COPY migrations/ ./migrations/
|
||||
COPY migrate_*.py ./
|
||||
|
||||
# Copy startup script
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements
|
||||
COPY requirements.txt ./
|
||||
COPY api/requirements.txt ./api-requirements.txt
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
pip install --no-cache-dir -r api-requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY bot/ ./bot/
|
||||
COPY data/ ./data/
|
||||
COPY api/ ./api/
|
||||
COPY gamedata/ ./gamedata/
|
||||
COPY migrate_*.py ./
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the API server
|
||||
CMD ["python", "-m", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -1,39 +0,0 @@
|
||||
# Build stage for PWA
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY pwa/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY pwa/ ./
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage - simple Python server for static files
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /usr/share/app
|
||||
|
||||
# Copy built assets from build stage
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
# Copy game images
|
||||
COPY images/ ./dist/images/
|
||||
|
||||
# Install simple HTTP server
|
||||
RUN pip install --no-cache-dir aiofiles
|
||||
|
||||
# Copy a simple static file server script
|
||||
COPY pwa/server.py ./
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start the server
|
||||
CMD ["python", "server.py"]
|
||||
545
README.md
@@ -1,371 +1,188 @@
|
||||
# Echoes of the Ashes
|
||||
# Echoes of the Ash
|
||||
|
||||
A post-apocalyptic survival RPG available on **Telegram** and **Web**, featuring turn-based exploration, resource management, and a persistent world.
|
||||
> A post-apocalyptic survival RPG - Browser-based MUD-style game
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 🌐 Play Now
|
||||
## 🎮 What is Echoes of the Ash?
|
||||
|
||||
- **Telegram Bot**: [@your_bot_username](https://t.me/your_bot_username)
|
||||
- **Web/Mobile**: [echoesoftheashgame.patacuack.net](https://echoesoftheashgame.patacuack.net)
|
||||
|
||||
## 🎮 Features
|
||||
|
||||
### Core Gameplay
|
||||
- **🗺️ Exploration**: Navigate through 7 interconnected locations
|
||||
- **👀 Interact**: Search and interact with 24+ unique objects
|
||||
- **🎒 Inventory**: Collect, use, and manage 28 different items
|
||||
- **⚡️ Stamina System**: Actions require stamina management with automatic regeneration
|
||||
- **❤️ Survival**: Heal using consumables, avoid damage
|
||||
- **🔄 Cooldowns**: Per-action cooldown system prevents spam
|
||||
- **♻️ Auto-Recovery**: Stamina regenerates over time (1+ per 5 minutes based on endurance)
|
||||
|
||||
### Visual Experience
|
||||
- **📸 Location Images**: Every location has a unique image
|
||||
- **🖼️ Smart Caching**: Images cached in database for instant loading
|
||||
- **✨ Smooth Transitions**: Uses `edit_message_media` for seamless navigation
|
||||
- **🧭 Context-Aware**: Location images persist across menus
|
||||
|
||||
### Game World
|
||||
- **7 Locations**: Downtown, Gas Station, Residential, Clinic, Plaza, Park, Overpass
|
||||
- **5 Interactable Types**: Rubble, Sedans, Houses, Medical Cabinets, Tool Sheds, Dumpsters, Vending Machines
|
||||
- **28 Items**: Resources, consumables, weapons, equipment, quest items
|
||||
- **Risk vs Reward**: Higher risk actions can cause damage but yield better loot
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Telegram Bot
|
||||
|
||||
1. Get a Bot Token from [@BotFather](https://t.me/botfather)
|
||||
2. Create `.env` file with your credentials
|
||||
3. Run `docker-compose up -d --build`
|
||||
4. Find your bot and send `/start`
|
||||
|
||||
See [Installation Guide](#installation) for detailed instructions.
|
||||
|
||||
### Progressive Web App (PWA)
|
||||
|
||||
1. Run `./setup_pwa.sh` to set up the web version
|
||||
2. Open [echoesoftheashgame.patacuack.net](https://echoesoftheashgame.patacuack.net)
|
||||
3. Register an account and play!
|
||||
|
||||
See [PWA_QUICKSTART.md](PWA_QUICKSTART.md) for detailed instructions.
|
||||
|
||||
## 📱 Platform Features
|
||||
|
||||
### Telegram Bot
|
||||
- 🤖 Native Telegram integration
|
||||
- 🔔 Instant push notifications
|
||||
- 💬 Chat-based gameplay
|
||||
- 👥 Easy sharing with friends
|
||||
|
||||
### Web/Mobile PWA
|
||||
- 🌐 Play in any browser
|
||||
- 📱 Install as mobile app
|
||||
- 🎨 Modern responsive UI
|
||||
- 🔐 Separate authentication
|
||||
- ⚡ Offline support (coming soon)
|
||||
- 🔔 Web push notifications (coming soon)
|
||||
|
||||
## 🛠️ Installation
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose
|
||||
- For Telegram: Bot Token from [@BotFather](https://t.me/botfather)
|
||||
- For PWA: Node.js 20+ (for development)
|
||||
|
||||
### Basic Setup
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
cd /opt/dockers/echoes_of_the_ashes
|
||||
```
|
||||
|
||||
2. Create `.env` file:
|
||||
```env
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
DATABASE_URL=postgresql+psycopg://user:password@echoes_of_the_ashes_db:5432/telegram_rpg
|
||||
POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=password
|
||||
POSTGRES_DB=telegram_rpg
|
||||
JWT_SECRET_KEY=generate-with-openssl-rand-hex-32
|
||||
```
|
||||
|
||||
3. Start services:
|
||||
```bash
|
||||
# Telegram bot only
|
||||
docker-compose up -d --build
|
||||
|
||||
# With PWA (web version)
|
||||
./setup_pwa.sh
|
||||
```
|
||||
|
||||
4. Check logs:
|
||||
```bash
|
||||
docker logs echoes_of_the_ashes_bot -f
|
||||
docker logs echoes_of_the_ashes_api -f
|
||||
docker logs echoes_of_the_ashes_pwa -f
|
||||
```
|
||||
|
||||
## 🎯 How to Play
|
||||
|
||||
### Basic Commands
|
||||
- `/start` - Start your journey or return to main menu
|
||||
|
||||
### Main Menu
|
||||
- **🗺️ Move** - Travel to connected locations
|
||||
- **👀 Inspect Area** - View and interact with objects
|
||||
- **👤 Profile** - View your character stats
|
||||
- **🎒 Inventory** - Manage your items
|
||||
|
||||
### Actions
|
||||
- **Search/Loot** - Find items in the environment (costs stamina)
|
||||
- **Use Items** - Consume food/medicine to restore HP/stamina
|
||||
- **Drop Items** - Leave items at current location
|
||||
- **Pick Up** - Collect items from the ground
|
||||
|
||||
### Stats
|
||||
- **HP**: Health Points (die at 0)
|
||||
- **Stamina**: Required for actions (regenerates over time)
|
||||
- **Weight/Volume**: Inventory capacity limits
|
||||
|
||||
## 🗺️ World Map
|
||||
|
||||
```
|
||||
🛣️ Highway Overpass
|
||||
|
|
||||
🏥 Clinic --- ⛽️ Gas Station
|
||||
| |
|
||||
🏘️ Residential --- 🌆 Downtown --- 🏬 Plaza
|
||||
| |
|
||||
+------------ 🌳 Park ------------+
|
||||
```
|
||||
|
||||
## 📦 Items
|
||||
|
||||
### Consumables
|
||||
| Item | Effect | Emoji |
|
||||
|------|--------|-------|
|
||||
| First Aid Kit | +50 HP | 🩹 |
|
||||
| Mystery Pills | +30 HP | 💊 |
|
||||
| Canned Beans | +20 HP, +5 Stamina | 🥫 |
|
||||
| Energy Bar | +15 Stamina | 🍫 |
|
||||
| Bottled Water | +10 Stamina | 💧 |
|
||||
|
||||
### Resources
|
||||
- ⚙️ Scrap Metal
|
||||
- 🪵 Wood Planks
|
||||
- 📌 Rusty Nails
|
||||
- 🧵 Cloth Scraps
|
||||
- 🍶 Plastic Bottles
|
||||
|
||||
### Equipment
|
||||
- 🎒 Hiking Backpack (+20 capacity)
|
||||
- 🔦 Flashlight
|
||||
- 🔧 Tire Iron
|
||||
- ⚾ Baseball Bat
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Tech Stack
|
||||
- **Language**: Python 3.11
|
||||
- **Bot Framework**: python-telegram-bot 21.0.1
|
||||
- **Database**: PostgreSQL 15 (async with SQLAlchemy)
|
||||
- **Deployment**: Docker Compose
|
||||
- **Scheduler**: APScheduler (for stamina regeneration)
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
telegram-rpg/
|
||||
├── bot/
|
||||
│ ├── database.py # Database operations
|
||||
│ ├── handlers.py # Telegram event handlers
|
||||
│ ├── keyboards.py # Inline keyboard layouts
|
||||
│ └── logic.py # Game logic
|
||||
├── data/
|
||||
│ ├── items.py # Item definitions
|
||||
│ ├── models.py # Game world models
|
||||
│ └── world_loader.py # World construction
|
||||
├── docs/ # Comprehensive documentation
|
||||
├── images/ # Location and interactable images
|
||||
├── main.py # Entry point
|
||||
└── docker-compose.yml # Container orchestration
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
- **players**: Character stats and state
|
||||
- **inventory**: Player item storage
|
||||
- **dropped_items**: World item storage
|
||||
- **cooldowns**: Per-action cooldown tracking
|
||||
- **image_cache**: Telegram file_id caching
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
Detailed documentation in `docs/`:
|
||||
- **INVENTORY_USE.md** - Item usage system
|
||||
- **EXPANDED_WORLD.md** - All locations and items
|
||||
- **WORLD_MAP.md** - Map visualization and strategy
|
||||
- **IMAGE_SYSTEM.md** - Image caching implementation
|
||||
- **UX_IMPROVEMENTS.md** - Clean chat mechanics
|
||||
- **ACTION_FEEDBACK.md** - Action result display
|
||||
- **SMOOTH_TRANSITIONS.md** - Message editing system
|
||||
- **UPDATE_SUMMARY.md** - Latest changes
|
||||
|
||||
## 🎨 Adding Content
|
||||
|
||||
### New Item
|
||||
Edit `data/items.py`:
|
||||
```python
|
||||
"new_item": {
|
||||
"name": "New Item",
|
||||
"weight": 1.0,
|
||||
"volume": 0.5,
|
||||
"type": "consumable",
|
||||
"effects": {"hp": 20},
|
||||
"emoji": "🎁"
|
||||
}
|
||||
```
|
||||
|
||||
### New Interactable
|
||||
Edit `data/world_loader.py`:
|
||||
```python
|
||||
NEW_TEMPLATE = Interactable(
|
||||
id="new_object",
|
||||
name="New Object",
|
||||
image_path="images/interactables/new.png"
|
||||
)
|
||||
action = Action(id="search", label="🔎 Search", stamina_cost=2)
|
||||
action.add_outcome("success", Outcome(
|
||||
text="You find something!",
|
||||
items_reward={"new_item": 1}
|
||||
))
|
||||
NEW_TEMPLATE.add_action(action)
|
||||
```
|
||||
|
||||
### New Location
|
||||
```python
|
||||
new_location = Location(
|
||||
id="new_place",
|
||||
name="🏛️ New Place",
|
||||
description="Description here",
|
||||
image_path="images/locations/new_place.png"
|
||||
)
|
||||
new_location.add_interactable("new_place_object", NEW_TEMPLATE)
|
||||
new_location.add_exit("north", "other_location")
|
||||
world.add_location(new_location)
|
||||
```
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run bot
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Database Management
|
||||
```bash
|
||||
# Access database
|
||||
docker exec -it echoes_of_the_ashes_db psql -U user -d telegram_rpg
|
||||
|
||||
# Backup database
|
||||
docker exec echoes_of_the_ashes_db pg_dump -U user telegram_rpg > backup.sql
|
||||
|
||||
# Restore database
|
||||
docker exec -i echoes_of_the_ashes_db psql -U user telegram_rpg < backup.sql
|
||||
```
|
||||
|
||||
### Logs
|
||||
```bash
|
||||
# Follow bot logs
|
||||
docker logs echoes_of_the_ashes_bot -f
|
||||
|
||||
# Database logs
|
||||
docker logs echoes_of_the_ashes_db -f
|
||||
```
|
||||
|
||||
## 🎲 Game Mechanics
|
||||
|
||||
### Outcome Probability
|
||||
- **Critical Failure**: Rare, negative effects
|
||||
- **Failure**: Common, no reward
|
||||
- **Success**: Common, standard rewards
|
||||
|
||||
Configured in `bot/logic.py`:
|
||||
```python
|
||||
def roll_outcome(action: Action):
|
||||
roll = random.random()
|
||||
if roll < 0.1: return "critical_failure"
|
||||
elif roll < 0.5: return "failure"
|
||||
else: return "success"
|
||||
```
|
||||
|
||||
### Stamina Regeneration
|
||||
- **Rate**: 1 stamina per 5 minutes
|
||||
- **Maximum**: Defined by player stats
|
||||
- **Automatic**: Background scheduler
|
||||
|
||||
### Cooldowns
|
||||
- **Per-Action**: Each action has independent cooldown
|
||||
- **Duration**: Configured per action (30-60 minutes typical)
|
||||
- **Storage**: Composite key `instance_id:action_id`
|
||||
|
||||
## 🚧 Future Plans
|
||||
|
||||
### Planned Features
|
||||
- [ ] Combat system
|
||||
- [ ] Crafting mechanics
|
||||
- [ ] Quest system
|
||||
- [ ] NPC interactions
|
||||
- [ ] Base building
|
||||
- [ ] Equipment slots
|
||||
- [ ] Status effects
|
||||
- [ ] Day/night cycle
|
||||
- [ ] Weather system
|
||||
- [ ] Trading economy
|
||||
|
||||
### Balance Improvements
|
||||
- [ ] Dynamic difficulty
|
||||
- [ ] Rare item spawns
|
||||
- [ ] Location-based dangers
|
||||
- [ ] Resource scarcity tuning
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please:
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Test thoroughly
|
||||
5. Submit a pull request
|
||||
|
||||
## 📝 License
|
||||
|
||||
This project is open source and available under the MIT License.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- Built with [python-telegram-bot](https://python-telegram-bot.org/)
|
||||
- Inspired by classic post-apocalyptic RPGs
|
||||
- Community feedback and testing
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues or questions:
|
||||
- Open a GitHub issue
|
||||
- Check the documentation in `docs/`
|
||||
- Review error logs with `docker logs`
|
||||
Echoes of the Ash is a **browser-based RPG** set in a dark, post-apocalyptic world. Inspired by classic MUD (Multi-User Dungeon) games, it combines text-driven exploration with visual elements, real-time combat, and survival mechanics.
|
||||
|
||||
---
|
||||
|
||||
**Current Version**: 1.1.0 (Expanded World Update)
|
||||
**Last Updated**: October 16, 2025
|
||||
**Status**: ✅ Active Development
|
||||
## 🌟 Current Game Features
|
||||
|
||||
### Core Systems
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| **Character System** | ✅ Complete | Create characters with 4 stats (Strength, Agility, Endurance, Intellect) |
|
||||
| **Health & Stamina** | ✅ Complete | HP/Stamina management with visual progress bars |
|
||||
| **Leveling & XP** | ✅ Complete | XP-based progression with stat point allocation |
|
||||
| **Inventory** | ✅ Complete | Weight/volume-based carrying capacity |
|
||||
| **Equipment** | ✅ Complete | Weapon, armor, and backpack slots |
|
||||
| **Combat (PvE)** | ✅ Complete | Turn-based combat with visual effects |
|
||||
| **Combat (PvP)** | ✅ Complete | Player vs Player combat system |
|
||||
| **Real-time Updates** | ✅ Complete | WebSocket-based live game state |
|
||||
|
||||
### Exploration & Interaction
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| **World Map** | ✅ Complete | Graph-based location system with connections |
|
||||
| **Movement** | ✅ Complete | Navigate between connected locations |
|
||||
| **Interactables** | ✅ Complete | Search containers, objects for loot |
|
||||
| **Enemy Spawning** | ✅ Complete | Static and wandering NPCs |
|
||||
| **Corpse Looting** | ✅ Complete | Loot fallen enemies and players |
|
||||
| **Dropped Items** | ✅ Complete | Pick up items on the ground |
|
||||
|
||||
### Crafting & Economy
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| **Workbench** | ✅ Complete | Craft, repair, and salvage items |
|
||||
| **Crafting System** | ✅ Complete | Create items from materials |
|
||||
| **Repair System** | ✅ Complete | Restore durability to equipment |
|
||||
| **Salvage System** | ✅ Complete | Break down items for materials |
|
||||
|
||||
### Social & Multiplayer
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| **Accounts** | ✅ Complete | Registration, login, JWT authentication |
|
||||
| **Multiple Characters** | ✅ Complete | Create up to 3 characters per account |
|
||||
| **Leaderboards** | ✅ Complete | Rankings by level, kills, XP |
|
||||
| **Player Profiles** | ✅ Complete | View player stats and equipment |
|
||||
| **Online Players** | ✅ Complete | See who's currently online |
|
||||
|
||||
### Platforms
|
||||
|
||||
| Platform | Status | Description |
|
||||
|----------|--------|-------------|
|
||||
| **Web Browser** | ✅ Complete | Play at any time via modern browser |
|
||||
| **PWA (Mobile)** | ✅ Complete | Install as app on mobile devices |
|
||||
| **Electron Desktop** | ✅ Complete | Standalone Windows/Mac/Linux app |
|
||||
| **Steam Integration** | 🔧 Setup | Steamworks SDK ready for deployment |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 What Can Players Do?
|
||||
|
||||
### Getting Started
|
||||
1. **Create an Account** - Register with username and password
|
||||
2. **Create a Character** - Name your survivor and choose starting stats
|
||||
3. **Enter the World** - Spawn at the starting location
|
||||
|
||||
### Gameplay Loop
|
||||
1. **Explore** - Move between connected locations to discover new areas
|
||||
2. **Scavenge** - Search containers, corpses, and interactables for supplies
|
||||
3. **Fight** - Engage hostile NPCs in turn-based combat
|
||||
4. **Craft** - Use workbenches to create, repair, or salvage items
|
||||
5. **Level Up** - Gain XP from combat and allocate stat points
|
||||
6. **Survive** - Manage HP, stamina, and inventory weight
|
||||
|
||||
### Combat
|
||||
- **Attack** enemies with equipped weapons
|
||||
- **Use Items** during battle (healing, buffs)
|
||||
- **Flee** when outmatched (success based on Agility)
|
||||
- **PvP** - Challenge other players in combat
|
||||
|
||||
### Character Progression
|
||||
- **4 Core Stats**: Strength, Agility, Endurance, Intellect
|
||||
- **Equipment**: Weapons, armor, backpacks
|
||||
- **Stat Points**: Earn 1 per level to customize your build
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Technical Stack
|
||||
|
||||
### Frontend (PWA)
|
||||
- **Framework**: React 18 + TypeScript
|
||||
- **Build Tool**: Vite
|
||||
- **State Management**: Zustand
|
||||
- **Real-time**: WebSocket connections
|
||||
- **Styling**: Custom CSS with dark theme
|
||||
|
||||
### Backend (API)
|
||||
- **Framework**: FastAPI (Python)
|
||||
- **Database**: SQLite (development) / PostgreSQL (production)
|
||||
- **Cache**: Redis for real-time state
|
||||
- **Auth**: JWT tokens
|
||||
|
||||
### Desktop (Electron)
|
||||
- **Framework**: Electron 28
|
||||
- **Steam SDK**: steamworks.js integration
|
||||
- **Builds**: Windows (NSIS, Portable), Linux (AppImage, DEB), macOS
|
||||
|
||||
---
|
||||
|
||||
## 📊 Asset Summary
|
||||
|
||||
| Category | Count | Size |
|
||||
|----------|-------|------|
|
||||
| Location Images | 14 | - |
|
||||
| Item Images | 40 | - |
|
||||
| NPC Images | 5 | - |
|
||||
| Interactable Images | 8 | - |
|
||||
| Icon Sets | 1 | - |
|
||||
| **Total Images** | **134 files** | **~79 MB** |
|
||||
| Sound Effects | 0 | 0 |
|
||||
| Music | 0 | 0 |
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
### In Progress
|
||||
- [ ] Sound effects and ambient music
|
||||
- [ ] Quest/mission system
|
||||
- [ ] NPC dialogue trees
|
||||
|
||||
### Planned Features
|
||||
- [ ] Crafting recipes expansion
|
||||
- [ ] Faction/reputation system
|
||||
- [ ] Player trading
|
||||
- [ ] Housing/storage
|
||||
- [ ] Skill tree system
|
||||
- [ ] Status effects (poison, bleeding, etc.)
|
||||
- [ ] Weather/day-night cycle
|
||||
- [ ] Achievements
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Running the Game
|
||||
|
||||
### Web/PWA (Docker)
|
||||
```bash
|
||||
docker compose up echoes_of_the_ashes_pwa echoes_of_the_ashes_api
|
||||
```
|
||||
|
||||
### Electron Development
|
||||
```bash
|
||||
cd pwa
|
||||
npm install
|
||||
npm run electron:dev
|
||||
```
|
||||
|
||||
### Build Electron Apps
|
||||
```bash
|
||||
npm run electron:build:win # Windows
|
||||
npm run electron:build:linux # Linux
|
||||
npm run electron:build:mac # macOS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Additional Documentation
|
||||
|
||||
- [Game Mechanics](docs/game/MECHANICS.md) - Detailed gameplay systems
|
||||
- [API Documentation](docs/api/) - Backend endpoints reference
|
||||
- [Development Guide](docs/development/) - Contributing and architecture
|
||||
- [Map Editor](web-map/README.md) - World building tools
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.0.0-alpha
|
||||
**Last Updated**: December 2025
|
||||
|
||||
@@ -6,6 +6,8 @@ import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from api.services.helpers import get_game_message
|
||||
from .services.constants import PVP_TURN_TIMEOUT
|
||||
import os
|
||||
import fcntl
|
||||
from typing import Dict, Optional
|
||||
@@ -135,18 +137,24 @@ async def spawn_manager_loop(manager=None):
|
||||
if manager:
|
||||
from datetime import datetime
|
||||
npc_def = NPCS.get(npc_id)
|
||||
npc_name = npc_def.name if npc_def else npc_id.replace('_', ' ').title()
|
||||
npc_name_obj = npc_def.name if npc_def else npc_id.replace('_', ' ').title()
|
||||
# Handle localized name for the fallback message
|
||||
if isinstance(npc_name_obj, dict):
|
||||
npc_name_en = npc_name_obj.get('en', str(npc_name_obj))
|
||||
else:
|
||||
npc_name_en = str(npc_name_obj)
|
||||
|
||||
await manager.send_to_location(
|
||||
location_id=location_id,
|
||||
message={
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"A {npc_name} appeared!",
|
||||
"message": f"A {npc_name_en} appeared!",
|
||||
"action": "enemy_spawned",
|
||||
"npc_data": {
|
||||
"id": enemy_data['id'],
|
||||
"npc_id": npc_id,
|
||||
"name": npc_name,
|
||||
"name": npc_name_obj,
|
||||
"type": "enemy",
|
||||
"is_wandering": True,
|
||||
"image_path": npc_def.image_path if npc_def else None
|
||||
@@ -209,7 +217,8 @@ async def decay_dropped_items(manager=None):
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{count} dropped item(s) decayed",
|
||||
"action": "items_decayed"
|
||||
"action": "items_decayed",
|
||||
"count": count
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
@@ -339,6 +348,118 @@ async def check_combat_timers():
|
||||
await asyncio.sleep(10)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BACKGROUND TASK: PVP COMBAT TIMERS
|
||||
# ============================================================================
|
||||
|
||||
async def check_pvp_combat_timers(manager=None):
|
||||
"""Checks for expired PvP combat turns and auto-advances them."""
|
||||
logger.info("⚔️ PvP Combat Timer task started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(30) # Check every 30 seconds
|
||||
|
||||
start_time = time.time()
|
||||
all_pvp_combats = await db.get_all_pvp_combats()
|
||||
|
||||
processed = 0
|
||||
for combat in all_pvp_combats:
|
||||
try:
|
||||
# Check if combat has already ended (fled or player dead)
|
||||
if combat.get('attacker_fled') or combat.get('defender_fled'):
|
||||
continue
|
||||
|
||||
# Get both players to check HP
|
||||
attacker = await db.get_player_by_id(combat['attacker_character_id'])
|
||||
defender = await db.get_player_by_id(combat['defender_character_id'])
|
||||
|
||||
if not attacker or not defender:
|
||||
# Player doesn't exist, clean up combat
|
||||
await db.end_pvp_combat(combat['id'])
|
||||
continue
|
||||
|
||||
# Check if combat ended (someone died)
|
||||
if attacker['hp'] <= 0 or defender['hp'] <= 0:
|
||||
continue
|
||||
|
||||
# Check if turn has timed out
|
||||
turn_timeout = combat.get('turn_timeout_seconds', PVP_TURN_TIMEOUT)
|
||||
# Use imported constant instead of hardcoded 300
|
||||
turn_started = combat.get('turn_started_at', time.time())
|
||||
time_elapsed = time.time() - turn_started
|
||||
|
||||
if time_elapsed < turn_timeout:
|
||||
continue # Turn hasn't timed out yet
|
||||
|
||||
# Turn has timed out - advance to other player
|
||||
current_turn = combat.get('turn', 'attacker')
|
||||
new_turn = 'defender' if current_turn == 'attacker' else 'attacker'
|
||||
|
||||
logger.info(f"PvP turn timeout: combat {combat['id']} advancing from {current_turn} to {new_turn}")
|
||||
|
||||
# Update combat with new turn
|
||||
await db.update_pvp_combat(combat['id'], {
|
||||
'turn': new_turn,
|
||||
'turn_started_at': time.time(),
|
||||
'last_action': f"turn_timeout:{current_turn}|{time.time()}"
|
||||
})
|
||||
|
||||
processed += 1
|
||||
|
||||
# Send WebSocket notifications to both players
|
||||
if manager:
|
||||
# Get updated combat data
|
||||
updated_combat = await db.get_pvp_combat_by_id(combat['id'])
|
||||
if updated_combat:
|
||||
# Calculate time remaining for new turn
|
||||
time_remaining = turn_timeout
|
||||
|
||||
# Build combat update payload
|
||||
combat_update = {
|
||||
"type": "combat_update",
|
||||
"data": {
|
||||
"pvp_combat": {
|
||||
"id": updated_combat['id'],
|
||||
"turn": new_turn,
|
||||
"time_remaining": time_remaining,
|
||||
"turn_timeout": "skipped",
|
||||
"last_action": f"turn_timeout:{current_turn}"
|
||||
},
|
||||
"is_pvp": True,
|
||||
"messages": [
|
||||
{
|
||||
"type": "combat_timeout",
|
||||
"origin": "system",
|
||||
"timestamp": time.time()
|
||||
}
|
||||
]
|
||||
},
|
||||
"timestamp": time.time()
|
||||
}
|
||||
|
||||
# Notify both players
|
||||
await manager.send_personal_message(
|
||||
combat['attacker_character_id'],
|
||||
combat_update
|
||||
)
|
||||
await manager.send_personal_message(
|
||||
combat['defender_character_id'],
|
||||
combat_update
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing PvP combat {combat.get('id')}: {e}")
|
||||
|
||||
if processed > 0:
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(f"Processed {processed} PvP combat timeouts in {elapsed:.2f}s")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in PvP combat timer check: {e}", exc_info=True)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BACKGROUND TASK: INTERACTABLE COOLDOWN CLEANUP
|
||||
# ============================================================================
|
||||
@@ -405,6 +526,8 @@ async def cleanup_interactable_cooldowns(manager=None, world_locations=None):
|
||||
"data": {
|
||||
"instance_id": cooldown_info['instance_id'],
|
||||
"action_id": cooldown_info['action_id'],
|
||||
"name": cooldown_info['name'],
|
||||
"action_name": cooldown_info['action_name'],
|
||||
"message": f"{cooldown_info['action_name']} is ready on {cooldown_info['name']}"
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
@@ -424,7 +547,7 @@ async def cleanup_interactable_cooldowns(manager=None, world_locations=None):
|
||||
# ============================================================================
|
||||
|
||||
async def decay_corpses(manager=None):
|
||||
"""Removes old corpses.
|
||||
"""Removes old corpses and empty corpses.
|
||||
|
||||
Args:
|
||||
manager: WebSocket ConnectionManager for broadcasting decay events
|
||||
@@ -438,6 +561,7 @@ async def decay_corpses(manager=None):
|
||||
start_time = time.time()
|
||||
logger.info("Running corpse decay...")
|
||||
|
||||
# ===== TIME-BASED DECAY =====
|
||||
# Player corpses decay after 24 hours
|
||||
player_corpse_limit = time.time() - (24 * 3600)
|
||||
expired_player_corpses = await db.get_expired_player_corpses(player_corpse_limit)
|
||||
@@ -448,6 +572,20 @@ async def decay_corpses(manager=None):
|
||||
expired_npc_corpses = await db.get_expired_npc_corpses(npc_corpse_limit)
|
||||
npc_corpses_removed = await db.remove_expired_npc_corpses(npc_corpse_limit)
|
||||
|
||||
# ===== EMPTY CORPSE DECAY =====
|
||||
# Empty corpses (no loot remaining) decay immediately
|
||||
empty_player_corpses = await db.get_empty_player_corpses()
|
||||
empty_player_removed = await db.remove_empty_player_corpses()
|
||||
|
||||
empty_npc_corpses = await db.get_empty_npc_corpses()
|
||||
empty_npc_removed = await db.remove_empty_npc_corpses()
|
||||
|
||||
# Combine all decayed corpses for notification
|
||||
all_decayed_player_corpses = expired_player_corpses + empty_player_corpses
|
||||
all_decayed_npc_corpses = expired_npc_corpses + empty_npc_corpses
|
||||
total_player_removed = player_corpses_removed + empty_player_removed
|
||||
total_npc_removed = npc_corpses_removed + empty_npc_removed
|
||||
|
||||
# Notify players in locations where corpses decayed
|
||||
if manager:
|
||||
from datetime import datetime
|
||||
@@ -456,10 +594,10 @@ async def decay_corpses(manager=None):
|
||||
# Group corpses by location
|
||||
corpses_by_location = defaultdict(lambda: {"player": 0, "npc": 0})
|
||||
|
||||
for corpse in expired_player_corpses:
|
||||
for corpse in all_decayed_player_corpses:
|
||||
corpses_by_location[corpse['location_id']]["player"] += 1
|
||||
|
||||
for corpse in expired_npc_corpses:
|
||||
for corpse in all_decayed_npc_corpses:
|
||||
corpses_by_location[corpse['location_id']]["npc"] += 1
|
||||
|
||||
# Notify each location
|
||||
@@ -472,15 +610,21 @@ async def decay_corpses(manager=None):
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{total} {corpse_type} decayed",
|
||||
"action": "corpses_decayed"
|
||||
"action": "corpses_decayed",
|
||||
"count": total
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if player_corpses_removed > 0 or npc_corpses_removed > 0:
|
||||
logger.info(f"Decayed {player_corpses_removed} player corpses and {npc_corpses_removed} NPC corpses in {elapsed:.2f}s")
|
||||
if total_player_removed > 0 or total_npc_removed > 0:
|
||||
logger.info(
|
||||
f"Decayed {total_player_removed} player corpses "
|
||||
f"({player_corpses_removed} expired, {empty_player_removed} empty) and "
|
||||
f"{total_npc_removed} NPC corpses "
|
||||
f"({npc_corpses_removed} expired, {empty_npc_removed} empty) in {elapsed:.2f}s"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in corpse decay: {e}", exc_info=True)
|
||||
@@ -503,7 +647,7 @@ async def process_status_effects(manager=None):
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(300) # Wait 5 minutes
|
||||
await asyncio.sleep(60) # Wait 1 minute (requested by user)
|
||||
|
||||
start_time = time.time()
|
||||
logger.info("Running status effects processor...")
|
||||
@@ -523,37 +667,53 @@ async def process_status_effects(manager=None):
|
||||
|
||||
for player_id in affected_players:
|
||||
try:
|
||||
# Get current status effects (after decrement)
|
||||
effects = await db.get_player_status_effects(player_id)
|
||||
# Get current status effects (after decrement), INCLUDING expired (0 ticks)
|
||||
effects = await db.get_player_status_effects(player_id, min_ticks=0)
|
||||
|
||||
if not effects:
|
||||
continue
|
||||
|
||||
# Calculate total damage
|
||||
from api.game_logic import calculate_status_damage
|
||||
total_damage = calculate_status_damage(effects)
|
||||
# Prepare detailed effects data for frontend
|
||||
effects_data = [
|
||||
{
|
||||
"name": e['effect_name'],
|
||||
"ticks_remaining": e['ticks_remaining'],
|
||||
"effect_icon": e.get('effect_icon')
|
||||
}
|
||||
for e in effects
|
||||
]
|
||||
|
||||
if total_damage > 0:
|
||||
damage_dealt += total_damage
|
||||
# Calculate total impact (positive = damage, negative = healing)
|
||||
from api.game_logic import calculate_status_impact
|
||||
total_impact = calculate_status_impact(effects)
|
||||
|
||||
if total_impact > 0:
|
||||
# DAMAGE LOGIC
|
||||
damage_dealt += total_impact
|
||||
player = await db.get_player_by_id(player_id)
|
||||
|
||||
if not player or player['is_dead']:
|
||||
continue
|
||||
|
||||
new_hp = max(0, player['hp'] - total_damage)
|
||||
new_hp = max(0, player['hp'] - total_impact)
|
||||
|
||||
# Check if player died from status effects
|
||||
if new_hp <= 0:
|
||||
await db.update_player(player_id, {'hp': 0, 'is_dead': True})
|
||||
await db.update_player(player_id, hp=0, is_dead=True)
|
||||
deaths += 1
|
||||
|
||||
# Create player corpse
|
||||
# Only create corpse if player has items
|
||||
inventory = await db.get_inventory(player_id)
|
||||
await db.create_player_corpse(
|
||||
player_name=player['name'],
|
||||
location_id=player['location_id'],
|
||||
items=inventory
|
||||
)
|
||||
if inventory:
|
||||
import json
|
||||
await db.create_player_corpse(
|
||||
player_name=player['name'],
|
||||
location_id=player['location_id'],
|
||||
items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
|
||||
)
|
||||
logger.info(f"Created corpse for player {player['name']} with {len(inventory)} items")
|
||||
else:
|
||||
logger.info(f"Player {player['name']} died (status effects) with no items, skipping corpse creation")
|
||||
|
||||
# Remove status effects from dead player
|
||||
await db.remove_all_status_effects(player_id)
|
||||
@@ -561,6 +721,7 @@ async def process_status_effects(manager=None):
|
||||
# Notify player of death
|
||||
if manager:
|
||||
from datetime import datetime
|
||||
locale = player.get('locale', 'en')
|
||||
await manager.send_personal_message(
|
||||
player_id,
|
||||
{
|
||||
@@ -568,7 +729,7 @@ async def process_status_effects(manager=None):
|
||||
"data": {
|
||||
"hp": 0,
|
||||
"is_dead": True,
|
||||
"message": "You died from status effects"
|
||||
"message": get_game_message('diedFromStatus', locale)
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
@@ -577,10 +738,11 @@ async def process_status_effects(manager=None):
|
||||
logger.info(f"Player {player['name']} (ID: {player_id}) died from status effects")
|
||||
else:
|
||||
# Apply damage and notify player
|
||||
await db.update_player(player_id, {'hp': new_hp})
|
||||
await db.update_player(player_id, hp=new_hp)
|
||||
|
||||
if manager:
|
||||
from datetime import datetime
|
||||
locale = player.get('locale', 'en')
|
||||
await manager.send_personal_message(
|
||||
player_id,
|
||||
{
|
||||
@@ -588,8 +750,44 @@ async def process_status_effects(manager=None):
|
||||
"data": {
|
||||
"hp": new_hp,
|
||||
"max_hp": player['max_hp'],
|
||||
"damage": total_damage,
|
||||
"message": f"You took {total_damage} damage from status effects"
|
||||
"damage": total_impact,
|
||||
"message": get_game_message('statusDamage', locale, damage=total_impact),
|
||||
"effects": effects_data
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
)
|
||||
elif total_impact < 0:
|
||||
# HEALING LOGIC
|
||||
heal_amount = abs(total_impact)
|
||||
player = await db.get_player_by_id(player_id)
|
||||
|
||||
if not player or player['is_dead']:
|
||||
continue
|
||||
|
||||
# Don't heal if already full
|
||||
if player['hp'] >= player['max_hp']:
|
||||
continue
|
||||
|
||||
new_hp = min(player['max_hp'], player['hp'] + heal_amount)
|
||||
real_heal = new_hp - player['hp']
|
||||
|
||||
if real_heal > 0:
|
||||
await db.update_player(player_id, hp=new_hp)
|
||||
|
||||
if manager:
|
||||
from datetime import datetime
|
||||
locale = player.get('locale', 'en')
|
||||
await manager.send_personal_message(
|
||||
player_id,
|
||||
{
|
||||
"type": "status_effect_heal",
|
||||
"data": {
|
||||
"hp": new_hp,
|
||||
"max_hp": player['max_hp'],
|
||||
"heal": real_heal,
|
||||
"message": get_game_message('statusHeal', locale, heal=real_heal),
|
||||
"effects": effects_data
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
@@ -598,10 +796,13 @@ async def process_status_effects(manager=None):
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing status effects for player {player_id}: {e}")
|
||||
|
||||
# CLEANUP: Remove expired effects now that we've notified the user
|
||||
await db.clean_expired_status_effects()
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
f"Processed status effects for {len(affected_players)} players "
|
||||
f"({damage_dealt} total damage, {deaths} deaths) in {elapsed:.3f}s"
|
||||
f"({damage_dealt} damage, {deaths} deaths) in {elapsed:.3f}s"
|
||||
)
|
||||
|
||||
# Warn if taking too long (potential scaling issue)
|
||||
@@ -618,7 +819,83 @@ async def process_status_effects(manager=None):
|
||||
logger.error(f"❌ Error in status effects task: {e}", exc_info=True)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
# ============================================================================
|
||||
# BACKGROUND TASK: MERCHANT RESTOCK
|
||||
# ============================================================================
|
||||
|
||||
async def restock_merchants(manager=None, npcs_data=None):
|
||||
"""Periodically restocks merchant inventory."""
|
||||
logger.info("💰 Merchant Restock task started")
|
||||
|
||||
# If no data provided, we can't restock effectively without doing I/O which we want to avoid.
|
||||
if not npcs_data:
|
||||
logger.warning("⚠️ No NPC data provided to restock task. Merchants will not restock.")
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Use injected data
|
||||
static_npcs = npcs_data
|
||||
|
||||
start_time = time.time()
|
||||
restocked_count = 0
|
||||
|
||||
for npc_id, npc_def in static_npcs.items():
|
||||
trade_cfg = npc_def.get('trade', {})
|
||||
if not trade_cfg.get('enabled'):
|
||||
continue
|
||||
|
||||
stock_config = trade_cfg.get('stock', [])
|
||||
|
||||
for item_cfg in stock_config:
|
||||
if item_cfg.get('infinite'):
|
||||
continue
|
||||
|
||||
item_id = item_cfg['item_id']
|
||||
max_stock = item_cfg.get('max_stock', 10)
|
||||
restock_rate = item_cfg.get('restock_rate', 1)
|
||||
|
||||
# Get current stock
|
||||
current_item = await db.get_merchant_stock_item(npc_id, item_id)
|
||||
|
||||
now = time.time()
|
||||
|
||||
if not current_item:
|
||||
# Initialize if missing
|
||||
# If we assume 'restocked' means it should exist.
|
||||
await db.update_merchant_stock(
|
||||
npc_id=npc_id,
|
||||
item_id=item_id,
|
||||
quantity=restock_rate,
|
||||
update_restock_time=True
|
||||
)
|
||||
restocked_count += 1
|
||||
continue
|
||||
|
||||
# Check timer (1 hour default)
|
||||
last_restock = current_item.get('last_restock_at', 0)
|
||||
if now - last_restock > 3600: # 1 hour
|
||||
current_qty = current_item['quantity']
|
||||
if current_qty < max_stock:
|
||||
new_qty = min(max_stock, current_qty + restock_rate)
|
||||
|
||||
await db.update_merchant_stock(
|
||||
npc_id=npc_id,
|
||||
item_id=item_id,
|
||||
quantity=new_qty,
|
||||
update_restock_time=True
|
||||
)
|
||||
restocked_count += 1
|
||||
|
||||
if restocked_count > 0:
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(f"Restocked {restocked_count} items in {elapsed:.2f}s")
|
||||
|
||||
await asyncio.sleep(600) # Check every 10 minutes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in merchant restock task: {e}", exc_info=True)
|
||||
await asyncio.sleep(60)
|
||||
# ============================================================================
|
||||
# TASK STARTUP FUNCTION
|
||||
# ============================================================================
|
||||
@@ -667,7 +944,7 @@ def release_background_tasks_lock():
|
||||
_lock_file_handle = None
|
||||
|
||||
|
||||
async def start_background_tasks(manager=None, world_locations=None):
|
||||
async def start_background_tasks(manager=None, world_locations=None, npcs_data=None):
|
||||
"""
|
||||
Start all background tasks.
|
||||
Called when the API starts up.
|
||||
@@ -676,6 +953,7 @@ async def start_background_tasks(manager=None, world_locations=None):
|
||||
Args:
|
||||
manager: WebSocket ConnectionManager for broadcasting events
|
||||
world_locations: Dict of Location objects for interactable mapping
|
||||
npcs_data: Dict of static NPC definitions
|
||||
"""
|
||||
# Try to acquire lock - only one worker will succeed
|
||||
if not acquire_background_tasks_lock():
|
||||
@@ -690,8 +968,10 @@ async def start_background_tasks(manager=None, world_locations=None):
|
||||
asyncio.create_task(decay_dropped_items(manager)),
|
||||
asyncio.create_task(regenerate_stamina(manager)),
|
||||
asyncio.create_task(check_combat_timers()),
|
||||
asyncio.create_task(check_pvp_combat_timers(manager)),
|
||||
asyncio.create_task(decay_corpses(manager)),
|
||||
asyncio.create_task(process_status_effects(manager)),
|
||||
asyncio.create_task(restock_merchants(manager, npcs_data)),
|
||||
# Note: Interactable cooldowns are handled client-side with server validation
|
||||
]
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(s
|
||||
detail="No character selected. Please select a character first."
|
||||
)
|
||||
|
||||
player = await db.get_player_by_id(character_id)
|
||||
player = await db.get_character_by_id(character_id)
|
||||
if player is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
WebSocket connection manager for real-time game updates.
|
||||
Handles WebSocket connections and Redis pub/sub for cross-worker communication.
|
||||
"""
|
||||
import uuid
|
||||
from typing import Dict, Optional, List
|
||||
from fastapi import WebSocket
|
||||
import logging
|
||||
@@ -86,9 +87,13 @@ class ConnectionManager:
|
||||
connections = self.active_connections[player_id]
|
||||
disconnected_sockets = []
|
||||
|
||||
# Inject unique message ID for tracing
|
||||
if "id" not in message:
|
||||
message["id"] = str(uuid.uuid4())
|
||||
|
||||
for websocket in connections:
|
||||
try:
|
||||
logger.debug(f"Sending {message.get('type')} to player {player_id}")
|
||||
logger.debug(f"Using WS: Sending msg {message['id']} type={message.get('type')} to player {player_id}")
|
||||
await websocket.send_json(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send message to player {player_id}: {e}")
|
||||
|
||||
809
api/database.py
@@ -13,6 +13,7 @@ from sqlalchemy import (
|
||||
import time
|
||||
import logging
|
||||
from . import items
|
||||
from .services.constants import PVP_TURN_TIMEOUT
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -194,7 +195,7 @@ pvp_combats = Table(
|
||||
Column("defender_character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("turn", String, nullable=False), # "attacker" or "defender"
|
||||
Column("turn_started_at", Float, nullable=False),
|
||||
Column("turn_timeout_seconds", Integer, default=300), # 5 minutes default
|
||||
Column("turn_timeout_seconds", Integer, default=PVP_TURN_TIMEOUT), # Default from constants
|
||||
Column("location_id", String, nullable=False),
|
||||
Column("created_at", Float, nullable=False),
|
||||
Column("attacker_fled", Boolean, default=False),
|
||||
@@ -261,8 +262,12 @@ player_status_effects = Table(
|
||||
Column("character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("effect_name", String(50), nullable=False),
|
||||
Column("effect_icon", String(10), nullable=False),
|
||||
Column("effect_type", String(20), default="damage"), # 'damage', 'buff', 'debuff'
|
||||
Column("damage_per_tick", Integer, nullable=False, default=0),
|
||||
Column("value", Integer, default=0), # Generic value (buff %, damage, etc.)
|
||||
Column("ticks_remaining", Integer, nullable=False),
|
||||
Column("persist_after_combat", Boolean, default=False), # Keep after combat ends
|
||||
Column("source", String(50), nullable=True), # 'item:molotov', 'action:defend'
|
||||
Column("applied_at", Float, nullable=False),
|
||||
)
|
||||
|
||||
@@ -303,6 +308,62 @@ player_statistics = Table(
|
||||
)
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# QUESTS AND TRADE TABLES
|
||||
# ========================================================================
|
||||
|
||||
# Quests: Character progress
|
||||
character_quests = Table(
|
||||
"character_quests",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("quest_id", String, nullable=False),
|
||||
Column("status", String(20), default="active"), # active, completed, failed
|
||||
Column("progress", JSON, default={}), # {"rat_kills": 1, "wood_delivered": 50}
|
||||
Column("started_at", Float, default=lambda: time.time()),
|
||||
Column("completed_at", Float, nullable=True),
|
||||
Column("last_completed_at", Float, nullable=True), # For repeatable quests
|
||||
Column("cooldown_expires_at", Float, nullable=True), # For repeatable quests
|
||||
Column("times_completed", Integer, default=0),
|
||||
UniqueConstraint("character_id", "quest_id", name="uix_char_quest")
|
||||
)
|
||||
|
||||
# Quests: Character History
|
||||
character_quest_history = Table(
|
||||
"character_quest_history",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("quest_id", String, nullable=False),
|
||||
Column("started_at", Float, nullable=False),
|
||||
Column("completed_at", Float, default=lambda: time.time()),
|
||||
Column("rewards", JSON, default={}),
|
||||
)
|
||||
|
||||
# Quests: Global progress
|
||||
global_quests = Table(
|
||||
"global_quests",
|
||||
metadata,
|
||||
Column("quest_id", String, primary_key=True),
|
||||
Column("global_progress", JSON, default={}),
|
||||
Column("is_completed", Boolean, default=False),
|
||||
Column("updated_at", Float, default=lambda: time.time()),
|
||||
)
|
||||
|
||||
# Trade: Merchant Stock
|
||||
merchant_stock = Table(
|
||||
"merchant_stock",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("npc_id", String, nullable=False),
|
||||
Column("item_id", String, nullable=False),
|
||||
Column("unique_item_id", Integer, ForeignKey("unique_items.id", ondelete="SET NULL"), nullable=True),
|
||||
Column("quantity", Integer, default=0),
|
||||
Column("last_restock_at", Float, default=0),
|
||||
)
|
||||
|
||||
|
||||
# Database session context manager
|
||||
class DatabaseSession:
|
||||
"""Context manager for database sessions"""
|
||||
@@ -352,21 +413,22 @@ async def init_db():
|
||||
|
||||
# Interactable cooldowns - checked on interact attempts
|
||||
"CREATE INDEX IF NOT EXISTS idx_interactable_cooldowns_instance ON interactable_cooldowns(interactable_instance_id);",
|
||||
|
||||
# Quests
|
||||
"CREATE INDEX IF NOT EXISTS idx_character_quests_char ON character_quests(character_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_character_quests_status ON character_quests(status);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_character_quest_history_char ON character_quest_history(character_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_character_quest_history_completed ON character_quest_history(completed_at);",
|
||||
|
||||
# Merchant Stock
|
||||
"CREATE INDEX IF NOT EXISTS idx_merchant_stock_npc ON merchant_stock(npc_id);",
|
||||
]
|
||||
|
||||
for index_sql in indexes:
|
||||
await conn.execute(text(index_sql))
|
||||
|
||||
|
||||
# Player operations
|
||||
async def get_player_by_id(player_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get player by internal ID"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(
|
||||
select(players).where(players.c.id == player_id)
|
||||
)
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
|
||||
async def get_player_by_username(username: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -421,13 +483,7 @@ async def create_player(
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
async def update_player(player_id: int, **kwargs) -> bool:
|
||||
"""Update player fields - OLD FUNCTION, use update_character instead"""
|
||||
async with DatabaseSession() as session:
|
||||
stmt = update(characters).where(characters.c.id == player_id).values(**kwargs)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
|
||||
async def update_player_location(player_id: int, location_id: str) -> bool:
|
||||
@@ -701,6 +757,355 @@ async def can_create_character(account_id: int) -> tuple[bool, str]:
|
||||
# ========================================================================
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# QUEST OPERATIONS
|
||||
# ========================================================================
|
||||
|
||||
async def get_character_quests(character_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all quests for a character"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(
|
||||
select(character_quests)
|
||||
.where(character_quests.c.character_id == character_id)
|
||||
.order_by(character_quests.c.started_at.desc())
|
||||
)
|
||||
rows = result.fetchall()
|
||||
return [dict(row._mapping) for row in rows]
|
||||
|
||||
|
||||
async def get_character_quest(character_id: int, quest_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific quest for a character"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(
|
||||
select(character_quests).where(
|
||||
and_(
|
||||
character_quests.c.character_id == character_id,
|
||||
character_quests.c.quest_id == quest_id
|
||||
)
|
||||
)
|
||||
)
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
async def accept_quest(character_id: int, quest_id: str) -> Dict[str, Any]:
|
||||
"""Accept a new quest or restart a repeatable one"""
|
||||
# Check if exists first to handle restarts
|
||||
existing = await get_character_quest(character_id, quest_id)
|
||||
|
||||
async with DatabaseSession() as session:
|
||||
if existing:
|
||||
# Check if repeatable and cooldown passed
|
||||
# Validation should happen in logic layer, but good to be safe here
|
||||
stmt = update(character_quests).where(
|
||||
character_quests.c.id == existing['id']
|
||||
).values(
|
||||
status="active",
|
||||
progress={},
|
||||
started_at=time.time(),
|
||||
completed_at=None,
|
||||
# Preserve statistics
|
||||
).returning(character_quests)
|
||||
else:
|
||||
stmt = insert(character_quests).values(
|
||||
character_id=character_id,
|
||||
quest_id=quest_id,
|
||||
status="active",
|
||||
progress={},
|
||||
started_at=time.time(),
|
||||
times_completed=0
|
||||
).returning(character_quests)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
row = result.first()
|
||||
await session.commit()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
async def delete_character_quest(character_id: int, quest_id: str) -> bool:
|
||||
"""Delete a character quest (used when completing or abandoning)"""
|
||||
async with DatabaseSession() as session:
|
||||
stmt = delete(character_quests).where(
|
||||
and_(
|
||||
character_quests.c.character_id == character_id,
|
||||
character_quests.c.quest_id == quest_id
|
||||
)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def update_quest_progress(character_id: int, quest_id: str, progress: Dict, status: str = "active") -> bool:
|
||||
"""Update quest progress"""
|
||||
async with DatabaseSession() as session:
|
||||
# Check if we need to update timestamp
|
||||
values = {
|
||||
"progress": progress,
|
||||
"status": status
|
||||
}
|
||||
|
||||
if status == "completed":
|
||||
values["completed_at"] = time.time()
|
||||
values["last_completed_at"] = time.time()
|
||||
# Increment times_completed
|
||||
# We need to do this carefully atomically or just fetch-update
|
||||
# Doing fetch-update for simplicity as we are inside transaction block if we used one,
|
||||
# but DatabaseSession is per-call here.
|
||||
|
||||
# Using specific update to increment
|
||||
stmt = update(character_quests).where(
|
||||
and_(
|
||||
character_quests.c.character_id == character_id,
|
||||
character_quests.c.quest_id == quest_id
|
||||
)
|
||||
).values(**values)
|
||||
|
||||
# Also increment times_completed separately to avoid overwrite race with simple values
|
||||
stmt2 = update(character_quests).where(
|
||||
and_(
|
||||
character_quests.c.character_id == character_id,
|
||||
character_quests.c.quest_id == quest_id
|
||||
)
|
||||
).values(times_completed=character_quests.c.times_completed + 1)
|
||||
|
||||
await session.execute(stmt)
|
||||
await session.execute(stmt2)
|
||||
else:
|
||||
stmt = update(character_quests).where(
|
||||
and_(
|
||||
character_quests.c.character_id == character_id,
|
||||
character_quests.c.quest_id == quest_id
|
||||
)
|
||||
).values(**values)
|
||||
await session.execute(stmt)
|
||||
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def set_quest_cooldown(character_id: int, quest_id: str, expires_at: float) -> bool:
|
||||
"""Set cooldown for a repeatable quest"""
|
||||
async with DatabaseSession() as session:
|
||||
stmt = update(character_quests).where(
|
||||
and_(
|
||||
character_quests.c.character_id == character_id,
|
||||
character_quests.c.quest_id == quest_id
|
||||
)
|
||||
).values(cooldown_expires_at=expires_at)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def log_quest_completion(character_id: int, quest_id: str, started_at: float, rewards: Dict) -> bool:
|
||||
"""Log a quest completion to history"""
|
||||
async with DatabaseSession() as session:
|
||||
stmt = insert(character_quest_history).values(
|
||||
character_id=character_id,
|
||||
quest_id=quest_id,
|
||||
started_at=started_at,
|
||||
completed_at=time.time(),
|
||||
rewards=rewards
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def get_quest_history(character_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
|
||||
"""Get quest history with pagination"""
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
async with DatabaseSession() as session:
|
||||
# Get total count
|
||||
count_stmt = select(character_quest_history.c.id).where(
|
||||
character_quest_history.c.character_id == character_id
|
||||
)
|
||||
count_result = await session.execute(count_stmt)
|
||||
total_count = len(count_result.fetchall())
|
||||
|
||||
# Get paged results
|
||||
stmt = select(character_quest_history).where(
|
||||
character_quest_history.c.character_id == character_id
|
||||
).order_by(
|
||||
character_quest_history.c.completed_at.desc()
|
||||
).offset(offset).limit(page_size)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
rows = result.fetchall()
|
||||
data = [dict(row._mapping) for row in rows]
|
||||
|
||||
return {
|
||||
"data": data,
|
||||
"total": total_count,
|
||||
"page": page,
|
||||
"pages": (total_count + page_size - 1) // page_size
|
||||
}
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# GLOBAL QUEST OPERATIONS
|
||||
# ========================================================================
|
||||
|
||||
async def get_global_quest(quest_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get global quest progress"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(
|
||||
select(global_quests).where(global_quests.c.quest_id == quest_id)
|
||||
)
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
async def update_global_quest(quest_id: str, progress: Dict) -> bool:
|
||||
"""Update global quest progress"""
|
||||
async with DatabaseSession() as session:
|
||||
# Upsert
|
||||
existing = await session.execute(
|
||||
select(global_quests).where(global_quests.c.quest_id == quest_id)
|
||||
)
|
||||
if existing.first():
|
||||
stmt = update(global_quests).where(
|
||||
global_quests.c.quest_id == quest_id
|
||||
).values(
|
||||
global_progress=progress,
|
||||
updated_at=time.time()
|
||||
)
|
||||
else:
|
||||
stmt = insert(global_quests).values(
|
||||
quest_id=quest_id,
|
||||
global_progress=progress,
|
||||
updated_at=time.time()
|
||||
)
|
||||
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def get_completed_global_quests() -> List[str]:
|
||||
"""Get list of IDs of all completed global quests"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(
|
||||
select(global_quests.c.quest_id).where(global_quests.c.is_completed == True)
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def mark_global_quest_completed(quest_id: str) -> bool:
|
||||
"""Mark a global quest as completed"""
|
||||
async with DatabaseSession() as session:
|
||||
stmt = update(global_quests).where(
|
||||
global_quests.c.quest_id == quest_id
|
||||
).values(
|
||||
is_completed=True,
|
||||
updated_at=time.time()
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def get_all_quest_participants(quest_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all characters who have this quest active or completed"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(
|
||||
select(character_quests).where(character_quests.c.quest_id == quest_id)
|
||||
)
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
# MERCHANT OPERATIONS
|
||||
# ========================================================================
|
||||
|
||||
async def get_merchant_stock(npc_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get stock for a merchant"""
|
||||
async with DatabaseSession() as session:
|
||||
# Join with unique_items to get stats if applicable
|
||||
# This is a bit complex, let's just get the stock and helper can resolve details
|
||||
result = await session.execute(
|
||||
select(merchant_stock).where(merchant_stock.c.npc_id == npc_id)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
return [dict(row._mapping) for row in rows]
|
||||
|
||||
|
||||
async def update_merchant_stock(npc_id: str, item_id: str, quantity: int, unique_item_id: Optional[int] = None, update_restock_time: bool = False) -> bool:
|
||||
"""
|
||||
Update merchant stock quantity.
|
||||
If unique_item_id is provided, it targets that specific instance.
|
||||
If quantity <= 0, remove the row.
|
||||
If update_restock_time is True, updates last_restock_at to now.
|
||||
"""
|
||||
async with DatabaseSession() as session:
|
||||
# Check if exists
|
||||
conditions = [
|
||||
merchant_stock.c.npc_id == npc_id,
|
||||
merchant_stock.c.item_id == item_id
|
||||
]
|
||||
if unique_item_id is not None:
|
||||
conditions.append(merchant_stock.c.unique_item_id == unique_item_id)
|
||||
else:
|
||||
conditions.append(merchant_stock.c.unique_item_id.is_(None))
|
||||
|
||||
stmt = select(merchant_stock).where(and_(*conditions))
|
||||
result = await session.execute(stmt)
|
||||
existing = result.first()
|
||||
|
||||
if quantity <= 0:
|
||||
if existing:
|
||||
await session.execute(delete(merchant_stock).where(merchant_stock.c.id == existing.id))
|
||||
else:
|
||||
if existing:
|
||||
values = {"quantity": quantity}
|
||||
if update_restock_time:
|
||||
values["last_restock_at"] = time.time()
|
||||
|
||||
await session.execute(
|
||||
update(merchant_stock)
|
||||
.where(merchant_stock.c.id == existing.id)
|
||||
.values(**values)
|
||||
)
|
||||
else:
|
||||
await session.execute(
|
||||
insert(merchant_stock).values(
|
||||
npc_id=npc_id,
|
||||
item_id=item_id,
|
||||
unique_item_id=unique_item_id,
|
||||
quantity=quantity,
|
||||
last_restock_at=time.time()
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def get_merchant_stock_item(npc_id: str, item_id: str, unique_item_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get specific item from merchant stock"""
|
||||
async with DatabaseSession() as session:
|
||||
conditions = [
|
||||
merchant_stock.c.npc_id == npc_id,
|
||||
merchant_stock.c.item_id == item_id
|
||||
]
|
||||
if unique_item_id is not None:
|
||||
conditions.append(merchant_stock.c.unique_item_id == unique_item_id)
|
||||
else:
|
||||
conditions.append(merchant_stock.c.unique_item_id.is_(None))
|
||||
|
||||
result = await session.execute(select(merchant_stock).where(and_(*conditions)))
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
async def get_all_merchants() -> List[str]:
|
||||
"""Get list of all NPC IDs that have stock"""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(select(merchant_stock.c.npc_id).distinct())
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
|
||||
|
||||
# Inventory operations
|
||||
# NOTE: Functions below use 'player_id' parameter name for backward compatibility
|
||||
@@ -887,13 +1292,13 @@ async def end_combat(player_id: int) -> bool:
|
||||
|
||||
|
||||
# PvP Combat Functions
|
||||
async def create_pvp_combat(attacker_id: int, defender_id: int, location_id: str, turn_timeout: int = 300) -> dict:
|
||||
"""Create a new PvP combat. First turn goes to defender."""
|
||||
async def create_pvp_combat(attacker_id: int, defender_id: int, location_id: str, turn_timeout: int = PVP_TURN_TIMEOUT) -> dict:
|
||||
"""Create a new PvP combat. First turn goes to attacker."""
|
||||
async with DatabaseSession() as session:
|
||||
stmt = insert(pvp_combats).values(
|
||||
attacker_character_id=attacker_id,
|
||||
defender_character_id=defender_id,
|
||||
turn='defender', # Defender goes first
|
||||
turn='attacker', # Attacker goes first
|
||||
turn_started_at=time.time(),
|
||||
turn_timeout_seconds=turn_timeout,
|
||||
location_id=location_id,
|
||||
@@ -1984,22 +2389,158 @@ async def remove_expired_npc_corpses(timestamp_limit: float) -> int:
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def get_empty_player_corpses() -> List[Dict[str, Any]]:
|
||||
"""Get player corpses with no items remaining."""
|
||||
async with DatabaseSession() as session:
|
||||
stmt = select(player_corpses).where(
|
||||
or_(
|
||||
player_corpses.c.items == '[]',
|
||||
player_corpses.c.items == ''
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
|
||||
async def get_empty_npc_corpses() -> List[Dict[str, Any]]:
|
||||
"""Get NPC corpses with no loot remaining."""
|
||||
async with DatabaseSession() as session:
|
||||
stmt = select(npc_corpses).where(
|
||||
or_(
|
||||
npc_corpses.c.loot_remaining == '[]',
|
||||
npc_corpses.c.loot_remaining == ''
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
|
||||
async def remove_empty_player_corpses() -> int:
|
||||
"""Remove player corpses with no items remaining."""
|
||||
async with DatabaseSession() as session:
|
||||
stmt = delete(player_corpses).where(
|
||||
or_(
|
||||
player_corpses.c.items == '[]',
|
||||
player_corpses.c.items == ''
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def remove_empty_npc_corpses() -> int:
|
||||
"""Remove NPC corpses with no loot remaining."""
|
||||
async with DatabaseSession() as session:
|
||||
stmt = delete(npc_corpses).where(
|
||||
or_(
|
||||
npc_corpses.c.loot_remaining == '[]',
|
||||
npc_corpses.c.loot_remaining == ''
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
return result.rowcount
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STATUS EFFECTS FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
async def get_player_status_effects(player_id: int):
|
||||
async def add_effect(
|
||||
player_id: int,
|
||||
effect_name: str,
|
||||
effect_icon: str,
|
||||
ticks_remaining: int,
|
||||
effect_type: str = "damage",
|
||||
damage_per_tick: int = 0,
|
||||
value: int = 0,
|
||||
persist_after_combat: bool = False,
|
||||
source: str = None
|
||||
) -> int:
|
||||
"""
|
||||
Add a status effect to a player.
|
||||
If the effect already exists, it refreshes the duration (ticks_remaining).
|
||||
Returns the effect ID.
|
||||
"""
|
||||
async with DatabaseSession() as session:
|
||||
# Check if effect already exists
|
||||
result = await session.execute(
|
||||
select(player_status_effects).where(
|
||||
and_(
|
||||
player_status_effects.c.character_id == player_id,
|
||||
player_status_effects.c.effect_name == effect_name
|
||||
)
|
||||
)
|
||||
)
|
||||
existing_effect = result.first()
|
||||
|
||||
if existing_effect:
|
||||
# Refresh duration
|
||||
await session.execute(
|
||||
update(player_status_effects).where(
|
||||
player_status_effects.c.id == existing_effect.id
|
||||
).values(
|
||||
ticks_remaining=ticks_remaining,
|
||||
applied_at=time.time()
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return existing_effect.id
|
||||
else:
|
||||
# Insert new effect
|
||||
stmt = insert(player_status_effects).values(
|
||||
character_id=player_id,
|
||||
effect_name=effect_name,
|
||||
effect_icon=effect_icon,
|
||||
effect_type=effect_type,
|
||||
damage_per_tick=damage_per_tick,
|
||||
value=value,
|
||||
ticks_remaining=ticks_remaining,
|
||||
persist_after_combat=persist_after_combat,
|
||||
source=source,
|
||||
applied_at=time.time()
|
||||
).returning(player_status_effects.c.id)
|
||||
result = await session.execute(stmt)
|
||||
row = result.first()
|
||||
await session.commit()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
async def get_player_effects(player_id: int, min_ticks: int = 1) -> List[Dict[str, Any]]:
|
||||
"""Get all active status effects for a player."""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(
|
||||
select(player_status_effects).where(
|
||||
and_(
|
||||
player_status_effects.c.character_id == player_id,
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
player_status_effects.c.ticks_remaining >= min_ticks
|
||||
)
|
||||
)
|
||||
)
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
|
||||
# Alias for backward compatibility
|
||||
async def get_player_status_effects(player_id: int, min_ticks: int = 1):
|
||||
"""Alias for get_player_effects for backward compatibility."""
|
||||
return await get_player_effects(player_id, min_ticks)
|
||||
|
||||
|
||||
async def remove_effect(player_id: int, effect_name: str) -> bool:
|
||||
"""Remove a specific effect from a player by name."""
|
||||
async with DatabaseSession() as session:
|
||||
await session.execute(
|
||||
delete(player_status_effects).where(
|
||||
and_(
|
||||
player_status_effects.c.character_id == player_id,
|
||||
player_status_effects.c.effect_name == effect_name
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def remove_all_status_effects(player_id: int):
|
||||
@@ -2010,36 +2551,141 @@ async def remove_all_status_effects(player_id: int):
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def decrement_all_status_effect_ticks():
|
||||
"""
|
||||
Decrement ticks for all active status effects and return affected player IDs.
|
||||
Used by background processor.
|
||||
"""
|
||||
async def clean_expired_status_effects():
|
||||
"""Remove all status effects with <= 0 ticks."""
|
||||
async with DatabaseSession() as session:
|
||||
# Get player IDs with effects before updating
|
||||
from sqlalchemy import distinct
|
||||
result = await session.execute(
|
||||
select(distinct(player_status_effects.c.character_id)).where(
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
await session.execute(
|
||||
delete(player_status_effects).where(player_status_effects.c.ticks_remaining <= 0)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def remove_non_persistent_effects(player_id: int):
|
||||
"""Remove effects where persist_after_combat is False. Called when combat ends."""
|
||||
async with DatabaseSession() as session:
|
||||
await session.execute(
|
||||
delete(player_status_effects).where(
|
||||
and_(
|
||||
player_status_effects.c.character_id == player_id,
|
||||
player_status_effects.c.persist_after_combat == False
|
||||
)
|
||||
)
|
||||
)
|
||||
affected_players = [row[0] for row in result.fetchall()]
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def tick_player_effects(player_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Decrement ticks and return effects that were applied this tick.
|
||||
Used during combat when player receives a turn.
|
||||
Returns list of effects with their current state (before tick was applied).
|
||||
"""
|
||||
async with DatabaseSession() as session:
|
||||
# Get effects before decrementing
|
||||
result = await session.execute(
|
||||
select(player_status_effects).where(
|
||||
and_(
|
||||
player_status_effects.c.character_id == player_id,
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
)
|
||||
)
|
||||
)
|
||||
effects = [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
if not effects:
|
||||
return []
|
||||
|
||||
# Decrement ticks
|
||||
await session.execute(
|
||||
update(player_status_effects).where(
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
and_(
|
||||
player_status_effects.c.character_id == player_id,
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
)
|
||||
).values(ticks_remaining=player_status_effects.c.ticks_remaining - 1)
|
||||
)
|
||||
|
||||
# Remove expired effects
|
||||
await session.execute(
|
||||
delete(player_status_effects).where(player_status_effects.c.ticks_remaining <= 0)
|
||||
delete(player_status_effects).where(
|
||||
and_(
|
||||
player_status_effects.c.character_id == player_id,
|
||||
player_status_effects.c.ticks_remaining <= 0
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
return affected_players
|
||||
return effects
|
||||
|
||||
|
||||
async def decrement_all_status_effect_ticks():
|
||||
"""
|
||||
Decrement ticks for all active status effects and return affected player IDs.
|
||||
Used by background processor. Only processes players NOT in combat.
|
||||
"""
|
||||
async with DatabaseSession() as session:
|
||||
from sqlalchemy import distinct
|
||||
|
||||
# Get all players with active effects
|
||||
result = await session.execute(
|
||||
select(distinct(player_status_effects.c.character_id)).where(
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
)
|
||||
)
|
||||
all_players = [row[0] for row in result.fetchall()]
|
||||
|
||||
# Filter out players in combat - they process effects on turn
|
||||
players_to_process = []
|
||||
for pid in all_players:
|
||||
if not await is_player_in_combat(pid):
|
||||
players_to_process.append(pid)
|
||||
|
||||
if not players_to_process:
|
||||
return []
|
||||
|
||||
# Decrement ticks only for players not in combat
|
||||
for pid in players_to_process:
|
||||
await session.execute(
|
||||
update(player_status_effects).where(
|
||||
and_(
|
||||
player_status_effects.c.character_id == pid,
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
)
|
||||
).values(ticks_remaining=player_status_effects.c.ticks_remaining - 1)
|
||||
)
|
||||
|
||||
# NOTE: We do NOT remove expired effects here anymore.
|
||||
# They will be processed by the background task (to apply final tick)
|
||||
# and then cleaned up via clean_expired_status_effects()
|
||||
|
||||
await session.commit()
|
||||
return players_to_process
|
||||
|
||||
|
||||
async def is_player_in_combat(player_id: int) -> bool:
|
||||
"""Check if player is in any active combat (PvE or PvP)."""
|
||||
async with DatabaseSession() as session:
|
||||
# Check PvE combat
|
||||
pve = await session.execute(
|
||||
select(active_combats.c.id).where(active_combats.c.character_id == player_id)
|
||||
)
|
||||
if pve.first():
|
||||
return True
|
||||
|
||||
# Check PvP combat
|
||||
pvp = await session.execute(
|
||||
select(pvp_combats.c.id).where(
|
||||
or_(
|
||||
pvp_combats.c.attacker_character_id == player_id,
|
||||
pvp_combats.c.defender_character_id == player_id
|
||||
)
|
||||
)
|
||||
)
|
||||
if pvp.first():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -2204,3 +2850,90 @@ async def remove_expired_dropped_items(timestamp_limit: float) -> int:
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
return result.rowcount
|
||||
|
||||
# ============================================================================
|
||||
# PVP COMBAT FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
async def get_pvp_combat_by_id(combat_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get PVP combat by ID."""
|
||||
async with DatabaseSession() as session:
|
||||
stmt = select(pvp_combats).where(pvp_combats.c.id == combat_id)
|
||||
result = await session.execute(stmt)
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
async def get_pvp_combat_by_player(character_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get active PVP combat for a player (either as attacker or defender)."""
|
||||
async with DatabaseSession() as session:
|
||||
stmt = select(pvp_combats).where(
|
||||
and_(
|
||||
or_(
|
||||
pvp_combats.c.attacker_character_id == character_id,
|
||||
pvp_combats.c.defender_character_id == character_id
|
||||
),
|
||||
# If acknowledged by both, it's effectively over for query purposes
|
||||
# But here we want the active one.
|
||||
# Logic: If I am attacker, and I haven't acknowledged => active
|
||||
# If I am defender, and I haven't acknowledged => active
|
||||
# Simplified: Just return the record, caller handles logic.
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
# There should only be one active combat at a time per player
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
# Note: create_pvp_combat is defined above at line ~876, not duplicated here
|
||||
|
||||
|
||||
async def update_pvp_combat(combat_id: int, updates: Dict[str, Any]) -> bool:
|
||||
"""Update PVP combat state."""
|
||||
# Don't add updated_at - column doesn't exist in table
|
||||
async with DatabaseSession() as session:
|
||||
stmt = update(pvp_combats).where(
|
||||
pvp_combats.c.id == combat_id
|
||||
).values(**updates)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def acknowledge_pvp_combat(combat_id: int, player_id: int) -> bool:
|
||||
"""Player acknowledges combat end."""
|
||||
async with DatabaseSession() as session:
|
||||
# First check who this player is
|
||||
stmt = select(pvp_combats).where(pvp_combats.c.id == combat_id)
|
||||
result = await session.execute(stmt)
|
||||
combat = result.first()
|
||||
|
||||
if not combat:
|
||||
return False
|
||||
|
||||
updates = {}
|
||||
if combat.attacker_character_id == player_id:
|
||||
updates['attacker_acknowledged'] = True
|
||||
elif combat.defender_character_id == player_id:
|
||||
updates['defender_acknowledged'] = True
|
||||
else:
|
||||
return False
|
||||
|
||||
stmt = update(pvp_combats).where(
|
||||
pvp_combats.c.id == combat_id
|
||||
).values(**updates)
|
||||
|
||||
await session.execute(stmt)
|
||||
|
||||
# Check if both acknowledged, then delete?
|
||||
# Or just keep it. We have acknowledge flags.
|
||||
# If both acknowledged, maybe delete to clean up?
|
||||
# Let's check updated flags
|
||||
if (updates.get('attacker_acknowledged') or combat.attacker_acknowledged) and \
|
||||
(updates.get('defender_acknowledged') or combat.defender_acknowledged):
|
||||
delete_stmt = delete(pvp_combats).where(pvp_combats.c.id == combat_id)
|
||||
await session.execute(delete_stmt)
|
||||
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
@@ -6,14 +6,15 @@ import random
|
||||
import time
|
||||
from typing import Dict, Any, Tuple, Optional, List
|
||||
from . import database as db
|
||||
from .services.helpers import get_locale_string, translate_travel_message, create_combat_message, get_game_message
|
||||
|
||||
|
||||
async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[bool, str, Optional[str], int, int]:
|
||||
async def move_player(player_id: int, direction: str, locations: Dict, locale: str = 'en') -> Tuple[bool, str, Optional[str], int, int]:
|
||||
"""
|
||||
Move player in a direction.
|
||||
Returns: (success, message, new_location_id, stamina_cost, distance_meters)
|
||||
"""
|
||||
player = await db.get_player_by_id(player_id)
|
||||
player = await db.get_character_by_id(player_id)
|
||||
if not player:
|
||||
return False, "Player not found", None, 0, 0
|
||||
|
||||
@@ -66,19 +67,21 @@ async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[
|
||||
|
||||
# Check stamina
|
||||
if player['stamina'] < stamina_cost:
|
||||
return False, "You're too exhausted to move. Wait for your stamina to regenerate.", None, 0, 0
|
||||
return False, get_game_message('exhausted_move', locale), None, 0, 0
|
||||
|
||||
# Update player location and stamina
|
||||
await db.update_player(
|
||||
await db.update_character(
|
||||
player_id,
|
||||
location_id=new_location_id,
|
||||
stamina=max(0, player['stamina'] - stamina_cost)
|
||||
)
|
||||
|
||||
return True, f"You travel {direction} to {new_location.name}.", new_location_id, stamina_cost, distance
|
||||
translated_location = get_locale_string(new_location.name, locale)
|
||||
travel_message = translate_travel_message(direction, translated_location, locale)
|
||||
return True, travel_message, new_location_id, stamina_cost, distance
|
||||
|
||||
|
||||
async def inspect_area(player_id: int, location, interactables_data: Dict) -> str:
|
||||
async def inspect_area(player_id: int, location, interactables_data: Dict, locale: str = 'en') -> str:
|
||||
"""
|
||||
Inspect the current area and return detailed information.
|
||||
Returns formatted text with interactables and their actions.
|
||||
@@ -89,18 +92,18 @@ async def inspect_area(player_id: int, location, interactables_data: Dict) -> st
|
||||
|
||||
# Check if player has enough stamina
|
||||
if player['stamina'] < 1:
|
||||
return "You're too exhausted to inspect the area thoroughly. Wait for your stamina to regenerate."
|
||||
return get_game_message('exhausted_inspect', locale)
|
||||
|
||||
# Deduct stamina
|
||||
await db.update_player_stamina(player_id, player['stamina'] - 1)
|
||||
|
||||
# Build inspection message
|
||||
lines = [f"🔍 **Inspecting {location.name}**\n"]
|
||||
lines = [get_game_message('inspecting_title', locale, name=location.name)]
|
||||
lines.append(location.description)
|
||||
lines.append("")
|
||||
|
||||
if location.interactables:
|
||||
lines.append("**Interactables:**")
|
||||
lines.append(get_game_message('interactables_title', locale))
|
||||
for interactable in location.interactables:
|
||||
lines.append(f"• **{interactable.name}**")
|
||||
if interactable.actions:
|
||||
@@ -109,13 +112,13 @@ async def inspect_area(player_id: int, location, interactables_data: Dict) -> st
|
||||
lines.append("")
|
||||
|
||||
if location.npcs:
|
||||
lines.append(f"**NPCs:** {', '.join(location.npcs)}")
|
||||
lines.append(f"{get_game_message('npcs_title', locale)} {', '.join(location.npcs)}")
|
||||
lines.append("")
|
||||
|
||||
# Check for dropped items
|
||||
dropped_items = await db.get_dropped_items(location.id)
|
||||
if dropped_items:
|
||||
lines.append("**Items on ground:**")
|
||||
lines.append(get_game_message('items_ground_title', locale))
|
||||
for item in dropped_items:
|
||||
lines.append(f"• {item['item_id']} x{item['quantity']}")
|
||||
|
||||
@@ -127,7 +130,8 @@ async def interact_with_object(
|
||||
interactable_id: str,
|
||||
action_id: str,
|
||||
location,
|
||||
items_manager
|
||||
items_manager,
|
||||
locale: str = 'en'
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Interact with an object using a specific action.
|
||||
@@ -145,7 +149,7 @@ async def interact_with_object(
|
||||
break
|
||||
|
||||
if not interactable:
|
||||
return {"success": False, "message": "Object not found"}
|
||||
return {"success": False, "message": get_game_message('object_not_found', locale)}
|
||||
|
||||
# Find the action
|
||||
action = None
|
||||
@@ -155,13 +159,13 @@ async def interact_with_object(
|
||||
break
|
||||
|
||||
if not action:
|
||||
return {"success": False, "message": "Action not found"}
|
||||
return {"success": False, "message": get_game_message('action_not_found', locale)}
|
||||
|
||||
# Check stamina
|
||||
if player['stamina'] < action.stamina_cost:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Not enough stamina. Need {action.stamina_cost}, have {player['stamina']}."
|
||||
"message": get_game_message('not_enough_stamina', locale, cost=action.stamina_cost, current=player['stamina'])
|
||||
}
|
||||
|
||||
# Check cooldown for this specific action
|
||||
@@ -170,7 +174,7 @@ async def interact_with_object(
|
||||
remaining = int(cooldown_expiry - time.time())
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"This action is still on cooldown. Wait {remaining} seconds."
|
||||
"message": get_game_message('cooldown_wait', locale, seconds=remaining)
|
||||
}
|
||||
|
||||
# Deduct stamina
|
||||
@@ -196,7 +200,7 @@ async def interact_with_object(
|
||||
if not outcome:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Action has no defined outcomes"
|
||||
"message": get_game_message('action_no_outcomes', locale)
|
||||
}
|
||||
|
||||
# Process outcome
|
||||
@@ -216,7 +220,7 @@ async def interact_with_object(
|
||||
if not item:
|
||||
continue
|
||||
|
||||
item_name = item.name if item else item_id
|
||||
item_name = get_locale_string(item.name, locale) if item else item_id
|
||||
emoji = item.emoji if item and hasattr(item, 'emoji') else ''
|
||||
|
||||
# Check if item has durability (unique item)
|
||||
@@ -283,9 +287,9 @@ async def interact_with_object(
|
||||
await db.set_interactable_cooldown(interactable_id, action_id, 60)
|
||||
|
||||
# Build message
|
||||
final_message = outcome.text
|
||||
final_message = get_locale_string(outcome.text, locale)
|
||||
if items_dropped:
|
||||
final_message += f"\n⚠️ Inventory full! Dropped to ground: {', '.join(items_dropped)}"
|
||||
final_message += f"\n⚠️ {get_game_message('inventory_full', locale)}! {get_game_message('dropped_to_ground', locale)}: {', '.join(items_dropped)}"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -299,7 +303,7 @@ async def interact_with_object(
|
||||
}
|
||||
|
||||
|
||||
async def use_item(player_id: int, item_id: str, items_manager) -> Dict[str, Any]:
|
||||
async def use_item(player_id: int, item_id: str, items_manager, locale: str = 'en') -> Dict[str, Any]:
|
||||
"""
|
||||
Use an item from inventory.
|
||||
Returns: {success, message, effects}
|
||||
@@ -317,7 +321,7 @@ async def use_item(player_id: int, item_id: str, items_manager) -> Dict[str, Any
|
||||
break
|
||||
|
||||
if not item_entry:
|
||||
return {"success": False, "message": "You don't have this item"}
|
||||
return {"success": False, "message": get_game_message('no_item', locale)}
|
||||
|
||||
# Get item data
|
||||
item = items_manager.get_item(item_id)
|
||||
@@ -325,12 +329,61 @@ async def use_item(player_id: int, item_id: str, items_manager) -> Dict[str, Any
|
||||
return {"success": False, "message": "Item not found in game data"}
|
||||
|
||||
if not item.consumable:
|
||||
return {"success": False, "message": "This item cannot be used"}
|
||||
return {"success": False, "message": get_game_message('cannot_use', locale)}
|
||||
|
||||
# Apply item effects
|
||||
effects = {}
|
||||
effects_msg = []
|
||||
|
||||
# 1. Apply Status Effects (e.g. Regeneration from Bandage)
|
||||
if 'status_effect' in item.effects:
|
||||
status_data = item.effects['status_effect']
|
||||
|
||||
# Check if effect already exists
|
||||
current_effects = await db.get_player_effects(player_id)
|
||||
effect_name = status_data['name']
|
||||
|
||||
# Handle potential dict/string difference in validation (db stores as string usually)
|
||||
# But we need to compare with what's in the DB.
|
||||
# DB get_player_effects returns list of dicts with 'effect_name' key.
|
||||
|
||||
is_active = False
|
||||
for effect in current_effects:
|
||||
# Simple string comparison should suffice as both should be localized keys or raw strings
|
||||
if effect['effect_name'] == effect_name:
|
||||
is_active = True
|
||||
break
|
||||
|
||||
if is_active:
|
||||
return {"success": False, "message": get_game_message('effect_already_active', locale)}
|
||||
|
||||
await db.add_effect(
|
||||
player_id=player['id'],
|
||||
effect_name=status_data['name'],
|
||||
effect_icon=status_data.get('icon', '✨'),
|
||||
effect_type=status_data.get('type', 'buff'),
|
||||
damage_per_tick=status_data.get('damage_per_tick', 0),
|
||||
value=status_data.get('value', 0),
|
||||
ticks_remaining=status_data.get('ticks', 3),
|
||||
persist_after_combat=True, # Consumable effects usually persist
|
||||
source=f"item:{item.id}"
|
||||
)
|
||||
effects['status_applied'] = status_data['name']
|
||||
effects_msg.append(f"Applied {get_locale_string(status_data['name'], locale) if isinstance(status_data['name'], dict) else status_data['name']}")
|
||||
|
||||
# 2. Cure Status Effects
|
||||
if 'cures' in item.effects:
|
||||
cures = item.effects['cures']
|
||||
cured_list = []
|
||||
for cure_effect in cures:
|
||||
if await db.remove_effect(player['id'], cure_effect):
|
||||
cured_list.append(cure_effect)
|
||||
|
||||
if cured_list:
|
||||
effects['cured'] = cured_list
|
||||
effects_msg.append(f"{get_game_message('cured', locale)}: {', '.join(cured_list)}")
|
||||
|
||||
# 3. Direct Healing (Legacy/Instant)
|
||||
if 'hp_restore' in item.effects:
|
||||
hp_restore = item.effects['hp_restore']
|
||||
old_hp = player['hp']
|
||||
@@ -363,7 +416,7 @@ async def use_item(player_id: int, item_id: str, items_manager) -> Dict[str, Any
|
||||
await db.update_player_statistics(player_id, **stat_updates)
|
||||
|
||||
# Build message
|
||||
msg = f"Used {item.name}"
|
||||
msg = f"{get_game_message('item_used', locale, name=get_locale_string(item.name, locale))}"
|
||||
if effects_msg:
|
||||
msg += f" ({', '.join(effects_msg)})"
|
||||
|
||||
@@ -374,7 +427,7 @@ async def use_item(player_id: int, item_id: str, items_manager) -> Dict[str, Any
|
||||
}
|
||||
|
||||
|
||||
async def pickup_item(player_id: int, item_id: int, location_id: str, quantity: int = None, items_manager=None) -> Dict[str, Any]:
|
||||
async def pickup_item(player_id: int, item_id: int, location_id: str, quantity: int = None, items_manager=None, locale: str = 'en') -> Dict[str, Any]:
|
||||
"""
|
||||
Pick up an item from the ground.
|
||||
item_id is the dropped_item id, not the item_id field.
|
||||
@@ -386,7 +439,7 @@ async def pickup_item(player_id: int, item_id: int, location_id: str, quantity:
|
||||
dropped_item = await db.get_dropped_item(item_id)
|
||||
|
||||
if not dropped_item:
|
||||
return {"success": False, "message": "Item not found on ground"}
|
||||
return {"success": False, "message": get_game_message('item_not_found_ground', locale)}
|
||||
|
||||
# Get item definition
|
||||
item_def = items_manager.get_item(dropped_item['item_id']) if items_manager else None
|
||||
@@ -399,7 +452,7 @@ async def pickup_item(player_id: int, item_id: int, location_id: str, quantity:
|
||||
pickup_qty = available_qty
|
||||
else:
|
||||
if quantity < 1:
|
||||
return {"success": False, "message": "Invalid quantity"}
|
||||
return {"success": False, "message": get_game_message('invalid_quantity', locale)}
|
||||
pickup_qty = quantity
|
||||
|
||||
# Get player and calculate capacity
|
||||
@@ -420,13 +473,13 @@ async def pickup_item(player_id: int, item_id: int, location_id: str, quantity:
|
||||
if new_weight > max_weight:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"⚠️ Item too heavy! {item_def.emoji} {item_def.name} x{pickup_qty} ({item_weight:.1f}kg) would exceed capacity. Current: {current_weight:.1f}/{max_weight:.1f}kg"
|
||||
"message": get_game_message('item_too_heavy', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=pickup_qty, weight=item_weight, current=current_weight, max=max_weight)
|
||||
}
|
||||
|
||||
if new_volume > max_volume:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"⚠️ Item too large! {item_def.emoji} {item_def.name} x{pickup_qty} ({item_volume:.1f}L) would exceed capacity. Current: {current_volume:.1f}/{max_volume:.1f}L"
|
||||
"message": get_game_message('item_too_large', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=pickup_qty, volume=item_volume, current=current_volume, max=max_volume)
|
||||
}
|
||||
|
||||
# Items fit - update dropped item quantity or remove it
|
||||
@@ -446,7 +499,7 @@ async def pickup_item(player_id: int, item_id: int, location_id: str, quantity:
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Picked up {item_def.emoji} {item_def.name} x{pickup_qty}"
|
||||
"message": f"{get_game_message('picked_up', locale)} {item_def.emoji} {get_locale_string(item_def.name, locale)} x{pickup_qty}"
|
||||
}
|
||||
|
||||
|
||||
@@ -492,15 +545,17 @@ async def check_and_apply_level_up(player_id: int) -> Dict[str, Any]:
|
||||
# STATUS EFFECTS UTILITIES
|
||||
# ============================================================================
|
||||
|
||||
def calculate_status_damage(effects: list) -> int:
|
||||
def calculate_status_impact(effects: list) -> int:
|
||||
"""
|
||||
Calculate total damage from all status effects.
|
||||
Calculate total impact from all status effects.
|
||||
Positive value = Damage
|
||||
Negative value = Healing
|
||||
|
||||
Args:
|
||||
effects: List of status effect dicts
|
||||
|
||||
Returns:
|
||||
Total damage per tick
|
||||
Total impact per tick
|
||||
"""
|
||||
return sum(effect.get('damage_per_tick', 0) for effect in effects)
|
||||
|
||||
@@ -509,8 +564,6 @@ def calculate_status_damage(effects: list) -> int:
|
||||
# COMBAT UTILITIES
|
||||
# ============================================================================
|
||||
|
||||
return message, player_defeated
|
||||
|
||||
|
||||
def generate_npc_intent(npc_def, combat_state: dict) -> dict:
|
||||
"""
|
||||
@@ -535,19 +588,106 @@ def generate_npc_intent(npc_def, combat_state: dict) -> dict:
|
||||
return intent
|
||||
|
||||
|
||||
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -> Tuple[str, bool]:
|
||||
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -> Tuple[List[dict], bool]:
|
||||
"""
|
||||
Execute NPC turn based on PREVIOUS intent, then generate NEXT intent.
|
||||
Returns: (messages_list, player_defeated)
|
||||
"""
|
||||
player = await db.get_player_by_id(player_id)
|
||||
if not player:
|
||||
return "Player not found", True
|
||||
return [], True
|
||||
|
||||
messages = []
|
||||
|
||||
# 1. PROCESS NPC STATUS EFFECTS
|
||||
npc_hp = combat['npc_hp']
|
||||
npc_max_hp = combat['npc_max_hp']
|
||||
npc_status_str = combat.get('npc_status_effects', '')
|
||||
|
||||
if npc_status_str:
|
||||
# Parse status: "bleeding:5:3" (name:dmg:ticks) or multiple "bleeding:5:3|burning:2:2"
|
||||
# Handling multiple effects separated by |
|
||||
effects_list = npc_status_str.split('|')
|
||||
active_effects = []
|
||||
npc_damage_taken = 0
|
||||
npc_healing_received = 0
|
||||
|
||||
for effect_str in effects_list:
|
||||
if not effect_str: continue
|
||||
try:
|
||||
parts = effect_str.split(':')
|
||||
if len(parts) >= 3:
|
||||
name = parts[0]
|
||||
dmg = int(parts[1])
|
||||
ticks = int(parts[2])
|
||||
|
||||
# Apply effect
|
||||
if ticks > 0:
|
||||
if dmg > 0:
|
||||
npc_damage_taken += dmg
|
||||
messages.append(create_combat_message(
|
||||
"effect_damage",
|
||||
origin="enemy",
|
||||
damage=dmg,
|
||||
effect_name=name,
|
||||
npc_name=npc_def.name
|
||||
))
|
||||
elif dmg < 0:
|
||||
heal = abs(dmg)
|
||||
npc_healing_received += heal
|
||||
messages.append(create_combat_message(
|
||||
"effect_heal", # Check if this message type exists or fallback
|
||||
origin="enemy",
|
||||
heal=heal,
|
||||
effect_name=name,
|
||||
npc_name=npc_def.name
|
||||
))
|
||||
|
||||
# Decrement tick
|
||||
ticks -= 1
|
||||
if ticks > 0:
|
||||
active_effects.append(f"{name}:{dmg}:{ticks}")
|
||||
except Exception as e:
|
||||
print(f"Error parsing NPC status: {e}")
|
||||
|
||||
# Update NPC active effects
|
||||
new_status_str = "|".join(active_effects)
|
||||
if new_status_str != npc_status_str:
|
||||
await db.update_combat(player_id, {'npc_status_effects': new_status_str})
|
||||
|
||||
# Apply Total Damage/Healing
|
||||
if npc_damage_taken > 0:
|
||||
npc_hp = max(0, npc_hp - npc_damage_taken)
|
||||
|
||||
if npc_healing_received > 0:
|
||||
npc_hp = min(npc_max_hp, npc_hp + npc_healing_received)
|
||||
|
||||
# Update NPC HP in DB
|
||||
await db.update_combat(player_id, {'npc_hp': npc_hp})
|
||||
|
||||
# Check if NPC died from effects
|
||||
if npc_hp <= 0:
|
||||
messages.append(create_combat_message(
|
||||
"victory",
|
||||
origin="neutral",
|
||||
npc_name=npc_def.name
|
||||
))
|
||||
# Award XP/Loot logic handled in combat route mostly, but we need to signal it.
|
||||
# Returning true for player_defeated is definitely WRONG here if NPC died.
|
||||
# The router usually handles "victory" check after action.
|
||||
# But here this is triggered during NPC turn (which happens after Player turn).
|
||||
# If NPC dies on its OWN turn, we need to handle it.
|
||||
# However, typically NPC dies on Player turn.
|
||||
# If NPC dies from bleeding on its turn, the player wins.
|
||||
# We need to signal this back to router.
|
||||
# But the current return signature is (messages, player_defeated).
|
||||
# We might need to handle the win logic here or update signature.
|
||||
# For now, let's update HP and let the flow continue.
|
||||
# Wait, if NPC is dead, it shouldn't attack!
|
||||
# returning here prevents NPC from attacking if it died from status effects
|
||||
return messages, False
|
||||
|
||||
# Parse current intent (stored in DB as string or JSON, assuming simple string for now or we parse it)
|
||||
# For now, let's assume simple string "attack", "defend", "special" stored in npc_intent
|
||||
# If we want more complex data, we should use JSON, but the migration added VARCHAR.
|
||||
# Let's stick to simple string for the column, but we can store "type:value" if needed.
|
||||
|
||||
current_intent_str = combat.get('npc_intent', 'attack')
|
||||
# Handle legacy/null
|
||||
if not current_intent_str:
|
||||
@@ -555,73 +695,116 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
||||
|
||||
intent_type = current_intent_str
|
||||
|
||||
message = ""
|
||||
actual_damage = 0
|
||||
|
||||
# EXECUTE INTENT
|
||||
if intent_type == 'defend':
|
||||
# NPC defends - maybe heals or takes less damage next turn?
|
||||
# For simplicity: Heals 5% HP
|
||||
heal_amount = int(combat['npc_max_hp'] * 0.05)
|
||||
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
|
||||
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
|
||||
message = f"{npc_def.name} defends and recovers {heal_amount} HP!"
|
||||
|
||||
elif intent_type == 'special':
|
||||
# Strong attack (1.5x damage)
|
||||
npc_damage = int(random.randint(npc_def.damage_min, npc_def.damage_max) * 1.5)
|
||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||
|
||||
message = f"{npc_def.name} uses a SPECIAL ATTACK for {npc_damage} damage!"
|
||||
if armor_absorbed > 0:
|
||||
message += f" (Armor absorbed {armor_absorbed})"
|
||||
if npc_hp > 0: # Only attack if alive
|
||||
if intent_type == 'defend':
|
||||
# NPC defends - heals 5% HP
|
||||
heal_amount = int(combat['npc_max_hp'] * 0.05)
|
||||
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
|
||||
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
|
||||
|
||||
if broken_armor:
|
||||
for armor in broken_armor:
|
||||
message += f"\n💔 Your {armor['emoji']} {armor['name']} broke!"
|
||||
messages.append(create_combat_message(
|
||||
"enemy_defend",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name,
|
||||
heal=heal_amount
|
||||
))
|
||||
|
||||
elif intent_type == 'special':
|
||||
# Strong attack (1.5x damage)
|
||||
npc_damage = int(random.randint(npc_def.damage_min, npc_def.damage_max) * 1.5)
|
||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"enemy_special",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name,
|
||||
damage=npc_damage,
|
||||
armor_absorbed=armor_absorbed
|
||||
))
|
||||
|
||||
await db.update_player(player_id, hp=new_player_hp)
|
||||
|
||||
else: # Default 'attack'
|
||||
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
||||
# Enrage bonus if NPC is below 30% HP
|
||||
if combat['npc_hp'] / combat['npc_max_hp'] < 0.3:
|
||||
npc_damage = int(npc_damage * 1.5)
|
||||
message = f"{npc_def.name} is ENRAGED! "
|
||||
else:
|
||||
message = ""
|
||||
if broken_armor:
|
||||
for armor in broken_armor:
|
||||
messages.append(create_combat_message(
|
||||
"item_broken",
|
||||
origin="player",
|
||||
item_name=armor['name'],
|
||||
emoji=armor['emoji']
|
||||
))
|
||||
|
||||
await db.update_player(player_id, hp=new_player_hp)
|
||||
|
||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||
|
||||
message += f"{npc_def.name} attacks for {npc_damage} damage!"
|
||||
if armor_absorbed > 0:
|
||||
message += f" (Armor absorbed {armor_absorbed})"
|
||||
else: # Default 'attack'
|
||||
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
||||
|
||||
if broken_armor:
|
||||
for armor in broken_armor:
|
||||
message += f"\n💔 Your {armor['emoji']} {armor['name']} broke!"
|
||||
# Enrage bonus if NPC is below 30% HP
|
||||
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3
|
||||
if is_enraged:
|
||||
npc_damage = int(npc_damage * 1.5)
|
||||
messages.append(create_combat_message(
|
||||
"enemy_enraged",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name
|
||||
))
|
||||
|
||||
# Check if player is defending (reduces damage by value%)
|
||||
player_effects = await db.get_player_effects(player_id)
|
||||
defending_effect = next((e for e in player_effects if e['effect_name'] == 'defending'), None)
|
||||
if defending_effect:
|
||||
reduction = defending_effect.get('value', 50) / 100 # Default 50% reduction
|
||||
npc_damage = int(npc_damage * (1 - reduction))
|
||||
messages.append(create_combat_message(
|
||||
"damage_reduced",
|
||||
origin="player",
|
||||
reduction=int(reduction * 100)
|
||||
))
|
||||
# Remove defending effect after use
|
||||
await db.remove_effect(player_id, 'defending')
|
||||
|
||||
await db.update_player(player_id, hp=new_player_hp)
|
||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"enemy_attack",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name,
|
||||
damage=npc_damage,
|
||||
armor_absorbed=armor_absorbed
|
||||
))
|
||||
|
||||
if broken_armor:
|
||||
for armor in broken_armor:
|
||||
messages.append(create_combat_message(
|
||||
"item_broken",
|
||||
origin="player",
|
||||
item_name=armor['name'],
|
||||
emoji=armor['emoji']
|
||||
))
|
||||
|
||||
await db.update_player(player_id, hp=new_player_hp)
|
||||
|
||||
# GENERATE NEXT INTENT
|
||||
# We need to update the combat state with the new HP values first to make good decisions
|
||||
# But we can just use the values we calculated.
|
||||
|
||||
# Check if player defeated
|
||||
player_defeated = False
|
||||
if player['hp'] - actual_damage <= 0 and intent_type != 'defend': # Check HP after damage
|
||||
# Re-fetch to be sure or just trust calculation
|
||||
if new_player_hp <= 0:
|
||||
message += "\nYou have been defeated!"
|
||||
messages.append(create_combat_message(
|
||||
"player_defeated",
|
||||
origin="neutral",
|
||||
npc_name=npc_def.name
|
||||
))
|
||||
player_defeated = True
|
||||
await db.update_player(player_id, hp=0, is_dead=True)
|
||||
await db.update_player_statistics(player_id, deaths=1, damage_taken=actual_damage, increment=True)
|
||||
await db.end_combat(player_id)
|
||||
return message, player_defeated
|
||||
return messages, player_defeated
|
||||
|
||||
if not player_defeated:
|
||||
if actual_damage > 0:
|
||||
@@ -645,4 +828,4 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
||||
'npc_intent': next_intent['type']
|
||||
})
|
||||
|
||||
return message, player_defeated
|
||||
return messages, player_defeated
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
"""
|
||||
Internal API endpoints for Telegram Bot
|
||||
These endpoints are protected by an internal key and handle game logic
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict, Any, List
|
||||
import os
|
||||
|
||||
# Internal API key for bot authentication
|
||||
INTERNAL_API_KEY = os.getenv("API_INTERNAL_KEY", "internal-bot-access-key-change-me")
|
||||
|
||||
router = APIRouter(prefix="/api/internal", tags=["internal"])
|
||||
|
||||
|
||||
def verify_internal_key(x_internal_key: str = Header(...)):
|
||||
"""Verify internal API key"""
|
||||
if x_internal_key != INTERNAL_API_KEY:
|
||||
raise HTTPException(status_code=403, detail="Invalid internal API key")
|
||||
return True
|
||||
|
||||
|
||||
# ==================== Pydantic Models ====================
|
||||
|
||||
class PlayerCreate(BaseModel):
|
||||
telegram_id: int
|
||||
name: str = "Survivor"
|
||||
|
||||
class PlayerUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
hp: Optional[int] = None
|
||||
stamina: Optional[int] = None
|
||||
location_id: Optional[str] = None
|
||||
level: Optional[int] = None
|
||||
xp: Optional[int] = None
|
||||
strength: Optional[int] = None
|
||||
agility: Optional[int] = None
|
||||
endurance: Optional[int] = None
|
||||
intellect: Optional[int] = None
|
||||
|
||||
class MoveRequest(BaseModel):
|
||||
direction: str
|
||||
|
||||
class CombatStart(BaseModel):
|
||||
telegram_id: int
|
||||
npc_id: str
|
||||
|
||||
class CombatAction(BaseModel):
|
||||
action: str # "attack", "defend", "flee"
|
||||
|
||||
class UseItem(BaseModel):
|
||||
item_db_id: int
|
||||
|
||||
class EquipItem(BaseModel):
|
||||
item_db_id: int
|
||||
|
||||
|
||||
# ==================== Player Endpoints ====================
|
||||
|
||||
@router.get("/player/telegram/{telegram_id}")
|
||||
async def get_player_by_telegram(
|
||||
telegram_id: int,
|
||||
_: bool = Depends(verify_internal_key)
|
||||
):
|
||||
"""Get player by Telegram ID"""
|
||||
from bot.database import get_player
|
||||
player = await get_player(telegram_id=telegram_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
return player
|
||||
|
||||
|
||||
@router.post("/player")
|
||||
async def create_player_internal(
|
||||
player_data: PlayerCreate,
|
||||
_: bool = Depends(verify_internal_key)
|
||||
):
|
||||
"""Create a new player (Telegram bot)"""
|
||||
from bot.database import create_player
|
||||
player = await create_player(telegram_id=player_data.telegram_id, name=player_data.name)
|
||||
if not player:
|
||||
raise HTTPException(status_code=500, detail="Failed to create player")
|
||||
return player
|
||||
|
||||
|
||||
@router.patch("/player/telegram/{telegram_id}")
|
||||
async def update_player_internal(
|
||||
telegram_id: int,
|
||||
updates: PlayerUpdate,
|
||||
_: bool = Depends(verify_internal_key)
|
||||
):
|
||||
"""Update player data"""
|
||||
from bot.database import update_player
|
||||
|
||||
# Convert to dict and remove None values
|
||||
update_dict = {k: v for k, v in updates.dict().items() if v is not None}
|
||||
|
||||
if not update_dict:
|
||||
return {"success": True, "message": "No updates provided"}
|
||||
|
||||
await update_player(telegram_id=telegram_id, updates=update_dict)
|
||||
return {"success": True, "message": "Player updated"}
|
||||
|
||||
|
||||
# ==================== Location Endpoints ====================
|
||||
|
||||
@router.get("/location/{location_id}")
|
||||
async def get_location_internal(
|
||||
location_id: str,
|
||||
_: bool = Depends(verify_internal_key)
|
||||
):
|
||||
"""Get location details"""
|
||||
from api.main import LOCATIONS
|
||||
|
||||
location = LOCATIONS.get(location_id)
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
return {
|
||||
"id": location.id,
|
||||
"name": location.name,
|
||||
"description": location.description,
|
||||
"exits": location.exits,
|
||||
"interactables": {k: {
|
||||
"id": v.id,
|
||||
"name": v.name,
|
||||
"actions": list(v.actions.keys())
|
||||
} for k, v in location.interactables.items()},
|
||||
"image_path": location.image_path
|
||||
}
|
||||
|
||||
|
||||
@router.post("/player/telegram/{telegram_id}/move")
|
||||
async def move_player_internal(
|
||||
telegram_id: int,
|
||||
move_data: MoveRequest,
|
||||
_: bool = Depends(verify_internal_key)
|
||||
):
|
||||
"""Move player in a direction"""
|
||||
from bot.database import get_player, update_player
|
||||
from api.main import LOCATIONS
|
||||
|
||||
player = await get_player(telegram_id=telegram_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
current_location = LOCATIONS.get(player['location_id'])
|
||||
if not current_location:
|
||||
raise HTTPException(status_code=400, detail="Invalid current location")
|
||||
|
||||
# Check stamina
|
||||
if player['stamina'] < 1:
|
||||
raise HTTPException(status_code=400, detail="Not enough stamina to move")
|
||||
|
||||
# Find exit
|
||||
destination_id = current_location.exits.get(move_data.direction.lower())
|
||||
if not destination_id:
|
||||
raise HTTPException(status_code=400, detail=f"Cannot move {move_data.direction} from here")
|
||||
|
||||
new_location = LOCATIONS.get(destination_id)
|
||||
if not new_location:
|
||||
raise HTTPException(status_code=400, detail="Invalid destination")
|
||||
|
||||
# Update player
|
||||
await update_player(telegram_id=telegram_id, updates={
|
||||
'location_id': new_location.id,
|
||||
'stamina': max(0, player['stamina'] - 1)
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"location": {
|
||||
"id": new_location.id,
|
||||
"name": new_location.name,
|
||||
"description": new_location.description,
|
||||
"exits": new_location.exits
|
||||
},
|
||||
"stamina": max(0, player['stamina'] - 1)
|
||||
}
|
||||
|
||||
|
||||
# ==================== Inventory Endpoints ====================
|
||||
|
||||
@router.get("/player/telegram/{telegram_id}/inventory")
|
||||
async def get_inventory_internal(
|
||||
telegram_id: int,
|
||||
_: bool = Depends(verify_internal_key)
|
||||
):
|
||||
"""Get player's inventory"""
|
||||
from bot.database import get_inventory
|
||||
|
||||
inventory = await get_inventory(telegram_id)
|
||||
return {"items": inventory}
|
||||
|
||||
|
||||
@router.post("/player/telegram/{telegram_id}/use_item")
|
||||
async def use_item_internal(
|
||||
telegram_id: int,
|
||||
item_data: UseItem,
|
||||
_: bool = Depends(verify_internal_key)
|
||||
):
|
||||
"""Use an item from inventory"""
|
||||
from bot.logic import use_item_logic
|
||||
from bot.database import get_player
|
||||
|
||||
player = await get_player(telegram_id=telegram_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
result = await use_item_logic(player, item_data.item_db_id)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/player/telegram/{telegram_id}/equip")
|
||||
async def equip_item_internal(
|
||||
telegram_id: int,
|
||||
item_data: EquipItem,
|
||||
_: bool = Depends(verify_internal_key)
|
||||
):
|
||||
"""Equip/unequip an item"""
|
||||
from bot.logic import toggle_equip
|
||||
|
||||
result = await toggle_equip(telegram_id, item_data.item_db_id)
|
||||
return {"success": True, "message": result}
|
||||
|
||||
|
||||
# ==================== Combat Endpoints ====================
|
||||
|
||||
@router.post("/combat/start")
|
||||
async def start_combat_internal(
|
||||
combat_data: CombatStart,
|
||||
_: bool = Depends(verify_internal_key)
|
||||
):
|
||||
"""Start combat with an NPC"""
|
||||
from bot.combat import start_combat
|
||||
from bot.database import get_player
|
||||
|
||||
player = await get_player(telegram_id=combat_data.telegram_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
result = await start_combat(combat_data.telegram_id, combat_data.npc_id, player['location_id'])
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=400, detail=result.get("message", "Failed to start combat"))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/combat/telegram/{telegram_id}")
|
||||
async def get_combat_internal(
|
||||
telegram_id: int,
|
||||
_: bool = Depends(verify_internal_key)
|
||||
):
|
||||
"""Get active combat state"""
|
||||
from bot.combat import get_active_combat
|
||||
|
||||
combat = await get_active_combat(telegram_id)
|
||||
if not combat:
|
||||
raise HTTPException(status_code=404, detail="No active combat")
|
||||
|
||||
return combat
|
||||
|
||||
|
||||
@router.post("/combat/telegram/{telegram_id}/action")
|
||||
async def combat_action_internal(
|
||||
telegram_id: int,
|
||||
action_data: CombatAction,
|
||||
_: bool = Depends(verify_internal_key)
|
||||
):
|
||||
"""Perform combat action"""
|
||||
from bot.combat import player_attack, player_defend, player_flee
|
||||
|
||||
if action_data.action == "attack":
|
||||
result = await player_attack(telegram_id)
|
||||
elif action_data.action == "defend":
|
||||
result = await player_defend(telegram_id)
|
||||
elif action_data.action == "flee":
|
||||
result = await player_flee(telegram_id)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid combat action")
|
||||
|
||||
return result
|
||||
20
api/items.py
@@ -4,7 +4,7 @@ Loads and manages game items from JSON without bot dependencies.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any, Optional, Union
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ from dataclasses import dataclass
|
||||
class Item:
|
||||
"""Represents a game item"""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
name: Union[str, Dict[str, str]]
|
||||
description: Union[str, Dict[str, str]]
|
||||
type: str
|
||||
image_path: str = ""
|
||||
emoji: str = "📦"
|
||||
@@ -24,6 +24,7 @@ class Item:
|
||||
volume: float = 0.0
|
||||
stats: Dict[str, int] = None
|
||||
effects: Dict[str, Any] = None
|
||||
value: int = 10 # Base value for trading
|
||||
# Equipment system
|
||||
slot: str = None # Equipment slot: head, torso, legs, feet, weapon, offhand, backpack
|
||||
durability: int = None # Max durability for equippable items
|
||||
@@ -45,6 +46,10 @@ class Item:
|
||||
uncraft_yield: list = None # Materials yielded from uncrafting (before loss chance)
|
||||
uncraft_loss_chance: float = 0.3 # Chance to lose materials when uncrafting (0.3 = 30%)
|
||||
uncraft_tools: list = None # Tools required for uncrafting
|
||||
# Combat system
|
||||
combat_usable: bool = False # Can be used during combat
|
||||
combat_only: bool = False # Can ONLY be used during combat
|
||||
combat_effects: Dict[str, Any] = None # Effects applied in combat (damage, status)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.stats is None:
|
||||
@@ -65,7 +70,8 @@ class Item:
|
||||
self.uncraft_yield = []
|
||||
if self.uncraft_tools is None:
|
||||
self.uncraft_tools = []
|
||||
self.craft_materials = []
|
||||
if self.combat_effects is None:
|
||||
self.combat_effects = {}
|
||||
|
||||
|
||||
class ItemsManager:
|
||||
@@ -104,6 +110,7 @@ class ItemsManager:
|
||||
name=item_data.get('name', 'Unknown Item'),
|
||||
description=item_data.get('description', ''),
|
||||
type=item_type,
|
||||
value=item_data.get('value', 10),
|
||||
image_path=item_data.get('image_path', ''),
|
||||
emoji=item_data.get('emoji', '📦'),
|
||||
stackable=item_data.get('stackable', True),
|
||||
@@ -129,7 +136,10 @@ class ItemsManager:
|
||||
uncraftable=item_data.get('uncraftable', False),
|
||||
uncraft_yield=item_data.get('uncraft_yield', []),
|
||||
uncraft_loss_chance=item_data.get('uncraft_loss_chance', 0.3),
|
||||
uncraft_tools=item_data.get('uncraft_tools', [])
|
||||
uncraft_tools=item_data.get('uncraft_tools', []),
|
||||
combat_usable=item_data.get('combat_usable', is_consumable), # Default: consumables are combat usable
|
||||
combat_only=item_data.get('combat_only', False),
|
||||
combat_effects=item_data.get('combat_effects', {})
|
||||
)
|
||||
self.items[item_id] = item
|
||||
|
||||
|
||||
499
api/main.old.py
@@ -1,499 +0,0 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import jwt
|
||||
import bcrypt
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add parent directory to path to import bot modules
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from bot.database import get_player, create_player
|
||||
from data.world_loader import load_world
|
||||
from api.internal import router as internal_router
|
||||
|
||||
app = FastAPI(title="Echoes of the Ashes API", version="1.0.0")
|
||||
|
||||
# Include internal API router
|
||||
app.include_router(internal_router)
|
||||
|
||||
# CORS configuration
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://echoesoftheashgame.patacuack.net", "http://localhost:3000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# JWT Configuration
|
||||
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
# Load world data
|
||||
WORLD = None
|
||||
LOCATIONS = {}
|
||||
try:
|
||||
WORLD = load_world()
|
||||
# WORLD.locations is already a dict {location_id: Location}
|
||||
LOCATIONS = WORLD.locations
|
||||
print(f"✅ Loaded {len(LOCATIONS)} locations")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Could not load world data: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Pydantic Models
|
||||
class UserRegister(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
class User(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
telegram_id: Optional[str] = None
|
||||
|
||||
class PlayerState(BaseModel):
|
||||
location_id: str
|
||||
location_name: str
|
||||
health: int
|
||||
max_health: int
|
||||
stamina: int
|
||||
max_stamina: int
|
||||
inventory: List[dict]
|
||||
status_effects: List[dict]
|
||||
|
||||
class MoveRequest(BaseModel):
|
||||
direction: str
|
||||
|
||||
|
||||
# Helper Functions
|
||||
def create_access_token(data: dict):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
try:
|
||||
token = credentials.credentials
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id: int = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
||||
return user_id
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token has expired")
|
||||
except jwt.JWTError:
|
||||
raise HTTPException(status_code=401, detail="Could not validate credentials")
|
||||
|
||||
|
||||
# Routes
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Echoes of the Ashes API", "status": "online"}
|
||||
|
||||
@app.post("/api/auth/register", response_model=Token)
|
||||
async def register(user_data: UserRegister):
|
||||
"""Register a new user account"""
|
||||
try:
|
||||
# Check if username already exists
|
||||
existing_player = await get_player(username=user_data.username)
|
||||
if existing_player:
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
|
||||
# Hash password
|
||||
password_hash = bcrypt.hashpw(user_data.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
# Create player with web auth
|
||||
player = await create_player(
|
||||
telegram_id=None,
|
||||
username=user_data.username,
|
||||
password_hash=password_hash
|
||||
)
|
||||
|
||||
if not player or 'id' not in player:
|
||||
print(f"ERROR: create_player returned: {player}")
|
||||
raise HTTPException(status_code=500, detail="Failed to create player - no ID returned")
|
||||
|
||||
# Create token
|
||||
access_token = create_access_token(data={"sub": player['id']})
|
||||
|
||||
return {"access_token": access_token}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"ERROR in register: {str(e)}")
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/api/auth/login", response_model=Token)
|
||||
async def login(user_data: UserLogin):
|
||||
"""Login with username and password"""
|
||||
try:
|
||||
# Get player
|
||||
player = await get_player(username=user_data.username)
|
||||
if not player or not player.get('password_hash'):
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
# Verify password
|
||||
if not bcrypt.checkpw(user_data.password.encode('utf-8'), player['password_hash'].encode('utf-8')):
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
# Create token
|
||||
access_token = create_access_token(data={"sub": player['id']})
|
||||
|
||||
return {"access_token": access_token}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/api/auth/me", response_model=User)
|
||||
async def get_current_user(user_id: int = Depends(verify_token)):
|
||||
"""Get current authenticated user"""
|
||||
player = await get_player(player_id=user_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return {
|
||||
"id": player['id'],
|
||||
"username": player.get('username'),
|
||||
"telegram_id": player.get('telegram_id')
|
||||
}
|
||||
|
||||
@app.get("/api/game/state", response_model=PlayerState)
|
||||
async def get_game_state(user_id: int = Depends(verify_token)):
|
||||
"""Get current player game state"""
|
||||
player = await get_player(player_id=user_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
location = LOCATIONS.get(player['location_id'])
|
||||
|
||||
# TODO: Get actual inventory and status effects from database
|
||||
inventory = []
|
||||
status_effects = []
|
||||
|
||||
return {
|
||||
"location_id": player['location_id'],
|
||||
"location_name": location.name if location else "Unknown",
|
||||
"health": player['hp'],
|
||||
"max_health": player['max_hp'],
|
||||
"stamina": player['stamina'],
|
||||
"max_stamina": player['max_stamina'],
|
||||
"inventory": inventory,
|
||||
"status_effects": status_effects
|
||||
}
|
||||
|
||||
@app.post("/api/game/move")
|
||||
async def move_player(move_data: MoveRequest, user_id: int = Depends(verify_token)):
|
||||
"""Move player in a direction"""
|
||||
from bot.database import update_player
|
||||
|
||||
player = await get_player(player_id=user_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
current_location = LOCATIONS.get(player['location_id'])
|
||||
if not current_location:
|
||||
raise HTTPException(status_code=400, detail="Invalid current location")
|
||||
|
||||
# Check if player has enough stamina
|
||||
if player['stamina'] < 1:
|
||||
raise HTTPException(status_code=400, detail="Not enough stamina to move")
|
||||
|
||||
# Find exit in the specified direction (exits is dict {direction: destination_id})
|
||||
destination_id = current_location.exits.get(move_data.direction.lower())
|
||||
|
||||
if not destination_id:
|
||||
raise HTTPException(status_code=400, detail=f"Cannot move {move_data.direction} from here")
|
||||
|
||||
# Move player
|
||||
new_location = LOCATIONS.get(destination_id)
|
||||
if not new_location:
|
||||
raise HTTPException(status_code=400, detail="Invalid destination")
|
||||
|
||||
# Update player location and stamina (use player_id for web users)
|
||||
await update_player(player_id=player['id'], updates={
|
||||
'location_id': new_location.id,
|
||||
'stamina': max(0, player['stamina'] - 1)
|
||||
})
|
||||
|
||||
# Get updated player state
|
||||
updated_player = await get_player(player_id=user_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"You travel {move_data.direction} to {new_location.name}. {new_location.description}",
|
||||
"player_state": {
|
||||
"location_id": updated_player['location_id'],
|
||||
"location_name": new_location.name,
|
||||
"health": updated_player['hp'],
|
||||
"max_health": updated_player['max_hp'],
|
||||
"stamina": updated_player['stamina'],
|
||||
"max_stamina": updated_player['max_stamina'],
|
||||
"inventory": [],
|
||||
"status_effects": []
|
||||
}
|
||||
}
|
||||
|
||||
@app.get("/api/game/location")
|
||||
async def get_current_location(user_id: int = Depends(verify_token)):
|
||||
"""Get detailed information about current location"""
|
||||
player = await get_player(player_id=user_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
location = LOCATIONS.get(player['location_id'])
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail=f"Location '{player['location_id']}' not found")
|
||||
|
||||
# Get available directions from exits dict
|
||||
directions = list(location.exits.keys())
|
||||
|
||||
# Get NPCs at location (TODO: implement NPC spawning)
|
||||
npcs = []
|
||||
|
||||
# Get items at location (TODO: implement dropped items)
|
||||
items = []
|
||||
|
||||
# Determine image extension (png or jpg)
|
||||
image_url = None
|
||||
if location.image_path:
|
||||
# Use the path from location data
|
||||
image_url = f"/{location.image_path}"
|
||||
else:
|
||||
# Default to png with fallback to jpg
|
||||
image_url = f"/images/locations/{location.id}.png"
|
||||
|
||||
return {
|
||||
"id": location.id,
|
||||
"name": location.name,
|
||||
"description": location.description,
|
||||
"directions": directions,
|
||||
"npcs": npcs,
|
||||
"items": items,
|
||||
"image_url": image_url,
|
||||
"interactables": [{"id": k, "name": v.name} for k, v in location.interactables.items()]
|
||||
}
|
||||
|
||||
@app.get("/api/game/inventory")
|
||||
async def get_inventory(user_id: int = Depends(verify_token)):
|
||||
"""Get player's inventory"""
|
||||
from bot.database import get_inventory
|
||||
|
||||
player = await get_player(player_id=user_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
# For web users without telegram_id, inventory might be empty
|
||||
# This is a limitation of the current schema
|
||||
inventory = []
|
||||
|
||||
return {
|
||||
"items": inventory,
|
||||
"capacity": 20 # TODO: Calculate based on equipped bag
|
||||
}
|
||||
|
||||
@app.get("/api/game/profile")
|
||||
async def get_profile(user_id: int = Depends(verify_token)):
|
||||
"""Get player profile and stats"""
|
||||
player = await get_player(player_id=user_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
return {
|
||||
"name": player['name'],
|
||||
"level": player['level'],
|
||||
"xp": player['xp'],
|
||||
"hp": player['hp'],
|
||||
"max_hp": player['max_hp'],
|
||||
"stamina": player['stamina'],
|
||||
"max_stamina": player['max_stamina'],
|
||||
"strength": player['strength'],
|
||||
"agility": player['agility'],
|
||||
"endurance": player['endurance'],
|
||||
"intellect": player['intellect'],
|
||||
"unspent_points": player['unspent_points'],
|
||||
"is_dead": player['is_dead']
|
||||
}
|
||||
|
||||
@app.get("/api/game/map")
|
||||
async def get_map(user_id: int = Depends(verify_token)):
|
||||
"""Get world map data"""
|
||||
player = await get_player(player_id=user_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
# Return all locations and connections (LOCATIONS is dict {id: Location})
|
||||
locations_data = []
|
||||
for loc_id, loc in LOCATIONS.items():
|
||||
locations_data.append({
|
||||
"id": loc.id,
|
||||
"name": loc.name,
|
||||
"description": loc.description,
|
||||
"exits": loc.exits # Dict of {direction: destination_id}
|
||||
})
|
||||
|
||||
return {
|
||||
"current_location": player['location_id'],
|
||||
"locations": locations_data
|
||||
}
|
||||
|
||||
@app.post("/api/game/inspect")
|
||||
async def inspect_area(user_id: int = Depends(verify_token)):
|
||||
"""Inspect the current area for details"""
|
||||
player = await get_player(player_id=user_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
location = LOCATIONS.get(player['location_id'])
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
# Get detailed information
|
||||
interactables_detail = []
|
||||
for inst_id, inter in location.interactables.items():
|
||||
actions = [{"id": act.id, "label": act.label, "stamina_cost": act.stamina_cost}
|
||||
for act in inter.actions.values()]
|
||||
interactables_detail.append({
|
||||
"instance_id": inst_id,
|
||||
"name": inter.name,
|
||||
"actions": actions
|
||||
})
|
||||
|
||||
return {
|
||||
"location": location.name,
|
||||
"description": location.description,
|
||||
"interactables": interactables_detail,
|
||||
"exits": location.exits
|
||||
}
|
||||
|
||||
class InteractRequest(BaseModel):
|
||||
interactable_id: str
|
||||
action_id: str
|
||||
|
||||
@app.post("/api/game/interact")
|
||||
async def interact_with_object(interact_data: InteractRequest, user_id: int = Depends(verify_token)):
|
||||
"""Interact with an object in the world"""
|
||||
from bot.database import update_player, add_inventory_item
|
||||
import random
|
||||
|
||||
player = await get_player(player_id=user_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
location = LOCATIONS.get(player['location_id'])
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
interactable = location.interactables.get(interact_data.interactable_id)
|
||||
if not interactable:
|
||||
raise HTTPException(status_code=404, detail="Interactable not found")
|
||||
|
||||
action = interactable.actions.get(interact_data.action_id)
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail="Action not found")
|
||||
|
||||
# Check stamina
|
||||
if player['stamina'] < action.stamina_cost:
|
||||
raise HTTPException(status_code=400, detail="Not enough stamina")
|
||||
|
||||
# Perform action - randomly choose outcome
|
||||
outcome_key = random.choice(list(action.outcomes.keys()))
|
||||
outcome = action.outcomes[outcome_key]
|
||||
|
||||
# Apply outcome
|
||||
stamina_change = -action.stamina_cost
|
||||
hp_change = -outcome.damage_taken if outcome.damage_taken else 0
|
||||
items_found = outcome.items_reward if outcome.items_reward else {}
|
||||
|
||||
# Update player
|
||||
new_hp = max(1, player['hp'] + hp_change)
|
||||
new_stamina = max(0, player['stamina'] + stamina_change)
|
||||
|
||||
await update_player(player_id=player['id'], updates={
|
||||
'hp': new_hp,
|
||||
'stamina': new_stamina
|
||||
})
|
||||
|
||||
# Add items to inventory (if player has telegram_id for FK)
|
||||
items_added = []
|
||||
if player.get('telegram_id') and items_found:
|
||||
for item_id, quantity in items_found.items():
|
||||
# This will fail for web users without telegram_id
|
||||
# TODO: Fix inventory schema
|
||||
try:
|
||||
items_added.append({"id": item_id, "quantity": quantity})
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": outcome_key,
|
||||
"message": outcome.text,
|
||||
"items_found": items_added,
|
||||
"hp_change": hp_change,
|
||||
"stamina_change": stamina_change,
|
||||
"new_hp": new_hp,
|
||||
"new_stamina": new_stamina
|
||||
}
|
||||
|
||||
class UseItemRequest(BaseModel):
|
||||
item_db_id: int
|
||||
|
||||
@app.post("/api/game/use_item")
|
||||
async def use_item_endpoint(item_data: UseItemRequest, user_id: int = Depends(verify_token)):
|
||||
"""Use an item from inventory"""
|
||||
from bot.logic import use_item_logic
|
||||
|
||||
player = await get_player(player_id=user_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
if not player.get('telegram_id'):
|
||||
raise HTTPException(status_code=400, detail="Inventory not available for web users yet")
|
||||
|
||||
result = await use_item_logic(player, item_data.item_db_id)
|
||||
return result
|
||||
|
||||
class EquipItemRequest(BaseModel):
|
||||
item_db_id: int
|
||||
|
||||
@app.post("/api/game/equip_item")
|
||||
async def equip_item_endpoint(item_data: EquipItemRequest, user_id: int = Depends(verify_token)):
|
||||
"""Equip or unequip an item"""
|
||||
from bot.logic import toggle_equip
|
||||
|
||||
player = await get_player(player_id=user_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
if not player.get('telegram_id'):
|
||||
raise HTTPException(status_code=400, detail="Inventory not available for web users yet")
|
||||
|
||||
result = await toggle_equip(player['telegram_id'], item_data.item_db_id)
|
||||
return {"success": True, "message": result}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
84
api/main.py
@@ -33,7 +33,11 @@ from .routers import (
|
||||
crafting,
|
||||
loot,
|
||||
statistics,
|
||||
admin
|
||||
statistics,
|
||||
admin,
|
||||
quests,
|
||||
trade,
|
||||
npcs
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
@@ -79,11 +83,31 @@ async def lifespan(app: FastAPI):
|
||||
print("✅ Redis listener started")
|
||||
|
||||
# Start background tasks (distributed via Redis locks)
|
||||
tasks = await background_tasks.start_background_tasks(manager, LOCATIONS)
|
||||
tasks = await background_tasks.start_background_tasks(manager, LOCATIONS, NPCS_DATA)
|
||||
if tasks:
|
||||
print(f"✅ Started {len(tasks)} background tasks in this worker")
|
||||
else:
|
||||
print("⏭️ Background tasks running in another worker")
|
||||
|
||||
# APPLY GLOBAL QUEST UNLOCKS
|
||||
print("🔓 Applying global quest unlocks...")
|
||||
try:
|
||||
completed_quests = await db.get_completed_global_quests()
|
||||
print(f" - Found {len(completed_quests)} completed global quests")
|
||||
for quest_id in completed_quests:
|
||||
# Unlock locations
|
||||
for loc in LOCATIONS.values():
|
||||
if loc.unlocked_by == quest_id:
|
||||
loc.locked = False
|
||||
print(f" - Unlocked location: {loc.id}")
|
||||
|
||||
# Unlock interactables
|
||||
for inter in loc.interactables:
|
||||
if inter.unlocked_by == quest_id:
|
||||
inter.locked = False
|
||||
print(f" - Unlocked interactable: {inter.id} in {loc.id}")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to apply global quest unlocks: {e}")
|
||||
|
||||
yield
|
||||
|
||||
@@ -123,14 +147,53 @@ if IMAGES_DIR.exists():
|
||||
else:
|
||||
print(f"⚠️ Images directory not found: {IMAGES_DIR}")
|
||||
|
||||
# Initialize routers with game data dependencies
|
||||
# Load Quests and NPCs Data at startup
|
||||
QUESTS_DATA = {}
|
||||
NPCS_DATA = {}
|
||||
|
||||
try:
|
||||
print("🔄 Loading quests and NPCs...")
|
||||
quests_path = Path("./gamedata/quests.json")
|
||||
npcs_path = Path("./gamedata/static_npcs.json")
|
||||
|
||||
import json
|
||||
if quests_path.exists():
|
||||
with open(quests_path, "r") as f:
|
||||
q_data = json.load(f)
|
||||
QUESTS_DATA = q_data.get("quests", {})
|
||||
print(f"✅ Loaded {len(QUESTS_DATA)} quests")
|
||||
|
||||
if npcs_path.exists():
|
||||
with open(npcs_path, "r") as f:
|
||||
n_data = json.load(f)
|
||||
NPCS_DATA = n_data.get("static_npcs", {})
|
||||
print(f"✅ Loaded {len(NPCS_DATA)} static NPCs")
|
||||
|
||||
# Load Enemies / Other NPCs
|
||||
enemies_path = Path("./gamedata/npcs.json")
|
||||
if enemies_path.exists():
|
||||
with open(enemies_path, "r") as f:
|
||||
e_data = json.load(f)
|
||||
enemies = e_data.get("npcs", {})
|
||||
# Merge into NPCS_DATA
|
||||
NPCS_DATA.update(enemies)
|
||||
print(f"✅ Loaded {len(enemies)} enemies/NPCs")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading game data: {e}")
|
||||
|
||||
# Initialize routers with game data dependencies
|
||||
game_routes.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
||||
combat.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
||||
combat.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager, QUESTS_DATA)
|
||||
equipment.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||
crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||
statistics.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||
admin.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR)
|
||||
quests.init_router_dependencies(ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA, LOCATIONS)
|
||||
trade.init_router_dependencies(ITEMS_MANAGER, NPCS_DATA)
|
||||
npcs.init_router_dependencies()
|
||||
|
||||
# Include all routers
|
||||
app.include_router(auth.router)
|
||||
@@ -142,6 +205,9 @@ app.include_router(crafting.router)
|
||||
app.include_router(loot.router)
|
||||
app.include_router(statistics.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(quests.router)
|
||||
app.include_router(trade.router)
|
||||
app.include_router(npcs.router)
|
||||
|
||||
print("✅ All routers registered")
|
||||
|
||||
@@ -214,9 +280,15 @@ async def websocket_endpoint(websocket: WebSocket, token: str):
|
||||
# Keep connection alive
|
||||
while True:
|
||||
try:
|
||||
data = await websocket.receive_text()
|
||||
# Handle ping/pong or other client messages
|
||||
logger.debug(f"Received from {username}: {data}")
|
||||
data_text = await websocket.receive_text()
|
||||
try:
|
||||
data_json = json.loads(data_text)
|
||||
if data_json.get("type") == "ack":
|
||||
logger.debug(f"ACK received from {username} for msg {data_json.get('reply_to')}")
|
||||
else:
|
||||
logger.debug(f"Received from {username}: {data_text}")
|
||||
except:
|
||||
logger.debug(f"Received from {username}: {data_text}")
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pyjwt==2.8.0
|
||||
bcrypt==4.1.1
|
||||
pydantic==2.5.2
|
||||
python-multipart==0.0.6
|
||||
@@ -2,12 +2,16 @@
|
||||
Authentication router.
|
||||
Handles user registration, login, and profile retrieval.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from fastapi import APIRouter, HTTPException, Depends, status, Request
|
||||
from typing import Dict, Any
|
||||
|
||||
from ..services.helpers import get_game_message
|
||||
|
||||
from ..core.security import create_access_token, hash_password, verify_password, get_current_user
|
||||
from ..services.models import UserRegister, UserLogin
|
||||
from .. import database as db
|
||||
from ..items import items_manager
|
||||
from ..services.helpers import calculate_player_capacity, enrich_character_data
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["authentication"])
|
||||
|
||||
@@ -108,23 +112,7 @@ async def login(user: UserLogin):
|
||||
"is_premium": account.get("premium_expires_at") is not None,
|
||||
},
|
||||
"characters": [
|
||||
{
|
||||
"id": char["id"],
|
||||
"name": char["name"],
|
||||
"level": char["level"],
|
||||
"xp": char["xp"],
|
||||
"hp": char["hp"],
|
||||
"max_hp": char["max_hp"],
|
||||
"stamina": char["stamina"],
|
||||
"max_stamina": char["max_stamina"],
|
||||
"strength": char["strength"],
|
||||
"agility": char["agility"],
|
||||
"endurance": char["endurance"],
|
||||
"intellect": char["intellect"],
|
||||
"avatar_data": char.get("avatar_data"),
|
||||
"last_played_at": char.get("last_played_at"),
|
||||
"location_id": char["location_id"],
|
||||
}
|
||||
await enrich_character_data(char, items_manager)
|
||||
for char in characters
|
||||
],
|
||||
"needs_character_creation": len(characters) == 0
|
||||
@@ -186,17 +174,7 @@ async def get_account(current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"last_login_at": account.get("last_login_at"),
|
||||
},
|
||||
"characters": [
|
||||
{
|
||||
"id": char["id"],
|
||||
"name": char["name"],
|
||||
"level": char["level"],
|
||||
"xp": char["xp"],
|
||||
"hp": char["hp"],
|
||||
"max_hp": char["max_hp"],
|
||||
"location_id": char["location_id"],
|
||||
"avatar_data": char.get("avatar_data"),
|
||||
"last_played_at": char.get("last_played_at"),
|
||||
}
|
||||
await enrich_character_data(char, items_manager)
|
||||
for char in characters
|
||||
]
|
||||
}
|
||||
@@ -205,10 +183,12 @@ async def get_account(current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
@router.post("/change-email")
|
||||
async def change_email(
|
||||
request: "ChangeEmailRequest",
|
||||
req: Request,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Change account email address"""
|
||||
from ..services.models import ChangeEmailRequest
|
||||
locale = req.headers.get('Accept-Language', 'en')
|
||||
|
||||
# Get account
|
||||
account_id = current_user.get("account_id")
|
||||
@@ -250,7 +230,7 @@ async def change_email(
|
||||
# Update email
|
||||
try:
|
||||
await db.update_account_email(account_id, request.new_email)
|
||||
return {"message": "Email updated successfully", "new_email": request.new_email}
|
||||
return {"message": get_game_message('email_updated', locale), "new_email": request.new_email}
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -261,10 +241,12 @@ async def change_email(
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
request: "ChangePasswordRequest",
|
||||
req: Request,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Change account password"""
|
||||
from ..services.models import ChangePasswordRequest
|
||||
locale = req.headers.get('Accept-Language', 'en')
|
||||
|
||||
# Get account
|
||||
account_id = current_user.get("account_id")
|
||||
@@ -305,7 +287,7 @@ async def change_password(
|
||||
new_password_hash = hash_password(request.new_password)
|
||||
await db.update_account_password(account_id, new_password_hash)
|
||||
|
||||
return {"message": "Password updated successfully"}
|
||||
return {"message": get_game_message('password_updated', locale)}
|
||||
|
||||
|
||||
@router.post("/steam-login")
|
||||
@@ -367,17 +349,7 @@ async def steam_login(steam_data: Dict[str, Any]):
|
||||
"last_login_at": account.get("last_login_at")
|
||||
},
|
||||
"characters": [
|
||||
{
|
||||
"id": char["id"],
|
||||
"name": char["name"],
|
||||
"level": char["level"],
|
||||
"xp": char["xp"],
|
||||
"hp": char["hp"],
|
||||
"max_hp": char["max_hp"],
|
||||
"location_id": char["location_id"],
|
||||
"avatar_data": char.get("avatar_data"),
|
||||
"last_played_at": char.get("last_played_at")
|
||||
}
|
||||
await enrich_character_data(char, items_manager)
|
||||
for char in characters
|
||||
],
|
||||
"needs_character_creation": len(characters) == 0
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
Character management router.
|
||||
Handles character creation, selection, and deletion.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from fastapi import APIRouter, HTTPException, Depends, status, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from ..items import items_manager
|
||||
from ..services.helpers import enrich_character_data, get_game_message
|
||||
|
||||
from ..core.security import decode_token, create_access_token, security
|
||||
from ..services.models import CharacterCreate, CharacterSelect
|
||||
from .. import database as db
|
||||
|
||||
router = APIRouter(prefix="/api/characters", tags=["characters"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""List all characters for the logged-in account"""
|
||||
@@ -29,20 +31,7 @@ async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(se
|
||||
|
||||
return {
|
||||
"characters": [
|
||||
{
|
||||
"id": char["id"],
|
||||
"name": char["name"],
|
||||
"level": char["level"],
|
||||
"xp": char["xp"],
|
||||
"hp": char["hp"],
|
||||
"max_hp": char["max_hp"],
|
||||
"stamina": char["stamina"],
|
||||
"max_stamina": char["max_stamina"],
|
||||
"avatar_data": char.get("avatar_data"),
|
||||
"location_id": char["location_id"],
|
||||
"created_at": char["created_at"],
|
||||
"last_played_at": char.get("last_played_at"),
|
||||
}
|
||||
await enrich_character_data(char, items_manager)
|
||||
for char in characters
|
||||
]
|
||||
}
|
||||
@@ -51,10 +40,12 @@ async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(se
|
||||
@router.post("")
|
||||
async def create_character_endpoint(
|
||||
character: CharacterCreate,
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||
):
|
||||
"""Create a new character"""
|
||||
token = credentials.credentials
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
payload = decode_token(token)
|
||||
account_id = payload.get("account_id")
|
||||
|
||||
@@ -120,7 +111,7 @@ async def create_character_endpoint(
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Character created successfully",
|
||||
"message": get_game_message('character_created', locale),
|
||||
"character": {
|
||||
"id": new_character["id"],
|
||||
"name": new_character["name"],
|
||||
@@ -203,10 +194,12 @@ async def select_character(
|
||||
@router.delete("/{character_id}")
|
||||
async def delete_character_endpoint(
|
||||
character_id: int,
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||
):
|
||||
"""Delete a character"""
|
||||
token = credentials.credentials
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
payload = decode_token(token)
|
||||
account_id = payload.get("account_id")
|
||||
|
||||
@@ -234,5 +227,5 @@ async def delete_character_endpoint(
|
||||
await db.delete_character(character_id)
|
||||
|
||||
return {
|
||||
"message": f"Character '{character['name']}' deleted successfully"
|
||||
"message": get_game_message('character_deleted', locale, name=character['name'])
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import logging
|
||||
|
||||
from ..core.security import get_current_user, security, verify_internal_key
|
||||
from ..services.models import *
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost, get_locale_string
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
from .. import game_logic
|
||||
@@ -156,7 +156,7 @@ async def get_craftable_items(current_user: dict = Depends(get_current_user)):
|
||||
})
|
||||
|
||||
# Sort: craftable items first, then by tier, then by name
|
||||
craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], x['name']))
|
||||
craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], get_locale_string(x['name'])))
|
||||
|
||||
return {'craftable_items': craftable_items}
|
||||
|
||||
@@ -375,6 +375,7 @@ async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get
|
||||
|
||||
class UncraftItemRequest(BaseModel):
|
||||
inventory_id: int
|
||||
quantity: int = 1
|
||||
|
||||
|
||||
@router.post("/api/game/uncraft_item")
|
||||
@@ -402,6 +403,14 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
||||
|
||||
if not inv_item:
|
||||
raise HTTPException(status_code=404, detail="Item not found in inventory")
|
||||
|
||||
# Check quantity
|
||||
if request.quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="Quantity must be greater than 0")
|
||||
|
||||
current_quantity = inv_item.get('quantity', 1)
|
||||
if request.quantity > current_quantity:
|
||||
raise HTTPException(status_code=400, detail=f"Not enough items. Have {current_quantity}, requested {request.quantity}")
|
||||
|
||||
# Get item definition
|
||||
item_def = ITEMS_MANAGER.items.get(inv_item['item_id'])
|
||||
@@ -415,29 +424,50 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
||||
if not uncraft_yield:
|
||||
raise HTTPException(status_code=400, detail="No uncraft recipe found")
|
||||
|
||||
# Check tools requirement
|
||||
# Check tools requirement (once per operation? or per item?)
|
||||
# Usually tools are checked once for the operation, but durability cost might be per item.
|
||||
# Logic above for crafting consumes tool durability for the batch?
|
||||
# In craft_item above, it loops through craft_tools but seemingly only once?
|
||||
# Wait, craft_item does NOT loop for quantity because craft_item only crafts 1 at a time (request has no quantity).
|
||||
|
||||
# For uncrafting multiple, we should multiply tool cost.
|
||||
uncraft_tools = getattr(item_def, 'uncraft_tools', [])
|
||||
tools_consumed = []
|
||||
|
||||
if uncraft_tools:
|
||||
success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], uncraft_tools, inventory)
|
||||
# Scale tool cost by quantity
|
||||
scaled_uncraft_tools = []
|
||||
for tool_req in uncraft_tools:
|
||||
scaled_req = tool_req.copy()
|
||||
scaled_req['durability_cost'] = tool_req['durability_cost'] * request.quantity
|
||||
scaled_uncraft_tools.append(scaled_req)
|
||||
|
||||
success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], scaled_uncraft_tools, inventory)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
else:
|
||||
tools_consumed = []
|
||||
|
||||
# Calculate stamina cost
|
||||
stamina_cost = calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
|
||||
base_stamina_cost = calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
|
||||
total_stamina_cost = base_stamina_cost * request.quantity
|
||||
|
||||
# Check stamina
|
||||
if player['stamina'] < stamina_cost:
|
||||
raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {stamina_cost}, have {player['stamina']}")
|
||||
if player['stamina'] < total_stamina_cost:
|
||||
raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {total_stamina_cost}, have {player['stamina']}")
|
||||
|
||||
# Deduct stamina
|
||||
new_stamina = max(0, player['stamina'] - stamina_cost)
|
||||
new_stamina = max(0, player['stamina'] - total_stamina_cost)
|
||||
await db.update_player_stamina(current_user['id'], new_stamina)
|
||||
|
||||
# Remove the item from inventory
|
||||
# Use remove_inventory_row since we have the inventory ID
|
||||
await db.remove_inventory_row(inv_item['id'])
|
||||
# Update inventory item
|
||||
if request.quantity == current_quantity:
|
||||
# Remove the item row entirely
|
||||
await db.remove_inventory_row(inv_item['id'])
|
||||
else:
|
||||
# Update quantity
|
||||
await db.update_inventory_item(
|
||||
inv_item['id'],
|
||||
quantity=current_quantity - request.quantity
|
||||
)
|
||||
|
||||
# Calculate durability ratio for yield reduction
|
||||
durability_ratio = 1.0 # Default: full yield
|
||||
@@ -449,96 +479,120 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
||||
if max_durability > 0:
|
||||
durability_ratio = current_durability / max_durability
|
||||
|
||||
# Re-fetch inventory to get updated capacity after removing the item
|
||||
# Re-fetch inventory to get updated capacity
|
||||
inventory = await db.get_inventory(current_user['id'])
|
||||
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
|
||||
|
||||
# Calculate materials with loss chance and durability reduction
|
||||
# Calculate materials
|
||||
import random
|
||||
loss_chance = getattr(item_def, 'uncraft_loss_chance', 0.3)
|
||||
yield_info = {
|
||||
'base_yield': uncraft_yield,
|
||||
'loss_chance': loss_chance,
|
||||
'stamina_cost': calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
|
||||
}
|
||||
|
||||
materials_yielded_dict = {}
|
||||
materials_lost_dict = {}
|
||||
materials_dropped_dict = {}
|
||||
|
||||
# Loop for each item being uncrafted to calculate yield fairly
|
||||
for _ in range(request.quantity):
|
||||
for material in uncraft_yield:
|
||||
# Apply durability reduction first
|
||||
base_quantity = material['quantity']
|
||||
|
||||
# Calculate adjusted quantity based on durability
|
||||
adjusted_quantity = int(round(base_quantity * durability_ratio))
|
||||
|
||||
mat_def = ITEMS_MANAGER.items.get(material['item_id'])
|
||||
mat_name = mat_def.name if mat_def else material['item_id']
|
||||
|
||||
loss_key = (material['item_id'], mat_name)
|
||||
|
||||
# If durability is too low (< 10%), yield nothing for this material
|
||||
if durability_ratio < 0.1 or adjusted_quantity <= 0:
|
||||
if loss_key not in materials_lost_dict:
|
||||
materials_lost_dict[loss_key] = 0
|
||||
materials_lost_dict[loss_key] += base_quantity
|
||||
continue
|
||||
|
||||
# Roll for loss chance
|
||||
if random.random() < loss_chance:
|
||||
# Lost this material
|
||||
if loss_key not in materials_lost_dict:
|
||||
materials_lost_dict[loss_key] = 0
|
||||
materials_lost_dict[loss_key] += adjusted_quantity
|
||||
else:
|
||||
# Check if it fits in inventory (incremental check?)
|
||||
# For simplicity, check per unit or accumulate and check at end.
|
||||
# Checking per unit is safer but slower.
|
||||
# Since we are modifying inventory in loop (potentially), we should be careful.
|
||||
# Actually, we should accumulate yield then add to inventory at end to optimize DB calls?
|
||||
# But we need to check capacity.
|
||||
# Let's accumulate pending yield.
|
||||
|
||||
yield_key = (material['item_id'], mat_name, mat_def.emoji if mat_def else '📦', mat_def)
|
||||
if yield_key not in materials_yielded_dict:
|
||||
materials_yielded_dict[yield_key] = 0
|
||||
materials_yielded_dict[yield_key] += adjusted_quantity
|
||||
|
||||
# Now process the accumulated yield
|
||||
materials_yielded = []
|
||||
materials_lost = []
|
||||
materials_dropped = []
|
||||
|
||||
for material in uncraft_yield:
|
||||
# Apply durability reduction first
|
||||
base_quantity = material['quantity']
|
||||
# Convert lost dict to list
|
||||
for (item_id, name), qty in materials_lost_dict.items():
|
||||
materials_lost.append({
|
||||
'item_id': item_id,
|
||||
'name': name,
|
||||
'quantity': qty,
|
||||
'reason': 'lost_or_low_durability'
|
||||
})
|
||||
|
||||
# Calculate adjusted quantity based on durability
|
||||
# Use round() to ensure minimum yield of 1 for high durability items (e.g. 90% of 1 = 0.9 -> 1)
|
||||
adjusted_quantity = int(round(base_quantity * durability_ratio))
|
||||
# Process yield
|
||||
for (item_id, name, emoji, mat_def), qty in materials_yielded_dict.items():
|
||||
mat_weight = getattr(mat_def, 'weight', 0) * qty
|
||||
mat_volume = getattr(mat_def, 'volume', 0) * qty
|
||||
|
||||
mat_def = ITEMS_MANAGER.items.get(material['item_id'])
|
||||
# Simple check against capacity (assuming current_weight was just updated from DB)
|
||||
# Note: we might fill up mid-loop. ideally we add one by one or check total.
|
||||
# Let's check total.
|
||||
|
||||
# If durability is too low (< 10%), yield nothing for this material
|
||||
if durability_ratio < 0.1 or adjusted_quantity <= 0:
|
||||
materials_lost.append({
|
||||
'item_id': material['item_id'],
|
||||
'name': mat_def.name if mat_def else material['item_id'],
|
||||
'quantity': base_quantity,
|
||||
'reason': 'durability_too_low'
|
||||
})
|
||||
continue
|
||||
|
||||
# Roll for each material separately with loss chance
|
||||
if random.random() < loss_chance:
|
||||
# Lost this material
|
||||
materials_lost.append({
|
||||
'item_id': material['item_id'],
|
||||
'name': mat_def.name if mat_def else material['item_id'],
|
||||
'quantity': adjusted_quantity,
|
||||
'reason': 'random_loss'
|
||||
if current_weight + mat_weight <= max_weight and current_volume + mat_volume <= max_volume:
|
||||
# Fits
|
||||
await db.add_item_to_inventory(
|
||||
player_id=current_user['id'],
|
||||
item_id=item_id,
|
||||
quantity=qty
|
||||
)
|
||||
current_weight += mat_weight
|
||||
current_volume += mat_volume
|
||||
|
||||
materials_yielded.append({
|
||||
'item_id': item_id,
|
||||
'name': name,
|
||||
'emoji': emoji,
|
||||
'quantity': qty
|
||||
})
|
||||
else:
|
||||
# Check if it fits in inventory
|
||||
mat_weight = getattr(mat_def, 'weight', 0) * adjusted_quantity
|
||||
mat_volume = getattr(mat_def, 'volume', 0) * adjusted_quantity
|
||||
# Drop
|
||||
await db.drop_item_to_world(
|
||||
item_id=item_id,
|
||||
quantity=qty,
|
||||
location_id=player['location_id']
|
||||
)
|
||||
|
||||
if current_weight + mat_weight <= max_weight and current_volume + mat_volume <= max_volume:
|
||||
# Fits in inventory
|
||||
await db.add_item_to_inventory(
|
||||
player_id=current_user['id'],
|
||||
item_id=material['item_id'],
|
||||
quantity=adjusted_quantity
|
||||
)
|
||||
|
||||
# Update current capacity tracking
|
||||
current_weight += mat_weight
|
||||
current_volume += mat_volume
|
||||
|
||||
materials_yielded.append({
|
||||
'item_id': material['item_id'],
|
||||
'name': mat_def.name if mat_def else material['item_id'],
|
||||
'emoji': mat_def.emoji if mat_def else '📦',
|
||||
'quantity': adjusted_quantity
|
||||
})
|
||||
else:
|
||||
# Inventory full - drop to ground
|
||||
await db.drop_item_to_world(
|
||||
item_id=material['item_id'],
|
||||
quantity=adjusted_quantity,
|
||||
location_id=player['location_id']
|
||||
)
|
||||
|
||||
materials_dropped.append({
|
||||
'item_id': material['item_id'],
|
||||
'name': mat_def.name if mat_def else material['item_id'],
|
||||
'emoji': mat_def.emoji if mat_def else '📦',
|
||||
'quantity': adjusted_quantity
|
||||
})
|
||||
materials_dropped.append({
|
||||
'item_id': item_id,
|
||||
'name': name,
|
||||
'emoji': emoji,
|
||||
'quantity': qty
|
||||
})
|
||||
|
||||
message = f"Uncrafted {item_def.name}!"
|
||||
message = f"Uncrafted {request.quantity}x {item_def.name}!"
|
||||
if durability_ratio < 1.0:
|
||||
message += f" (Item condition reduced yield by {int((1 - durability_ratio) * 100)}%)"
|
||||
message += f" (Condition reduced yield)"
|
||||
if materials_lost:
|
||||
message += f" Lost {len(materials_lost)} material type(s)."
|
||||
message += f" Lost materials."
|
||||
if materials_dropped:
|
||||
message += f" Inventory full! Dropped {len(materials_dropped)} item(s) to the ground."
|
||||
message += f" Inventory full! Dropped items."
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
@@ -550,7 +604,7 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
||||
'tools_consumed': tools_consumed,
|
||||
'loss_chance': loss_chance,
|
||||
'durability_ratio': round(durability_ratio, 2),
|
||||
'stamina_cost': stamina_cost,
|
||||
'stamina_cost': total_stamina_cost,
|
||||
'new_stamina': new_stamina
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Equipment router.
|
||||
Auto-generated from main.py migration.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from fastapi import APIRouter, HTTPException, Depends, status, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
@@ -12,7 +12,7 @@ import logging
|
||||
|
||||
from ..core.security import get_current_user, security, verify_internal_key
|
||||
from ..services.models import *
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost, get_game_message, get_locale_string
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
from .. import game_logic
|
||||
@@ -41,10 +41,12 @@ router = APIRouter(tags=["equipment"])
|
||||
@router.post("/api/game/equip")
|
||||
async def equip_item(
|
||||
equip_req: EquipItemRequest,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Equip an item from inventory"""
|
||||
player_id = current_user['id']
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
# Get the inventory item
|
||||
inv_item = await db.get_inventory_item_by_id(equip_req.inventory_id)
|
||||
@@ -107,9 +109,9 @@ async def equip_item(
|
||||
|
||||
# Build message
|
||||
if unequipped_item_name:
|
||||
message = f"Unequipped {unequipped_item_name}, equipped {item_def.name}"
|
||||
message = get_game_message('unequip_equip', locale, old=unequipped_item_name, new=get_locale_string(item_def.name, locale))
|
||||
else:
|
||||
message = f"Equipped {item_def.name}"
|
||||
message = get_game_message('equipped', locale, item=get_locale_string(item_def.name, locale))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -122,10 +124,12 @@ async def equip_item(
|
||||
@router.post("/api/game/unequip")
|
||||
async def unequip_item(
|
||||
unequip_req: UnequipItemRequest,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Unequip an item from equipment slot"""
|
||||
player_id = current_user['id']
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
# Check if slot is valid
|
||||
valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack']
|
||||
@@ -190,7 +194,7 @@ async def unequip_item(
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Unequipped {item_def.name} (dropped to ground - inventory full)",
|
||||
"message": get_game_message('unequip_dropped', locale, item=get_locale_string(item_def.name, locale)),
|
||||
"dropped": True
|
||||
}
|
||||
|
||||
@@ -200,7 +204,7 @@ async def unequip_item(
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Unequipped {item_def.name}",
|
||||
"message": get_game_message('unequipped', locale, item=get_locale_string(item_def.name, locale)),
|
||||
"dropped": False
|
||||
}
|
||||
|
||||
@@ -241,10 +245,12 @@ async def get_equipment(current_user: dict = Depends(get_current_user)):
|
||||
@router.post("/api/game/repair_item")
|
||||
async def repair_item(
|
||||
repair_req: RepairItemRequest,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Repair an item using materials at a workbench location"""
|
||||
player_id = current_user['id']
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
# Get player's location
|
||||
player = await db.get_player_by_id(player_id)
|
||||
@@ -358,7 +364,7 @@ async def repair_item(
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Repaired {item_def.name}! Restored {repair_amount} durability.",
|
||||
"message": get_game_message('repaired_success', locale, item=get_locale_string(item_def.name, locale), amount=repair_amount),
|
||||
"item_name": item_def.name,
|
||||
"old_durability": current_durability,
|
||||
"new_durability": new_durability,
|
||||
@@ -580,6 +586,7 @@ async def get_repairable_items(current_user: dict = Depends(get_current_user)):
|
||||
# Check if player has this tool (find one with highest durability)
|
||||
tool_found = False
|
||||
tool_durability = 0
|
||||
tool_max_durability = 0
|
||||
best_tool_unique = None
|
||||
|
||||
for check_item in inventory:
|
||||
@@ -590,6 +597,7 @@ async def get_repairable_items(current_user: dict = Depends(get_current_user)):
|
||||
best_tool_unique = unique
|
||||
tool_found = True
|
||||
tool_durability = unique.get('durability', 0)
|
||||
tool_max_durability = unique.get('max_durability', 100)
|
||||
|
||||
|
||||
tools_info.append({
|
||||
@@ -598,7 +606,8 @@ async def get_repairable_items(current_user: dict = Depends(get_current_user)):
|
||||
'emoji': tool_def.emoji if tool_def else '🔧',
|
||||
'durability_cost': durability_cost,
|
||||
'has_tool': tool_found,
|
||||
'tool_durability': tool_durability
|
||||
'tool_durability': tool_durability,
|
||||
'tool_max_durability': tool_max_durability
|
||||
})
|
||||
if not tool_found:
|
||||
has_tools = False
|
||||
@@ -627,7 +636,7 @@ async def get_repairable_items(current_user: dict = Depends(get_current_user)):
|
||||
})
|
||||
|
||||
# Sort: repairable items first (can_repair=True), then by durability percent (lowest first), then by name
|
||||
repairable_items.sort(key=lambda x: (not x['can_repair'], -x['durability_percent'], x['name']))
|
||||
repairable_items.sort(key=lambda x: (not x['can_repair'], -x['durability_percent'], str(x['name'])))
|
||||
|
||||
return {'repairable_items': repairable_items}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Game Routes router.
|
||||
Auto-generated from main.py migration.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from fastapi import APIRouter, HTTPException, Depends, status, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
@@ -12,7 +12,7 @@ import logging
|
||||
|
||||
from ..core.security import get_current_user, security, verify_internal_key
|
||||
from ..services.models import *
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, get_game_message
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
from .. import game_logic
|
||||
@@ -28,11 +28,52 @@ redis_manager = None
|
||||
|
||||
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
|
||||
"""Initialize router with game data dependencies"""
|
||||
print("🔧 INITIALIZING GAME ROUTE DEPENDENCIES")
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
|
||||
LOCATIONS = locations
|
||||
ITEMS_MANAGER = items_manager
|
||||
WORLD = world
|
||||
redis_manager = redis_mgr
|
||||
|
||||
print(f"🔧 Locations keys: {list(LOCATIONS.keys())}")
|
||||
|
||||
# Load separate static NPCs
|
||||
from pathlib import Path
|
||||
try:
|
||||
# Use relative path consistent with Docker WORKDIR /app
|
||||
json_path = Path("./gamedata/static_npcs.json")
|
||||
with open(json_path, "r") as f:
|
||||
npc_data = json.load(f).get("static_npcs", {})
|
||||
print(f"🔧 Loaded static NPCs data keys: {list(npc_data.keys())}")
|
||||
|
||||
for npc_id, npc_def in npc_data.items():
|
||||
loc_id = npc_def.get("location_id")
|
||||
if loc_id and loc_id in LOCATIONS:
|
||||
# Check for duplication
|
||||
location = LOCATIONS[loc_id]
|
||||
existing = False
|
||||
for existing_npc in location.npcs:
|
||||
if isinstance(existing_npc, dict) and existing_npc.get("id") == npc_id:
|
||||
existing = True
|
||||
break
|
||||
|
||||
if not existing:
|
||||
# Inject
|
||||
location.npcs.append({
|
||||
"id": npc_id,
|
||||
"name": npc_def.get("name"), # Keep as dict/string, frontend handles localization
|
||||
"type": "npc",
|
||||
"level": 1,
|
||||
"image_path": npc_def.get("image"),
|
||||
"is_static": True,
|
||||
"trade": npc_def.get("trade", {}) # Setup trade config for frontend checks
|
||||
})
|
||||
print(f"✅ Injected static NPC {npc_id} into {loc_id}")
|
||||
else:
|
||||
print(f"⚠️ Could not inject NPC {npc_id}: Location {loc_id} not found")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to inject static NPCs: {e}")
|
||||
|
||||
router = APIRouter(tags=["game"])
|
||||
|
||||
@@ -158,10 +199,12 @@ async def _get_enriched_inventory(player_id: int):
|
||||
"unique_stats": unique_stats,
|
||||
"hp_restore": item.effects.get('hp_restore') if item.effects else None,
|
||||
"stamina_restore": item.effects.get('stamina_restore') if item.effects else None,
|
||||
"effects": item.effects,
|
||||
"damage_min": item.stats.get('damage_min') if item.stats else None,
|
||||
"damage_max": item.stats.get('damage_max') if item.stats else None,
|
||||
"stats": item.stats,
|
||||
# Workbench flags
|
||||
"value": getattr(item, 'value', 10),
|
||||
"is_repairable": is_repairable,
|
||||
"is_salvageable": is_salvageable,
|
||||
"current_durability": current_durability,
|
||||
@@ -183,6 +226,10 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
# Get player status effects
|
||||
status_effects = await db.get_player_effects(player_id)
|
||||
player['status_effects'] = status_effects
|
||||
|
||||
# Get location
|
||||
location = LOCATIONS.get(player['location_id'])
|
||||
|
||||
@@ -226,11 +273,50 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
|
||||
"encumbrance": item_def.encumbrance,
|
||||
"weapon_effects": item_def.weapon_effects if hasattr(item_def, 'weapon_effects') else {}
|
||||
}
|
||||
else:
|
||||
logger.error(f"❌ Item definition not found for equipped item: {inv_item['item_id']} (slot: {slot})")
|
||||
else:
|
||||
logger.warning(f"⚠️ Inventory item not found for equipped slot: {slot} (ID: {item_data['item_id']})")
|
||||
|
||||
if slot not in equipment:
|
||||
equipment[slot] = None
|
||||
|
||||
# Get combat state
|
||||
# Get active combat (PvE)
|
||||
combat = await db.get_active_combat(player_id)
|
||||
pvp_combat = None
|
||||
|
||||
# If no PvE combat, check for PvP combat
|
||||
if not combat:
|
||||
pvp_combat = await db.get_pvp_combat_by_player(player_id)
|
||||
if pvp_combat:
|
||||
# Format PvP combat to match frontend expectations or pass as dedicated field
|
||||
# Ideally, we pass it as 'pvp_combat' in the response and let frontend handle it,
|
||||
# OR we standardize the 'combat' field. Game.tsx seems to handle both.
|
||||
# But let's check Game.tsx or Combat.tsx props.
|
||||
# Combat.tsx expects: initialCombatData which has { combat: ..., pvp_combat: ..., is_pvp: bool }
|
||||
# If we return it in the main dict, Game.tsx passes the whole response to Combat.
|
||||
|
||||
# Enrich PvP combat with opponent data for the API response
|
||||
is_attacker = pvp_combat['attacker_character_id'] == player_id
|
||||
opponent_id = pvp_combat['defender_character_id'] if is_attacker else pvp_combat['attacker_character_id']
|
||||
opponent = await db.get_player_by_id(opponent_id)
|
||||
|
||||
if is_attacker:
|
||||
pvp_combat['attacker'] = player
|
||||
pvp_combat['defender'] = opponent
|
||||
pvp_combat['is_attacker'] = True
|
||||
else:
|
||||
pvp_combat['attacker'] = opponent
|
||||
pvp_combat['defender'] = player
|
||||
pvp_combat['is_attacker'] = False
|
||||
|
||||
# Determine if it's "combat_over" based on fled status or HP
|
||||
# This helps the frontend break out of the loop
|
||||
if pvp_combat.get('attacker_fled') or pvp_combat.get('defender_fled') or \
|
||||
pvp_combat.get('attacker_acknowledged') and pvp_combat.get('defender_acknowledged'): # Wait, if both ack, it's deleted.
|
||||
# If just fled, it's over but waiting for ack
|
||||
pass
|
||||
|
||||
if combat:
|
||||
# Ensure intent is present (handle legacy)
|
||||
if 'npc_intent' not in combat or not combat['npc_intent']:
|
||||
@@ -269,6 +355,7 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
|
||||
"tier": tier if tier is not None else None,
|
||||
"hp_restore": item.effects.get('hp_restore') if item.effects else None,
|
||||
"stamina_restore": item.effects.get('stamina_restore') if item.effects else None,
|
||||
"effects": item.effects,
|
||||
"damage_min": item.stats.get('damage_min') if item.stats else None,
|
||||
"damage_max": item.stats.get('damage_max') if item.stats else None
|
||||
})
|
||||
@@ -308,6 +395,8 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
|
||||
"inventory": inventory,
|
||||
"equipment": equipment,
|
||||
"combat": combat,
|
||||
"pvp_combat": pvp_combat,
|
||||
"is_pvp": pvp_combat is not None,
|
||||
"dropped_items": dropped_items
|
||||
}
|
||||
|
||||
@@ -320,6 +409,10 @@ async def get_player_profile(current_user: dict = Depends(get_current_user)):
|
||||
player = await db.get_player_by_id(player_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
# Get player status effects
|
||||
status_effects = await db.get_player_effects(player_id)
|
||||
player['status_effects'] = status_effects
|
||||
|
||||
# Get capacity metrics (weight/volume) using the helper function
|
||||
# We don't need the inventory array itself, just the capacity calculations
|
||||
@@ -386,8 +479,11 @@ async def spend_stat_point(
|
||||
|
||||
|
||||
@router.get("/api/game/location")
|
||||
async def get_current_location(current_user: dict = Depends(get_current_user)):
|
||||
async def get_current_location(request: Request, current_user: dict = Depends(get_current_user)):
|
||||
"""Get current location information"""
|
||||
# Extract locale from Accept-Language header
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
location_id = current_user['location_id']
|
||||
location = LOCATIONS.get(location_id)
|
||||
|
||||
@@ -406,6 +502,10 @@ async def get_current_location(current_user: dict = Depends(get_current_user)):
|
||||
# Format interactables for response with cooldown info
|
||||
interactables_data = []
|
||||
for interactable in location.interactables:
|
||||
# Check if locked
|
||||
if getattr(interactable, 'locked', False):
|
||||
continue
|
||||
|
||||
actions_data = []
|
||||
for action in interactable.actions:
|
||||
# Check cooldown status for this specific action
|
||||
@@ -424,7 +524,7 @@ async def get_current_location(current_user: dict = Depends(get_current_user)):
|
||||
"id": action.id,
|
||||
"name": action.label,
|
||||
"stamina_cost": action.stamina_cost,
|
||||
"description": f"Costs {action.stamina_cost} stamina",
|
||||
"description": get_game_message('costs_stamina', locale, cost=action.stamina_cost),
|
||||
"on_cooldown": is_on_cooldown,
|
||||
"cooldown_remaining": remaining_cooldown
|
||||
})
|
||||
@@ -460,6 +560,10 @@ async def get_current_location(current_user: dict = Depends(get_current_user)):
|
||||
destination_id = location.exits[direction]
|
||||
destination_loc = LOCATIONS.get(destination_id)
|
||||
|
||||
# Check if destination is locked
|
||||
if destination_loc and getattr(destination_loc, 'locked', False):
|
||||
continue
|
||||
|
||||
if destination_loc:
|
||||
# Calculate real distance using coordinates
|
||||
distance = calculate_distance(
|
||||
@@ -511,8 +615,12 @@ async def get_current_location(current_user: dict = Depends(get_current_user)):
|
||||
"name": npc.get('name', 'Unknown NPC'),
|
||||
"type": npc.get('type', 'npc'),
|
||||
"level": npc.get('level'),
|
||||
"is_wandering": False
|
||||
"is_wandering": False,
|
||||
"image_path": npc.get('image_path'),
|
||||
"is_static": npc.get('is_static', False),
|
||||
"trade": npc.get('trade')
|
||||
})
|
||||
|
||||
else:
|
||||
npcs_data.append({
|
||||
"id": npc,
|
||||
@@ -521,6 +629,9 @@ async def get_current_location(current_user: dict = Depends(get_current_user)):
|
||||
"is_wandering": False
|
||||
})
|
||||
|
||||
# Debug logging for missing NPCs - UNCONDITIONAL
|
||||
logger.info(f"📍 Requested Location: {location.id}, NPCs: {[n.get('id') for n in npcs_data]}")
|
||||
|
||||
# Enrich dropped items with metadata - DON'T consolidate unique items!
|
||||
items_dict = {}
|
||||
for item in dropped_items:
|
||||
@@ -677,10 +788,11 @@ async def get_current_location(current_user: dict = Depends(get_current_user)):
|
||||
corpses_data.append({
|
||||
"id": f"npc_{corpse['id']}",
|
||||
"type": "npc",
|
||||
"name": f"{npc_def.name if npc_def else corpse['npc_id']} Corpse",
|
||||
"name": f"{get_locale_string(npc_def.name, locale) if npc_def else corpse['npc_id']} Corpse",
|
||||
"emoji": "💀",
|
||||
"loot_count": len(loot),
|
||||
"timestamp": corpse['death_timestamp']
|
||||
"timestamp": corpse['death_timestamp'],
|
||||
"image_path": npc_def.image_path if npc_def else None
|
||||
})
|
||||
|
||||
for corpse in player_corpses:
|
||||
@@ -714,6 +826,7 @@ async def get_current_location(current_user: dict = Depends(get_current_user)):
|
||||
@router.post("/api/game/move")
|
||||
async def move(
|
||||
move_req: MoveRequest,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Move player in a direction"""
|
||||
@@ -748,13 +861,17 @@ async def move(
|
||||
if cooldown_remaining > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"You must wait {int(cooldown_remaining)} seconds before moving again."
|
||||
detail=get_game_message('move_cooldown', locale, seconds=int(cooldown_remaining))
|
||||
)
|
||||
|
||||
# Extract locale from Accept-Language header
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
success, message, new_location_id, stamina_cost, distance = await game_logic.move_player(
|
||||
current_user['id'],
|
||||
move_req.direction,
|
||||
LOCATIONS
|
||||
LOCATIONS,
|
||||
locale
|
||||
)
|
||||
|
||||
if not success:
|
||||
@@ -849,7 +966,8 @@ async def move(
|
||||
response = {
|
||||
"success": True,
|
||||
"message": message,
|
||||
"new_location_id": new_location_id
|
||||
"new_location_id": new_location_id,
|
||||
"new_location_name": new_location.name if new_location else "Unknown" # Add location name for frontend
|
||||
}
|
||||
|
||||
# Add encounter info if triggered
|
||||
@@ -857,7 +975,7 @@ async def move(
|
||||
response["encounter"] = {
|
||||
"triggered": True,
|
||||
"enemy_id": enemy_id,
|
||||
"message": f"⚠️ An enemy ambushes you upon arrival!",
|
||||
"message": get_game_message('enemy_ambush', locale),
|
||||
"combat": combat_data
|
||||
}
|
||||
|
||||
@@ -868,7 +986,7 @@ async def move(
|
||||
{
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{player['name']} left the area",
|
||||
"message": get_game_message('player_left', locale, player_name=player['name']),
|
||||
"action": "player_left",
|
||||
"player_id": current_user['id'],
|
||||
"player_name": player['name']
|
||||
@@ -884,7 +1002,7 @@ async def move(
|
||||
{
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{player['name']} arrived",
|
||||
"message": get_game_message('player_arrived', locale, player_name=player['name']),
|
||||
"action": "player_arrived",
|
||||
"player_id": current_user['id'],
|
||||
"player_name": player['name'],
|
||||
@@ -917,8 +1035,11 @@ async def move(
|
||||
|
||||
|
||||
@router.post("/api/game/inspect")
|
||||
async def inspect(current_user: dict = Depends(get_current_user)):
|
||||
async def inspect(request: Request, current_user: dict = Depends(get_current_user)):
|
||||
"""Inspect the current area"""
|
||||
# Extract locale from Accept-Language header
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
location_id = current_user['location_id']
|
||||
location = LOCATIONS.get(location_id)
|
||||
|
||||
@@ -934,7 +1055,8 @@ async def inspect(current_user: dict = Depends(get_current_user)):
|
||||
message = await game_logic.inspect_area(
|
||||
current_user['id'],
|
||||
location,
|
||||
{} # interactables_data - not needed with new structure
|
||||
{}, # interactables_data - not needed with new structure
|
||||
locale
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -946,15 +1068,19 @@ async def inspect(current_user: dict = Depends(get_current_user)):
|
||||
@router.post("/api/game/interact")
|
||||
async def interact(
|
||||
interact_req: InteractRequest,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Interact with an object"""
|
||||
"""Interact with an object in the game world"""
|
||||
# Extract locale from Accept-Language header
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
# Check if player is in combat
|
||||
combat = await db.get_active_combat(current_user['id'])
|
||||
if combat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot interact with objects while in combat"
|
||||
detail=get_game_message('interact_in_combat', locale)
|
||||
)
|
||||
|
||||
location_id = current_user['location_id']
|
||||
@@ -971,7 +1097,8 @@ async def interact(
|
||||
interact_req.interactable_id,
|
||||
interact_req.action_id,
|
||||
location,
|
||||
ITEMS_MANAGER
|
||||
ITEMS_MANAGER,
|
||||
locale
|
||||
)
|
||||
|
||||
if not result['success']:
|
||||
@@ -1021,7 +1148,7 @@ async def interact(
|
||||
"instance_id": interact_req.interactable_id,
|
||||
"action_id": interact_req.action_id,
|
||||
"cooldown_remaining": cooldown_remaining,
|
||||
"message": f"{current_user['name']} used {action_display} on {interactable_name}"
|
||||
"message": get_game_message('interactable_cooldown', locale, user=current_user['name'], interactable=get_locale_string(interactable_name, locale), action=get_locale_string(action_display, locale)),
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
@@ -1030,9 +1157,12 @@ async def interact(
|
||||
return result
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/game/use_item")
|
||||
async def use_item(
|
||||
use_req: UseItemRequest,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Use an item from inventory"""
|
||||
@@ -1045,10 +1175,14 @@ async def use_item(
|
||||
combat = await db.get_active_combat(current_user['id'])
|
||||
in_combat = combat is not None
|
||||
|
||||
# Extract locale from Accept-Language header
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
result = await game_logic.use_item(
|
||||
current_user['id'],
|
||||
use_req.item_id,
|
||||
ITEMS_MANAGER
|
||||
ITEMS_MANAGER,
|
||||
locale
|
||||
)
|
||||
|
||||
if not result['success']:
|
||||
@@ -1068,10 +1202,10 @@ async def use_item(
|
||||
npc_damage = int(npc_damage * 1.5)
|
||||
|
||||
new_player_hp = max(0, player['hp'] - npc_damage)
|
||||
combat_message = f"\n{npc_def.name} attacks for {npc_damage} damage!"
|
||||
combat_message = get_game_message('combat_enemy_attack', locale, name=npc_def.name, damage=npc_damage)
|
||||
|
||||
if new_player_hp <= 0:
|
||||
combat_message += "\nYou have been defeated!"
|
||||
combat_message += get_game_message('combat_defeated', locale)
|
||||
await db.update_player(current_user['id'], hp=0, is_dead=True)
|
||||
await db.end_combat(current_user['id'])
|
||||
result['combat_over'] = True
|
||||
@@ -1095,46 +1229,54 @@ async def use_item(
|
||||
'tier': inv_item.get('tier')
|
||||
})
|
||||
|
||||
# Store minimal data in database
|
||||
db_items = json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
|
||||
# Only create corpse if player has items
|
||||
corpse_data = None
|
||||
if inventory_items:
|
||||
# Store minimal data in database
|
||||
db_items = json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
|
||||
|
||||
logger.info(f"Creating player corpse for {player['name']} at {player['location_id']} with {len(inventory_items)} items")
|
||||
|
||||
corpse_id = await db.create_player_corpse(
|
||||
player_name=player['name'],
|
||||
location_id=player['location_id'],
|
||||
items=db_items
|
||||
)
|
||||
|
||||
logger.info(f"Successfully created player corpse: ID={corpse_id}, player={player['name']}, location={player['location_id']}, items_count={len(inventory_items)}")
|
||||
|
||||
# Clear player's inventory (items are now in corpse)
|
||||
await db.clear_inventory(current_user['id'])
|
||||
|
||||
# Build corpse data for broadcast
|
||||
corpse_data = {
|
||||
"id": f"player_{corpse_id}",
|
||||
"type": "player",
|
||||
"name": f"{player['name']}'s Corpse",
|
||||
"emoji": "⚰️",
|
||||
"player_name": player['name'],
|
||||
"loot_count": len(inventory_items),
|
||||
"items": inventory_items, # Full item list for UI
|
||||
"timestamp": time_module.time()
|
||||
}
|
||||
else:
|
||||
logger.info(f"Player {player['name']} died (use_item combat) with no items, skipping corpse creation")
|
||||
|
||||
logger.info(f"Creating player corpse for {player['name']} at {player['location_id']} with {len(inventory_items)} items")
|
||||
|
||||
corpse_id = await db.create_player_corpse(
|
||||
player_name=player['name'],
|
||||
location_id=player['location_id'],
|
||||
items=db_items
|
||||
)
|
||||
|
||||
logger.info(f"Successfully created player corpse: ID={corpse_id}, player={player['name']}, location={player['location_id']}, items_count={len(inventory_items)}")
|
||||
|
||||
# Clear player's inventory (items are now in corpse)
|
||||
await db.clear_inventory(current_user['id'])
|
||||
|
||||
# Build corpse data for broadcast
|
||||
corpse_data = {
|
||||
"id": f"player_{corpse_id}",
|
||||
"type": "player",
|
||||
"name": f"{player['name']}'s Corpse",
|
||||
"emoji": "⚰️",
|
||||
"player_name": player['name'],
|
||||
"loot_count": len(inventory_items),
|
||||
"items": inventory_items, # Full item list for UI
|
||||
"timestamp": time_module.time()
|
||||
}
|
||||
|
||||
# Broadcast to location that player died and corpse appeared
|
||||
# Broadcast to location that player died (and corpse if created)
|
||||
logger.info(f"Broadcasting player_died to location {player['location_id']} for player {player['name']}")
|
||||
broadcast_data = {
|
||||
"message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']),
|
||||
"action": "player_died",
|
||||
"player_id": player['id']
|
||||
}
|
||||
if corpse_data:
|
||||
broadcast_data["corpse"] = corpse_data
|
||||
|
||||
await manager.send_to_location(
|
||||
location_id=player['location_id'],
|
||||
message={
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{player['name']} was defeated in combat",
|
||||
"action": "player_died",
|
||||
"player_id": player['id'],
|
||||
"corpse": corpse_data # Send full corpse data
|
||||
},
|
||||
"data": broadcast_data,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
},
|
||||
exclude_player_id=current_user['id']
|
||||
@@ -1154,15 +1296,19 @@ async def use_item(
|
||||
@router.post("/api/game/pickup")
|
||||
async def pickup(
|
||||
pickup_req: PickupItemRequest,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Pick up an item from the ground"""
|
||||
# Extract locale from Accept-Language header
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
# Get item details for broadcast BEFORE picking it up (it will be removed from DB)
|
||||
# pickup_req.item_id is the dropped_item database ID, not the item_id string
|
||||
dropped_item = await db.get_dropped_item(pickup_req.item_id)
|
||||
if dropped_item:
|
||||
item_def = ITEMS_MANAGER.get_item(dropped_item['item_id'])
|
||||
item_name = item_def.name if item_def else dropped_item['item_id']
|
||||
item_name = get_locale_string(item_def.name, locale) if item_def else dropped_item['item_id']
|
||||
else:
|
||||
item_name = "item"
|
||||
|
||||
@@ -1171,7 +1317,8 @@ async def pickup(
|
||||
pickup_req.item_id,
|
||||
current_user['location_id'],
|
||||
pickup_req.quantity,
|
||||
ITEMS_MANAGER
|
||||
ITEMS_MANAGER,
|
||||
locale
|
||||
)
|
||||
|
||||
if not result['success']:
|
||||
@@ -1191,7 +1338,7 @@ async def pickup(
|
||||
{
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{player['name']} picked up {quantity}x {item_name}",
|
||||
"message": f"{player['name']} {get_game_message('picked_up', locale).lower()} {quantity}x {item_name}",
|
||||
"action": "item_picked_up"
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
@@ -1313,6 +1460,7 @@ async def get_inventory(current_user: dict = Depends(get_current_user)):
|
||||
@router.post("/api/game/item/drop")
|
||||
async def drop_item(
|
||||
drop_req: dict,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Drop an item from inventory"""
|
||||
@@ -1320,6 +1468,9 @@ async def drop_item(
|
||||
item_id = drop_req.get('item_id') # This is the item_id string like "energy_bar"
|
||||
quantity = drop_req.get('quantity', 1)
|
||||
|
||||
# Extract locale from Accept-Language header
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
# Get player to know their location
|
||||
player = await db.get_player_by_id(player_id)
|
||||
if not player:
|
||||
@@ -1328,10 +1479,21 @@ async def drop_item(
|
||||
# Get inventory item by item_id (string), not database id
|
||||
inventory = await db.get_inventory(player_id)
|
||||
inv_item = None
|
||||
for item in inventory:
|
||||
if item['item_id'] == item_id:
|
||||
inv_item = item
|
||||
break
|
||||
|
||||
# If inventory_id is provided, use it to find precise item
|
||||
inventory_id = drop_req.get('inventory_id')
|
||||
|
||||
if inventory_id:
|
||||
for item in inventory:
|
||||
if item['id'] == inventory_id:
|
||||
inv_item = item
|
||||
break
|
||||
else:
|
||||
# Fallback to legacy behavior (first matching item_id)
|
||||
for item in inventory:
|
||||
if item['item_id'] == item_id:
|
||||
inv_item = item
|
||||
break
|
||||
|
||||
if not inv_item:
|
||||
raise HTTPException(status_code=404, detail="Item not found in inventory")
|
||||
@@ -1377,7 +1539,7 @@ async def drop_item(
|
||||
message={
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{player['name']} dropped {item_def.emoji} {item_def.name} x{quantity}",
|
||||
"message": get_game_message('dropped_item_success', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=quantity).replace('You', player['name']).replace('Has tirado', f"{player['name']} ha tirado"),
|
||||
"action": "item_dropped"
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
@@ -1387,5 +1549,5 @@ async def drop_item(
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Dropped {item_def.emoji} {item_def.name} x{quantity}"
|
||||
"message": get_game_message('dropped_item_success', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=quantity)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
Loot router.
|
||||
Auto-generated from main.py migration.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from fastapi import APIRouter, HTTPException, Depends, status, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
@@ -12,7 +12,7 @@ import logging
|
||||
|
||||
from ..core.security import get_current_user, security, verify_internal_key
|
||||
from ..services.models import *
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, get_game_message
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
from .. import game_logic
|
||||
@@ -42,6 +42,7 @@ router = APIRouter(tags=["loot"])
|
||||
@router.get("/api/game/corpse/{corpse_id}")
|
||||
async def get_corpse_details(
|
||||
corpse_id: str,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get detailed information about a corpse's lootable items"""
|
||||
@@ -50,6 +51,9 @@ async def get_corpse_details(
|
||||
sys.path.insert(0, '/app')
|
||||
from data.npcs import NPCS
|
||||
|
||||
# Extract locale
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
# Parse corpse ID
|
||||
corpse_type, corpse_db_id = corpse_id.split('_', 1)
|
||||
corpse_db_id = int(corpse_db_id)
|
||||
@@ -58,7 +62,22 @@ async def get_corpse_details(
|
||||
|
||||
# Get player's inventory to check available tools
|
||||
inventory = await db.get_inventory(player['id'])
|
||||
available_tools = set([item['item_id'] for item in inventory])
|
||||
# Map item_id to max durability found in inventory for that item
|
||||
tools_durability = {}
|
||||
for item in inventory:
|
||||
item_id = item['item_id']
|
||||
durability = 0
|
||||
|
||||
# Helper to get actual durability from unique item data
|
||||
if item.get('unique_item_id'):
|
||||
unique_item = await db.get_unique_item(item['unique_item_id'])
|
||||
if unique_item:
|
||||
durability = unique_item.get('durability', 0)
|
||||
|
||||
if item_id not in tools_durability or durability > tools_durability[item_id]:
|
||||
tools_durability[item_id] = durability
|
||||
|
||||
available_tools = set(tools_durability.keys())
|
||||
|
||||
if corpse_type == 'npc':
|
||||
# Get NPC corpse
|
||||
@@ -76,15 +95,24 @@ async def get_corpse_details(
|
||||
loot_items = []
|
||||
for idx, loot_item in enumerate(loot_remaining):
|
||||
required_tool = loot_item.get('required_tool')
|
||||
durability_cost = loot_item.get('tool_durability_cost', 5)
|
||||
item_def = ITEMS_MANAGER.get_item(loot_item['item_id'])
|
||||
|
||||
has_tool = required_tool is None or required_tool in available_tools
|
||||
has_tool = True
|
||||
if required_tool:
|
||||
if required_tool not in tools_durability:
|
||||
has_tool = False
|
||||
elif tools_durability[required_tool] < durability_cost:
|
||||
has_tool = False
|
||||
|
||||
tool_def = ITEMS_MANAGER.get_item(required_tool) if required_tool else None
|
||||
|
||||
loot_items.append({
|
||||
'index': idx,
|
||||
'item_id': loot_item['item_id'],
|
||||
'item_name': item_def.name if item_def else loot_item['item_id'],
|
||||
'description': item_def.description if item_def else None,
|
||||
'image_path': item_def.image_path if item_def else None,
|
||||
'emoji': item_def.emoji if item_def else '📦',
|
||||
'quantity_min': loot_item['quantity_min'],
|
||||
'quantity_max': loot_item['quantity_max'],
|
||||
@@ -99,7 +127,7 @@ async def get_corpse_details(
|
||||
return {
|
||||
'corpse_id': corpse_id,
|
||||
'type': 'npc',
|
||||
'name': f"{npc_def.name if npc_def else corpse['npc_id']} Corpse",
|
||||
'name': get_game_message('corpse_name_npc', locale, name=get_locale_string(npc_def.name, locale) if npc_def else corpse['npc_id']),
|
||||
'loot_items': loot_items,
|
||||
'total_items': len(loot_items)
|
||||
}
|
||||
@@ -125,6 +153,8 @@ async def get_corpse_details(
|
||||
'index': idx,
|
||||
'item_id': item['item_id'],
|
||||
'item_name': item_def.name if item_def else item['item_id'],
|
||||
'description': item_def.description if item_def else None,
|
||||
'image_path': item_def.image_path if item_def else None,
|
||||
'emoji': item_def.emoji if item_def else '📦',
|
||||
'quantity_min': item['quantity'],
|
||||
'quantity_max': item['quantity'],
|
||||
@@ -137,7 +167,7 @@ async def get_corpse_details(
|
||||
return {
|
||||
'corpse_id': corpse_id,
|
||||
'type': 'player',
|
||||
'name': f"{corpse['player_name']}'s Corpse",
|
||||
'name': get_game_message('corpse_name_player', locale, name=corpse['player_name']),
|
||||
'loot_items': loot_items,
|
||||
'total_items': len(loot_items)
|
||||
}
|
||||
@@ -149,6 +179,7 @@ async def get_corpse_details(
|
||||
@router.post("/api/game/loot_corpse")
|
||||
async def loot_corpse(
|
||||
req: LootCorpseRequest,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Loot a corpse (NPC or player) - can loot specific item by index or all items"""
|
||||
@@ -158,6 +189,9 @@ async def loot_corpse(
|
||||
sys.path.insert(0, '/app')
|
||||
from data.npcs import NPCS
|
||||
|
||||
# Extract locale
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
# Parse corpse ID
|
||||
corpse_type, corpse_db_id = req.corpse_id.split('_', 1)
|
||||
corpse_db_id = int(corpse_db_id)
|
||||
@@ -310,26 +344,26 @@ async def loot_corpse(
|
||||
message_parts = []
|
||||
for item in looted_items:
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
item_name = item_def.name if item_def else item['item_id']
|
||||
item_name = get_locale_string(item_def.name, locale) if item_def else item['item_id']
|
||||
message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
|
||||
|
||||
dropped_parts = []
|
||||
for item in dropped_items:
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
item_name = item_def.name if item_def else item['item_id']
|
||||
item_name = get_locale_string(item_def.name, locale) if item_def else item['item_id']
|
||||
dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
|
||||
|
||||
message = ""
|
||||
if message_parts:
|
||||
message = "Looted: " + ", ".join(message_parts)
|
||||
message = get_game_message('looted_items_start', locale) + ", ".join(message_parts)
|
||||
if dropped_parts:
|
||||
if message:
|
||||
message += "\n"
|
||||
message += "⚠️ Backpack full! Dropped on ground: " + ", ".join(dropped_parts)
|
||||
message += get_game_message('backpack_full_drop', locale) + ", ".join(dropped_parts)
|
||||
if not message_parts and not dropped_parts:
|
||||
message = "Nothing could be looted"
|
||||
message = get_game_message('nothing_looted', locale)
|
||||
if remaining_loot and req.item_index is None:
|
||||
message += f"\n{len(remaining_loot)} item(s) require tools to extract"
|
||||
message += "\n" + get_game_message('items_require_tools', locale, count=len(remaining_loot))
|
||||
|
||||
# Broadcast to location about corpse looting
|
||||
if len(remaining_loot) == 0:
|
||||
@@ -339,7 +373,7 @@ async def loot_corpse(
|
||||
message={
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{player['name']} fully looted an NPC corpse",
|
||||
"message": get_game_message('full_loot_broadcast', locale, player_name=player['name']),
|
||||
"action": "corpse_looted"
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
@@ -438,24 +472,24 @@ async def loot_corpse(
|
||||
message_parts = []
|
||||
for item in looted_items:
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
item_name = item_def.name if item_def else item['item_id']
|
||||
item_name = get_locale_string(item_def.name, locale) if item_def else item['item_id']
|
||||
message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
|
||||
|
||||
dropped_parts = []
|
||||
for item in dropped_items:
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
item_name = item_def.name if item_def else item['item_id']
|
||||
item_name = get_locale_string(item_def.name, locale) if item_def else item['item_id']
|
||||
dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
|
||||
|
||||
message = ""
|
||||
if message_parts:
|
||||
message = "Looted: " + ", ".join(message_parts)
|
||||
message = get_game_message('looted_items_start', locale) + ", ".join(message_parts)
|
||||
if dropped_parts:
|
||||
if message:
|
||||
message += "\n"
|
||||
message += "⚠️ Backpack full! Dropped on ground: " + ", ".join(dropped_parts)
|
||||
message += get_game_message('backpack_full_drop', locale) + ", ".join(dropped_parts)
|
||||
if not message_parts and not dropped_parts:
|
||||
message = "Nothing could be looted"
|
||||
message = get_game_message('nothing_looted', locale)
|
||||
|
||||
# Broadcast to location about corpse looting
|
||||
if len(remaining_items) == 0:
|
||||
@@ -465,7 +499,7 @@ async def loot_corpse(
|
||||
message={
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{player['name']} fully looted {corpse['player_name']}'s corpse",
|
||||
"message": get_game_message('player_corpse_emptied_broadcast', locale, player_name=player['name'], corpse_name=corpse['player_name']),
|
||||
"action": "player_corpse_emptied",
|
||||
"corpse_id": req.corpse_id
|
||||
},
|
||||
@@ -480,7 +514,7 @@ async def loot_corpse(
|
||||
message={
|
||||
"type": "location_update",
|
||||
"data": {
|
||||
"message": f"{player['name']} looted from {corpse['player_name']}'s corpse",
|
||||
"message": get_game_message('player_corpse_looted_broadcast', locale, player_name=player['name'], corpse_name=corpse['player_name']),
|
||||
"action": "player_corpse_looted",
|
||||
"corpse_id": req.corpse_id,
|
||||
"remaining_items": remaining_items,
|
||||
|
||||
54
api/routers/npcs.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Dict, List, Any
|
||||
import json
|
||||
import logging
|
||||
from ..core.security import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/npcs",
|
||||
tags=["npcs"],
|
||||
responses={404: {"description": "Not found"}},
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
NPCS_DATA = {}
|
||||
|
||||
def init_router_dependencies():
|
||||
global NPCS_DATA
|
||||
try:
|
||||
# Use relative path consistent with Docker WORKDIR /app
|
||||
json_path = Path("./gamedata/static_npcs.json")
|
||||
with open(json_path, "r") as f:
|
||||
data = json.load(f)
|
||||
NPCS_DATA = data.get("static_npcs", {})
|
||||
logger.info(f"✅ Loaded {len(NPCS_DATA)} static NPCs")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load static_npcs.json: {e}")
|
||||
NPCS_DATA = {}
|
||||
|
||||
@router.get("/location/{location_id}")
|
||||
async def get_npcs_at_location(location_id: str):
|
||||
"""Get all static NPCs at a location"""
|
||||
result = []
|
||||
for npc_id, npc_def in NPCS_DATA.items():
|
||||
if npc_def.get('location_id') == location_id:
|
||||
result.append(npc_def)
|
||||
return result
|
||||
|
||||
@router.get("/{npc_id}/dialog")
|
||||
async def get_npc_dialog(npc_id: str, current_user: dict = Depends(get_current_user)):
|
||||
"""Get dialog options for an NPC"""
|
||||
npc_def = NPCS_DATA.get(npc_id)
|
||||
if not npc_def:
|
||||
raise HTTPException(status_code=404, detail="NPC not found")
|
||||
|
||||
dialog = npc_def.get('dialog', {})
|
||||
|
||||
# Enrich with quest offers?
|
||||
# Ideally checking available quests from quests.json where river_id == npc_id
|
||||
|
||||
return dialog
|
||||
618
api/routers/quests.py
Normal file
@@ -0,0 +1,618 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from typing import Dict, List, Any, Optional
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from ..core.websockets import manager
|
||||
from ..core.security import get_current_user
|
||||
from .. import database as db
|
||||
from .. import game_logic
|
||||
from ..items import ItemsManager
|
||||
from ..services.helpers import get_locale_string
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/quests",
|
||||
tags=["quests"],
|
||||
responses={404: {"description": "Not found"}},
|
||||
)
|
||||
|
||||
# Request Models
|
||||
class HistoryParams:
|
||||
page: int = 1
|
||||
page_size: int = 20
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Dependencies
|
||||
QUESTS_DATA = {}
|
||||
NPCS_DATA = {}
|
||||
LOCATIONS_DATA = {}
|
||||
|
||||
def init_router_dependencies(items_manager: ItemsManager, quests_data=None, npcs_data=None, locations_data=None):
|
||||
global ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA, LOCATIONS_DATA
|
||||
ITEMS_MANAGER = items_manager
|
||||
if quests_data:
|
||||
QUESTS_DATA = quests_data
|
||||
if npcs_data:
|
||||
NPCS_DATA = npcs_data
|
||||
if locations_data:
|
||||
LOCATIONS_DATA = locations_data
|
||||
|
||||
@router.get("/history")
|
||||
async def get_quest_history_endpoint(
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get completed quest history with pagination"""
|
||||
character_id = current_user['id']
|
||||
history = await db.get_quest_history(character_id, page=page, page_size=limit)
|
||||
|
||||
# Enrich with quest definitions
|
||||
enriched_data = []
|
||||
for entry in history['data']:
|
||||
quest_def = QUESTS_DATA.get(entry['quest_id'])
|
||||
if quest_def:
|
||||
# Merge entry data with quest def
|
||||
item = dict(entry)
|
||||
item['title'] = quest_def.get('title')
|
||||
item['description'] = quest_def.get('description')
|
||||
item['type'] = quest_def.get('type')
|
||||
item['objectives'] = quest_def.get('objectives') # Fix: Copy objectives
|
||||
|
||||
# Enrich with giver info
|
||||
if quest_def.get('giver_id'):
|
||||
giver = NPCS_DATA.get(quest_def['giver_id'])
|
||||
if giver:
|
||||
item['giver_name'] = giver.get('name')
|
||||
item['giver_image'] = giver.get('image')
|
||||
|
||||
# Get Location Name
|
||||
if giver.get('location_id'):
|
||||
loc = LOCATIONS_DATA.get(giver['location_id'])
|
||||
if loc:
|
||||
item['giver_location_name'] = loc.name
|
||||
else:
|
||||
item['giver_location_name'] = giver['location_id']
|
||||
|
||||
enriched_data.append(item)
|
||||
else:
|
||||
# Fallback if quest def removed?
|
||||
enriched_data.append(entry)
|
||||
|
||||
# 2nd pass: Enrich objectives and rewards for all items in enriched_data
|
||||
final_data = []
|
||||
for q_data in enriched_data:
|
||||
# ENRICH OBJECTIVES WITH NAMES
|
||||
if 'objectives' in q_data:
|
||||
enriched_objs = []
|
||||
for obj in q_data['objectives']:
|
||||
new_obj = dict(obj)
|
||||
target = obj.get('target')
|
||||
if obj.get('type') == 'kill_count':
|
||||
npc = NPCS_DATA.get(target)
|
||||
if npc:
|
||||
new_obj['target_name'] = npc.get('name')
|
||||
else:
|
||||
logger.warning(f"NPC not found for target: {target}")
|
||||
elif obj.get('type') == 'item_delivery':
|
||||
item = ITEMS_MANAGER.get_item(target)
|
||||
if item:
|
||||
new_obj['target_name'] = item.name
|
||||
else:
|
||||
logger.warning(f"Item not found for target: {target}")
|
||||
enriched_objs.append(new_obj)
|
||||
q_data['objectives'] = enriched_objs
|
||||
|
||||
# ENRICH REWARDS WITH NAMES
|
||||
# For history, rewards might be stored in 'rewards' json.
|
||||
if 'rewards' in q_data and 'items' in q_data['rewards']:
|
||||
enriched_items = {}
|
||||
for item_id, qty in q_data['rewards']['items'].items():
|
||||
item = ITEMS_MANAGER.get_item(item_id)
|
||||
name = item.name if item else item_id
|
||||
enriched_items[item_id] = {'qty': qty, 'name': name}
|
||||
|
||||
q_data['reward_items_details'] = enriched_items
|
||||
|
||||
final_data.append(q_data)
|
||||
|
||||
history['data'] = final_data
|
||||
return history
|
||||
|
||||
|
||||
@router.get("/active")
|
||||
async def get_active_quests(current_user: dict = Depends(get_current_user)):
|
||||
"""Get all active quests for the character"""
|
||||
character_id = current_user['id']
|
||||
quests = await db.get_character_quests(character_id)
|
||||
|
||||
result = []
|
||||
for q in quests:
|
||||
quest_def = QUESTS_DATA.get(q['quest_id'])
|
||||
if not quest_def:
|
||||
continue
|
||||
|
||||
# Enrich with static data
|
||||
q_data = dict(q)
|
||||
q_data['start_at'] = q['started_at'] # Consistency
|
||||
q_data.update(quest_def)
|
||||
|
||||
# Calculate cooldown status for repeatable quests
|
||||
if quest_def.get('repeatable') and q['cooldown_expires_at']:
|
||||
if time.time() < q['cooldown_expires_at']:
|
||||
q_data['on_cooldown'] = True
|
||||
q_data['cooldown_remaining'] = int(q['cooldown_expires_at'] - time.time())
|
||||
else:
|
||||
q_data['on_cooldown'] = False
|
||||
|
||||
# Global Quest Progress
|
||||
if quest_def.get('type') == 'global':
|
||||
g_quest = await db.get_global_quest(q['quest_id'])
|
||||
if g_quest:
|
||||
q_data['global_progress'] = g_quest.get('global_progress', {})
|
||||
q_data['global_is_completed'] = g_quest.get('is_completed', False)
|
||||
|
||||
# Enrich with giver info
|
||||
if quest_def.get('giver_id'):
|
||||
giver = NPCS_DATA.get(quest_def['giver_id'])
|
||||
if giver:
|
||||
q_data['giver_name'] = giver.get('name')
|
||||
q_data['giver_image'] = giver.get('image')
|
||||
|
||||
# Get Location Name
|
||||
if giver.get('location_id'):
|
||||
loc = LOCATIONS_DATA.get(giver['location_id'])
|
||||
if loc:
|
||||
q_data['giver_location_name'] = loc.name
|
||||
else:
|
||||
q_data['giver_location_name'] = giver['location_id']
|
||||
|
||||
# ENRICH OBJECTIVES WITH NAMES
|
||||
if 'objectives' in q_data:
|
||||
enriched_objs = []
|
||||
for obj in q_data['objectives']:
|
||||
new_obj = dict(obj)
|
||||
target = obj.get('target')
|
||||
if obj.get('type') == 'kill_count':
|
||||
npc = NPCS_DATA.get(target)
|
||||
if npc:
|
||||
new_obj['target_name'] = npc.get('name')
|
||||
elif obj.get('type') == 'item_delivery':
|
||||
item = ITEMS_MANAGER.get_item(target)
|
||||
if item:
|
||||
new_obj['target_name'] = item.name
|
||||
enriched_objs.append(new_obj)
|
||||
q_data['objectives'] = enriched_objs
|
||||
|
||||
# ENRICH REWARDS WITH NAMES
|
||||
if 'rewards' in q_data and 'items' in q_data['rewards']:
|
||||
enriched_items = {}
|
||||
for item_id, qty in q_data['rewards']['items'].items():
|
||||
item = ITEMS_MANAGER.get_item(item_id)
|
||||
name = item.name if item else item_id
|
||||
enriched_items[item_id] = {'qty': qty, 'name': name}
|
||||
|
||||
# Store back in a way frontend can use, or just replace items dict?
|
||||
# Frontend currently iterates entries of items.
|
||||
# Let's add a new field 'reward_items_details'
|
||||
q_data['reward_items_details'] = enriched_items
|
||||
|
||||
result.append(q_data)
|
||||
|
||||
return result
|
||||
|
||||
@router.get("/available")
|
||||
async def get_available_quests(current_user: dict = Depends(get_current_user)):
|
||||
"""Get quests available to be started at current location"""
|
||||
character_id = current_user['id']
|
||||
location_id = current_user['location_id']
|
||||
|
||||
# 1. Identify NPCs at this location
|
||||
local_npcs = [
|
||||
npc_id for npc_id, npc in NPCS_DATA.items()
|
||||
if npc.get('location_id') == location_id
|
||||
]
|
||||
|
||||
if not local_npcs:
|
||||
return []
|
||||
|
||||
# 2. Get quests offered by these NPCs
|
||||
potential_quests = []
|
||||
for q_id, q_def in QUESTS_DATA.items():
|
||||
if q_def.get('giver_id') in local_npcs:
|
||||
potential_quests.append(q_def)
|
||||
|
||||
# 3. Filter out active/completed non-repeatable quests
|
||||
# We need to check DB state
|
||||
available = []
|
||||
|
||||
# Bulk fetch might be better but loop is fine for now
|
||||
for q_def in potential_quests:
|
||||
q_id = q_def['quest_id']
|
||||
existing = await db.get_character_quest(character_id, q_id)
|
||||
|
||||
if not existing:
|
||||
# Never started -> Available
|
||||
available.append(q_def)
|
||||
else:
|
||||
# Exists
|
||||
if existing['status'] == 'active':
|
||||
continue # Already active
|
||||
|
||||
if existing['status'] == 'completed':
|
||||
if q_def.get('repeatable'):
|
||||
# Check cooldown
|
||||
expires = existing.get('cooldown_expires_at')
|
||||
if not expires or time.time() >= expires:
|
||||
available.append(q_def)
|
||||
else:
|
||||
continue # Completed and not repeatable
|
||||
|
||||
if existing['status'] == 'failed':
|
||||
available.append(q_def) # Can retry?
|
||||
|
||||
return available
|
||||
|
||||
@router.post("/accept/{quest_id}")
|
||||
async def accept_quest(quest_id: str, current_user: dict = Depends(get_current_user)):
|
||||
"""Accept a quest"""
|
||||
character_id = current_user['id']
|
||||
quest_def = QUESTS_DATA.get(quest_id)
|
||||
|
||||
if not quest_def:
|
||||
raise HTTPException(status_code=404, detail="Quest not found")
|
||||
|
||||
# Check if repeatable & cooldown
|
||||
existing = await db.get_character_quest(character_id, quest_id)
|
||||
if existing:
|
||||
if not quest_def.get('repeatable'):
|
||||
raise HTTPException(status_code=400, detail="Quest already completed or active")
|
||||
|
||||
# Check cooldown
|
||||
if existing.get('cooldown_expires_at') and time.time() < existing['cooldown_expires_at']:
|
||||
remaining = int(existing['cooldown_expires_at'] - time.time())
|
||||
raise HTTPException(status_code=400, detail=f"Quest on cooldown for {remaining}s")
|
||||
|
||||
if existing['status'] == 'active':
|
||||
raise HTTPException(status_code=400, detail="Quest already active")
|
||||
|
||||
# Accept quest
|
||||
await db.accept_quest(character_id, quest_id)
|
||||
|
||||
# Return updated quest data for frontend
|
||||
updated_q_data = dict(quest_def)
|
||||
updated_q_data['status'] = 'active'
|
||||
updated_q_data['start_at'] = int(time.time())
|
||||
updated_q_data['progress'] = {} # New quest
|
||||
|
||||
return {"success": True, "message": "Quest accepted", "quest": updated_q_data}
|
||||
|
||||
@router.post("/hand_in/{quest_id}")
|
||||
async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Hand in items or check completion for a quest.
|
||||
Automatically deducts items from inventory for delivery objectives.
|
||||
"""
|
||||
character_id = current_user['id']
|
||||
quest_def = QUESTS_DATA.get(quest_id)
|
||||
|
||||
if not quest_def:
|
||||
raise HTTPException(status_code=404, detail="Quest not found")
|
||||
|
||||
quest_record = await db.get_character_quest(character_id, quest_id)
|
||||
if not quest_record or quest_record['status'] != 'active':
|
||||
raise HTTPException(status_code=400, detail="Quest not active")
|
||||
|
||||
current_progress = quest_record.get('progress') or {}
|
||||
objectives = quest_def.get('objectives', [])
|
||||
|
||||
updated_progress = current_progress.copy()
|
||||
items_deducted = []
|
||||
all_completed = True
|
||||
|
||||
# Iterate objectives
|
||||
for obj in objectives:
|
||||
obj_type = obj['type']
|
||||
target = obj['target']
|
||||
required_count = obj['count']
|
||||
|
||||
current_count = current_progress.get(target, 0)
|
||||
|
||||
if current_count >= required_count:
|
||||
continue # Already done
|
||||
|
||||
if obj_type == 'item_delivery':
|
||||
# Check inventory
|
||||
inventory = await db.get_inventory(character_id)
|
||||
inv_item = next((i for i in inventory if i['item_id'] == target), None)
|
||||
|
||||
if inv_item:
|
||||
available = inv_item['quantity']
|
||||
needed = required_count - current_count # Personal needed (to match max count)
|
||||
|
||||
# GLOBAL CAP CHECK
|
||||
is_global = quest_def.get('type') == 'global'
|
||||
if is_global:
|
||||
global_quest = await db.get_global_quest(quest_id)
|
||||
global_prog = global_quest.get('global_progress', {}) if global_quest else {}
|
||||
global_current_val = global_prog.get(target, 0)
|
||||
global_remaining = max(0, required_count - global_current_val)
|
||||
|
||||
# Cap needed by global remaining
|
||||
needed = min(needed, global_remaining)
|
||||
|
||||
to_take = min(available, needed)
|
||||
|
||||
if to_take > 0:
|
||||
# Remove from inventory
|
||||
await db.remove_item_from_inventory(character_id, target, to_take)
|
||||
|
||||
# Update progress
|
||||
new_count = current_count + to_take
|
||||
updated_progress[target] = new_count
|
||||
items_deducted.append(f"{target} x{to_take}")
|
||||
|
||||
# Global Quest Logic
|
||||
if is_global:
|
||||
# Re-fetch or use existing? We need to be careful with race conditions slightly,
|
||||
# but safe enough for now to just update.
|
||||
# We already fetched 'global_prog' above.
|
||||
|
||||
# Add contribution
|
||||
new_global = global_current_val + to_take
|
||||
global_prog[target] = new_global
|
||||
await db.update_global_quest(quest_id, global_prog)
|
||||
|
||||
# Check for global completion
|
||||
is_global_complete = True
|
||||
for obj in objectives:
|
||||
t = obj['target']
|
||||
req = obj['count']
|
||||
# Check cached updated prog
|
||||
if global_prog.get(t, 0) < req:
|
||||
is_global_complete = False
|
||||
break
|
||||
|
||||
if is_global_complete:
|
||||
# Finish global quest!
|
||||
await finish_global_quest(quest_id, quest_def)
|
||||
|
||||
# RETURN IMMEDIATELY to prevent double rewards/deletion logic
|
||||
# We construct a success response here.
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Global Quest Completed!",
|
||||
"is_completed": True,
|
||||
"items_deducted": items_deducted,
|
||||
"rewards": ["See Global Rewards"], # Placeholders, real rewards via finish_global_quest/websocket
|
||||
"completion_text": quest_def.get("completion_text", "Global Quest Finished!"),
|
||||
"quest_update": {
|
||||
**quest_def,
|
||||
"quest_id": quest_id,
|
||||
"status": "completed",
|
||||
"progress": updated_progress,
|
||||
"on_cooldown": quest_def.get('repeatable'),
|
||||
}
|
||||
}
|
||||
else:
|
||||
# Prevent individual completion if global is not done
|
||||
all_completed = False
|
||||
|
||||
if new_count < required_count:
|
||||
all_completed = False
|
||||
else:
|
||||
all_completed = False
|
||||
else:
|
||||
all_completed = False
|
||||
|
||||
elif obj_type == 'kill_count':
|
||||
# Check if kill count is met (updated via other events usually)
|
||||
if current_count < required_count:
|
||||
all_completed = False
|
||||
|
||||
# WEIGHT CHECK FOR REWARDS
|
||||
rewards_msg = []
|
||||
if all_completed:
|
||||
rewards = quest_def.get('rewards', {})
|
||||
reward_weight = 0.0
|
||||
|
||||
if 'items' in rewards:
|
||||
for item_id, qty in rewards['items'].items():
|
||||
item_def = ITEMS_MANAGER.get_item(item_id)
|
||||
if item_def:
|
||||
reward_weight += item_def.weight * qty
|
||||
|
||||
# Calculate current weight
|
||||
# Calculate current weight and capacity
|
||||
from ..services.helpers import calculate_player_capacity
|
||||
inventory = await db.get_inventory(character_id)
|
||||
current_weight, capacity, _, _ = await calculate_player_capacity(inventory, ITEMS_MANAGER)
|
||||
|
||||
if current_weight + reward_weight > capacity:
|
||||
# Rollback? The items for delivery were already removed above!
|
||||
# Ideally we should check weight BEFORE deducting delivery items.
|
||||
# converting this to a "check before action" logic is hard because delivery logic is stateful.
|
||||
# However, delivery items REDUCE weight. So we are likely safe unless rewards are heavier than delivered items.
|
||||
# BUT, if we error here, we technically leave the quest in "partially delivered" state, which is fine.
|
||||
# The user can just clear inventory and try again.
|
||||
|
||||
raise HTTPException(status_code=400, detail=f"Not enough inventory space for rewards! (Overweight by {current_weight + reward_weight - capacity:.1f})")
|
||||
|
||||
# Give Rewards
|
||||
# XP
|
||||
if 'xp' in rewards:
|
||||
xp_gained = rewards['xp']
|
||||
new_xp = current_user['xp'] + xp_gained
|
||||
await db.update_player(character_id, xp=new_xp)
|
||||
rewards_msg.append(f"{xp_gained} XP")
|
||||
|
||||
# Check for level up
|
||||
try:
|
||||
level_up_result = await game_logic.check_and_apply_level_up(character_id)
|
||||
if level_up_result and level_up_result.get('leveled_up'):
|
||||
new_level = level_up_result['new_level']
|
||||
stats_gained = level_up_result['levels_gained']
|
||||
rewards_msg.append(f"Level Up! (Lvl {new_level}) +{stats_gained} Stat Points")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check level up in quest hand-in: {e}")
|
||||
|
||||
# Items
|
||||
if 'items' in rewards:
|
||||
for item_id, qty in rewards['items'].items():
|
||||
await db.add_item_to_inventory(character_id, item_id, qty)
|
||||
# Resolve name
|
||||
idev = ITEMS_MANAGER.get_item(item_id)
|
||||
name = idev.name if idev else item_id
|
||||
rewards_msg.append(f"{name} x{qty}")
|
||||
|
||||
# Set cooldown if repeatable
|
||||
if quest_def.get('repeatable'):
|
||||
cooldown_hours = quest_def.get('cooldown_hours', 24)
|
||||
expires = time.time() + (cooldown_hours * 3600)
|
||||
await db.set_quest_cooldown(character_id, quest_id, expires)
|
||||
|
||||
# LOG HISTORY
|
||||
await db.log_quest_completion(
|
||||
character_id=character_id,
|
||||
quest_id=quest_id,
|
||||
started_at=quest_record.get('started_at') or time.time(),
|
||||
rewards=quest_def.get('rewards', {})
|
||||
)
|
||||
|
||||
# REMOVE FROM ACTIVE QUESTS (DELETE)
|
||||
await db.delete_character_quest(character_id, quest_id)
|
||||
|
||||
status = "completed"
|
||||
|
||||
else:
|
||||
# Not completed, just update progress
|
||||
status = "active"
|
||||
await db.update_quest_progress(character_id, quest_id, updated_progress, status)
|
||||
|
||||
# ENRICH OBJECTIVES FOR RESPONSE
|
||||
enriched_objs = []
|
||||
for obj in objectives:
|
||||
new_obj = dict(obj)
|
||||
target = obj.get('target')
|
||||
|
||||
# Add current progress
|
||||
new_obj['current'] = updated_progress.get(target, 0)
|
||||
|
||||
# Add names
|
||||
if obj.get('type') == 'kill_count':
|
||||
npc = NPCS_DATA.get(target)
|
||||
if npc:
|
||||
new_obj['target_name'] = npc.get('name')
|
||||
elif obj.get('type') == 'item_delivery':
|
||||
item = ITEMS_MANAGER.get_item(target)
|
||||
if item:
|
||||
new_obj['target_name'] = item.name
|
||||
enriched_objs.append(new_obj)
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"progress": updated_progress,
|
||||
"is_completed": all_completed,
|
||||
"items_deducted": items_deducted,
|
||||
"message": "Progress updated",
|
||||
"quest_update": {
|
||||
**quest_def,
|
||||
"quest_id": quest_id,
|
||||
"status": status,
|
||||
"progress": updated_progress,
|
||||
"objectives": enriched_objs,
|
||||
"on_cooldown": all_completed and quest_def.get('repeatable'),
|
||||
# other fields as needed
|
||||
}
|
||||
}
|
||||
|
||||
if all_completed:
|
||||
response["message"] = "Quest Completed!"
|
||||
response["rewards"] = rewards_msg
|
||||
response["completion_text"] = quest_def.get("completion_text", {})
|
||||
|
||||
return response
|
||||
|
||||
# Also exposing global quest state
|
||||
@router.get("/global/{quest_id}")
|
||||
async def get_global_quest_progress(quest_id: str):
|
||||
quest = await db.get_global_quest(quest_id)
|
||||
if not quest:
|
||||
return {"progress": {}}
|
||||
return quest
|
||||
|
||||
|
||||
async def finish_global_quest(quest_id: str, quest_def: Dict):
|
||||
"""
|
||||
Handle global quest completion:
|
||||
1. Mark global quest as completed
|
||||
2. Unlock content (In-Memory)
|
||||
3. Distribute rewards to all participants
|
||||
4. Broadcast completion
|
||||
"""
|
||||
logger.info(f"🌍 Finishing Global Quest: {quest_id}")
|
||||
|
||||
# 1. Mark as completed in DB
|
||||
await db.mark_global_quest_completed(quest_id)
|
||||
|
||||
# 2. Unlock content (In-Memory)
|
||||
unlocks = []
|
||||
|
||||
# Unlock Locations
|
||||
for loc in LOCATIONS_DATA.values():
|
||||
if loc.unlocked_by == quest_id:
|
||||
loc.locked = False
|
||||
unlocks.append({"type": "location", "name": get_locale_string(loc.name, 'en'), "id": loc.id})
|
||||
|
||||
# Unlock interactables
|
||||
for inter in loc.interactables:
|
||||
if inter.unlocked_by == quest_id:
|
||||
inter.locked = False
|
||||
unlocks.append({"type": "interactable", "name": get_locale_string(inter.name, 'en'), "location": loc.id})
|
||||
|
||||
# 3. Distribute Rewards to participants
|
||||
participants = await db.get_all_quest_participants(quest_id)
|
||||
total_xp_pool = quest_def.get('rewards', {}).get('xp', 0)
|
||||
|
||||
total_required = 0
|
||||
for obj in quest_def.get('objectives', []):
|
||||
total_required += obj.get('count', 0)
|
||||
|
||||
for p in participants:
|
||||
# Calculate user contribution
|
||||
user_progress = p.get('progress', {})
|
||||
user_contribution = 0
|
||||
for obj in quest_def.get('objectives', []):
|
||||
target = obj['target']
|
||||
user_contribution += user_progress.get(target, 0)
|
||||
|
||||
if user_contribution > 0 and total_required > 0:
|
||||
percentage = user_contribution / total_required
|
||||
xp_reward = int(total_xp_pool * percentage)
|
||||
|
||||
if xp_reward > 0:
|
||||
# Give XP
|
||||
char = await db.get_player_by_id(p['character_id'])
|
||||
if char:
|
||||
new_xp = char['xp'] + xp_reward
|
||||
await db.update_player(p['character_id'], xp=new_xp)
|
||||
|
||||
# Mark as completed (delete from active) and log history
|
||||
await db.delete_character_quest(p['character_id'], quest_id)
|
||||
await db.log_quest_completion(
|
||||
character_id=p['character_id'],
|
||||
quest_id=quest_id,
|
||||
started_at=p['started_at'],
|
||||
rewards={"xp": xp_reward, "note": f"Contribution: {percentage*100:.1f}%"}
|
||||
)
|
||||
|
||||
# 4. Broadcast
|
||||
await manager.broadcast({
|
||||
"type": "global_quest_completed",
|
||||
"quest_id": quest_id,
|
||||
"title": get_locale_string(quest_def.get('title', 'Global Quest'), 'en'),
|
||||
"outcome": {
|
||||
"unlocks": unlocks
|
||||
}
|
||||
})
|
||||
234
api/routers/trade.py
Normal file
@@ -0,0 +1,234 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from typing import Dict, List, Any, Optional
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from ..core.security import get_current_user
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/trade",
|
||||
tags=["trade"],
|
||||
responses={404: {"description": "Not found"}},
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ITEMS_MANAGER = None
|
||||
NPCS_DATA = {}
|
||||
|
||||
def init_router_dependencies(items_manager: ItemsManager, npcs_data: Dict):
|
||||
global ITEMS_MANAGER, NPCS_DATA
|
||||
ITEMS_MANAGER = items_manager
|
||||
NPCS_DATA = npcs_data
|
||||
|
||||
@router.get("/{npc_id}")
|
||||
async def get_trade_stock(npc_id: str, current_user: dict = Depends(get_current_user)):
|
||||
"""Get NPC stock and trade config"""
|
||||
npc_def = NPCS_DATA.get(npc_id)
|
||||
if not npc_def or not npc_def.get('trade', {}).get('enabled'):
|
||||
raise HTTPException(status_code=404, detail="Merchant not found or trade disabled")
|
||||
|
||||
stock_db = await db.get_merchant_stock(npc_id)
|
||||
stock_config = npc_def['trade'].get('stock', [])
|
||||
|
||||
# Merge DB stock with infinite items from config
|
||||
final_stock = []
|
||||
|
||||
# Map DB items
|
||||
db_items_map = {}
|
||||
for item in stock_db:
|
||||
# Resolve item details
|
||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||
if item_def:
|
||||
item_data = {
|
||||
"item_id": item['item_id'],
|
||||
"name": item_def.name,
|
||||
"emoji": item_def.emoji,
|
||||
"quantity": item['quantity'],
|
||||
"value": item_def.value, # Base value
|
||||
"unique_item_id": item.get('unique_item_id'),
|
||||
"description": item_def.description,
|
||||
"image_path": item_def.image_path,
|
||||
"tier": item_def.tier,
|
||||
"item_type": item_def.type,
|
||||
"weight": item_def.weight,
|
||||
"volume": item_def.volume,
|
||||
"stats": item_def.stats,
|
||||
"effects": item_def.effects
|
||||
}
|
||||
# Handle unique item stats if needed (would need to fetch unique_item table)
|
||||
# For now assuming standard items mostly
|
||||
final_stock.append(item_data)
|
||||
db_items_map[item['item_id']] = True
|
||||
|
||||
# Add infinite items from config if not in DB (or valid placeholders)
|
||||
for cfg_item in stock_config:
|
||||
if cfg_item.get('infinite'):
|
||||
item_def = ITEMS_MANAGER.get_item(cfg_item['item_id'])
|
||||
if item_def:
|
||||
final_stock.append({
|
||||
"item_id": cfg_item['item_id'],
|
||||
"name": item_def.name,
|
||||
"emoji": item_def.emoji,
|
||||
"quantity": 9999,
|
||||
"is_infinite": True,
|
||||
"value": item_def.value,
|
||||
"description": item_def.description,
|
||||
"image_path": item_def.image_path,
|
||||
"tier": item_def.tier,
|
||||
"item_type": item_def.type,
|
||||
"weight": item_def.weight,
|
||||
"volume": item_def.volume,
|
||||
"stats": item_def.stats,
|
||||
"effects": item_def.effects
|
||||
})
|
||||
|
||||
return {
|
||||
"config": npc_def['trade'],
|
||||
"stock": final_stock
|
||||
}
|
||||
|
||||
@router.post("/{npc_id}/execute")
|
||||
async def execute_trade(
|
||||
npc_id: str,
|
||||
payload: Dict = Body(...),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Execute a trade.
|
||||
Payload: {
|
||||
"buying": [{"item_id": "water", "quantity": 1}],
|
||||
"selling": [{"item_id": "junk", "quantity": 1}]
|
||||
}
|
||||
"""
|
||||
character_id = current_user['id']
|
||||
npc_def = NPCS_DATA.get(npc_id)
|
||||
if not npc_def:
|
||||
raise HTTPException(status_code=404, detail="NPC not found")
|
||||
|
||||
trade_cfg = npc_def.get('trade', {})
|
||||
if not trade_cfg.get('enabled'):
|
||||
raise HTTPException(status_code=400, detail="Trade disabled")
|
||||
|
||||
buying = payload.get('buying', [])
|
||||
selling = payload.get('selling', [])
|
||||
|
||||
# Validate items and calculate value
|
||||
total_buy_value = 0
|
||||
total_sell_value = 0
|
||||
|
||||
# check player inventory for selling
|
||||
player_inventory = await db.get_inventory(character_id)
|
||||
|
||||
buy_markup = trade_cfg.get('buy_markup', 1.0)
|
||||
sell_markdown = trade_cfg.get('sell_markdown', 1.0)
|
||||
|
||||
# PROCESS SELLING (Player -> NPC)
|
||||
items_to_remove = []
|
||||
for sell_item in selling:
|
||||
item_id = sell_item['item_id']
|
||||
qty = sell_item['quantity']
|
||||
unique_id = sell_item.get('unique_item_id')
|
||||
|
||||
# Verify player has item
|
||||
inv_item = next((i for i in player_inventory if i['item_id'] == item_id and i.get('unique_item_id') == unique_id), None)
|
||||
if not inv_item or inv_item['quantity'] < qty:
|
||||
raise HTTPException(status_code=400, detail=f"Not enough {item_id} to sell")
|
||||
|
||||
item_def = ITEMS_MANAGER.get_item(item_id)
|
||||
value = (item_def.value * sell_markdown) * qty
|
||||
total_sell_value += value
|
||||
|
||||
items_to_remove.append((item_id, qty, unique_id))
|
||||
|
||||
# PROCESS BUYING (NPC -> Player)
|
||||
items_to_add = []
|
||||
db_stock = await db.get_merchant_stock(npc_id)
|
||||
|
||||
for buy_item in buying:
|
||||
item_id = buy_item['item_id']
|
||||
qty = buy_item['quantity']
|
||||
unique_id = buy_item.get('unique_item_id') # For unique items from stock
|
||||
|
||||
# Verify NPC has item (unless infinite)
|
||||
is_infinite = False
|
||||
config_entry = next((c for c in trade_cfg.get('stock', []) if c['item_id'] == item_id), None)
|
||||
if config_entry and config_entry.get('infinite'):
|
||||
is_infinite = True
|
||||
|
||||
if not is_infinite:
|
||||
stock_item = next((s for s in db_stock if s['item_id'] == item_id and s.get('unique_item_id') == unique_id), None)
|
||||
if not stock_item or stock_item['quantity'] < qty:
|
||||
raise HTTPException(status_code=400, detail=f"Merchant out of stock: {item_id}")
|
||||
|
||||
item_def = ITEMS_MANAGER.get_item(item_id)
|
||||
value = (item_def.value * buy_markup) * qty
|
||||
total_buy_value += value
|
||||
|
||||
items_to_add.append((item_id, qty, unique_id))
|
||||
|
||||
# VALIDATE VALUE
|
||||
# If using 'value' currency, trades must balance OR player pays difference if we implemented currency items
|
||||
# For now assuming pure barter or abstract credit if we had it.
|
||||
# Plan says: "currency": "value", "unlimited_currency": true
|
||||
# This implies player can Sell for "credit" in this transaction to Buy other things.
|
||||
# Usually in barter: Sell Value >= Buy Value. If Sell > Buy, player loses difference (or we assume "value" credits are not stored).
|
||||
# Re-reading: "Trade button active only if Player Value >= NPC Value".
|
||||
|
||||
if total_sell_value < total_buy_value:
|
||||
raise HTTPException(status_code=400, detail="Trade value too low. Offer more items.")
|
||||
|
||||
# EXECUTE TRADE
|
||||
|
||||
# 1. Remove sold items from Player
|
||||
for item_id, qty, unique_id in items_to_remove:
|
||||
await db.remove_item_from_inventory(character_id, item_id, qty) # Need to handle unique_id in remove?
|
||||
# remove_item_inventory in db currently takes player_id, item_id, qty.
|
||||
# It doesn't handle unique_id specific removal yet?
|
||||
# Checking db.py... remove_item_from_inventory isn't fully robust for unique items in the snippet I saw?
|
||||
# Wait, I strictly need to fix db.remove_item_from_inventory or use a more specific query if unique.
|
||||
# Assuming for now stackables are main concern. For uniques, quantity is 1.
|
||||
# If unique_id is passed, we should delete that specific row in inventory.
|
||||
# I'll implement a fallback db call here if needed or assume standard remove works for stackables.
|
||||
pass
|
||||
|
||||
# 2. Add sold items to NPC (if keep_sold_items)
|
||||
if trade_cfg.get('keep_sold_items'):
|
||||
for item_id, qty, unique_id in items_to_remove:
|
||||
# Add to merchant stock
|
||||
# If unique, pass unique_id
|
||||
# Logic to find existing row or create new
|
||||
current_stock = await db.get_merchant_stock_item(npc_id, item_id, unique_id)
|
||||
old_qty = current_stock['quantity'] if current_stock else 0
|
||||
await db.update_merchant_stock(npc_id, item_id, old_qty + qty, unique_id)
|
||||
|
||||
# 3. Remove bought items from NPC (if not infinite)
|
||||
for item_id, qty, unique_id in items_to_add:
|
||||
is_infinite = False
|
||||
config_entry = next((c for c in trade_cfg.get('stock', []) if c['item_id'] == item_id), None)
|
||||
if config_entry and config_entry.get('infinite'):
|
||||
is_infinite = True
|
||||
|
||||
if not is_infinite:
|
||||
current_stock = await db.get_merchant_stock_item(npc_id, item_id, unique_id)
|
||||
if current_stock:
|
||||
new_qty = current_stock['quantity'] - qty
|
||||
await db.update_merchant_stock(npc_id, item_id, new_qty, unique_id)
|
||||
|
||||
# 4. Add bought items to Player
|
||||
for item_id, qty, unique_id in items_to_add:
|
||||
# If buying unique item from NPC, it transfers ownership.
|
||||
# If infinite, it creates new item?
|
||||
|
||||
# If unique_id exists (buying specific unique item)
|
||||
if unique_id and not is_infinite:
|
||||
await db.add_item_to_inventory(character_id, item_id, qty, unique_item_id=unique_id)
|
||||
else:
|
||||
# Standard or infinite
|
||||
await db.add_item_to_inventory(character_id, item_id, qty)
|
||||
|
||||
# Log statistics?
|
||||
|
||||
return {"success": True, "message": "Trade completed"}
|
||||
6
api/services/constants.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Global game constants
|
||||
"""
|
||||
|
||||
# PvP Combat
|
||||
PVP_TURN_TIMEOUT = 60
|
||||
@@ -3,11 +3,159 @@ Helper utilities for game calculations and common operations.
|
||||
Contains distance calculations, stamina costs, capacity calculations, etc.
|
||||
"""
|
||||
import math
|
||||
from typing import Tuple, List, Dict, Any
|
||||
from typing import Tuple, List, Dict, Any, Union
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
|
||||
|
||||
def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> str:
|
||||
"""Helper to safely get string from i18n object or string."""
|
||||
if isinstance(value, dict):
|
||||
return value.get(lang) or value.get('en') or str(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
|
||||
# Translation maps for backend messages
|
||||
GAME_MESSAGES = {
|
||||
# Pickup
|
||||
'picked_up': {'en': 'Picked up', 'es': 'Has cogido'},
|
||||
'inventory_full': {'en': 'Inventory full', 'es': 'Inventario lleno'},
|
||||
'dropped_to_ground': {'en': 'Dropped to ground', 'es': 'Tirado al suelo'},
|
||||
'item_too_heavy': {
|
||||
'en': "⚠️ Item too heavy! {emoji} {name} x{qty} ({weight:.1f}kg) would exceed capacity. Current: {current:.1f}/{max:.1f}kg",
|
||||
'es': "⚠️ ¡Objeto muy pesado! {emoji} {name} x{qty} ({weight:.1f}kg) excedería la capacidad. Actual: {current:.1f}/{max:.1f}kg"
|
||||
},
|
||||
'item_too_large': {
|
||||
'en': "⚠️ Item too large! {emoji} {name} x{qty} ({volume:.1f}L) would exceed capacity. Current: {current:.1f}/{max:.1f}L",
|
||||
'es': "⚠️ ¡Objeto muy grande! {emoji} {name} x{qty} ({volume:.1f}L) excedería la capacidad. Actual: {current:.1f}/{max:.1f}L"
|
||||
},
|
||||
'item_not_found_ground': {'en': "Item not found on ground", 'es': "Objeto no encontrado en el suelo"},
|
||||
'invalid_quantity': {'en': "Invalid quantity", 'es': "Cantidad inválida"},
|
||||
'dropped_item_success': {'en': 'Dropped {emoji} {name} x{qty}', 'es': 'Has tirado {emoji} {name} x{qty}'},
|
||||
|
||||
# Movement
|
||||
'cannot_go_direction': {'en': "You cannot go {direction} from here.", 'es': "No puedes ir al {direction} desde aquí."},
|
||||
'exhausted_move': {'en': "You're too exhausted to move. Wait for your stamina to regenerate.", 'es': "Estás demasiado exhausto para moverte. Espera a recuperar stamina."},
|
||||
'move_cooldown': {'en': 'You must wait {seconds} seconds before moving again.', 'es': 'Debes esperar {seconds} segundos antes de moverte de nuevo.'},
|
||||
'enemy_ambush': {'en': '⚠️ An enemy ambushes you upon arrival!', 'es': '⚠️ ¡Un enemigo te tiende una emboscada al llegar!'},
|
||||
'player_left': {'en': '{player_name} left the area', 'es': '{player_name} abandonó el área'},
|
||||
'player_arrived': {'en': '{player_name} arrived', 'es': '{player_name} ha llegado'},
|
||||
'player_defeated_broadcast': {'en': '{player_name} was defeated in combat', 'es': '{player_name} fue derrotado en combate'},
|
||||
'player_defeated_enemy_broadcast': {'en': '{player_name} defeated {npc_name}', 'es': '{player_name} derrotó a {npc_name}'},
|
||||
'player_fled_broadcast': {'en': '{player_name} fled from combat', 'es': '{player_name} huyó del combate'},
|
||||
'player_entered_combat': {'en': '{player_name} entered combat with {npc_name}', 'es': '{player_name} entró en combate con {npc_name}'},
|
||||
'player_returned_pvp': {'en': '{player_name} has returned from PvP combat.', 'es': '{player_name} ha regresado del combate PvP.'},
|
||||
|
||||
|
||||
'pvp_defeat_broadcast': {'en': '{opponent} was defeated by {winner} in PvP combat', 'es': '{opponent} fue derrotado por {winner} en combate PvP'},
|
||||
'pvp_initiated_attacker': {'en': "You have initiated combat with {defender}! They get the first turn.", 'es': "¡Has iniciado combate con {defender}! Tiene el primer turno."},
|
||||
'pvp_challenged_defender': {'en': "{attacker} has challenged you to PvP combat! It's your turn.", 'es': "¡{attacker} te ha desafiado a combate PvP! Es tu turno."},
|
||||
'pvp_combat_started_broadcast': {'en': "⚔️ PvP combat started between {attacker} and {defender}!", 'es': "⚔️ ¡Combate PvP iniciado entre {attacker} y {defender}!"},
|
||||
'flee_success_text': {'en': "{name} fled from combat!", 'es': "¡{name} huyó del combate!"},
|
||||
'flee_fail_text': {'en': "{name} tried to flee but failed!", 'es': "¡{name} intentó huir pero falló!"},
|
||||
|
||||
# Loot
|
||||
'corpse_name_npc': {'en': "{name} Corpse", 'es': "Cadáver de {name}"},
|
||||
'corpse_name_player': {'en': "{name}'s Corpse", 'es': "Cadáver de {name}"},
|
||||
'looted_items_start': {'en': "Looted: ", 'es': "Saqueado: "},
|
||||
'backpack_full_drop': {'en': "⚠️ Backpack full! Dropped on ground: ", 'es': "⚠️ ¡Mochila llena! Tirado al suelo: "},
|
||||
'nothing_looted': {'en': "Nothing could be looted", 'es': "No se pudo saquear nada"},
|
||||
'items_require_tools': {'en': "{count} item(s) require tools to extract", 'es': "{count} objeto(s) requieren herramientas"},
|
||||
'full_loot_broadcast': {'en': "{player_name} fully looted an NPC corpse", 'es': "{player_name} saqueó completamente un cadáver de NPC"},
|
||||
'player_corpse_emptied_broadcast': {'en': "{player_name} fully looted {corpse_name}'s corpse", 'es': "{player_name} vació el cadáver de {corpse_name}"},
|
||||
'player_corpse_looted_broadcast': {'en': "{player_name} looted from {corpse_name}'s corpse", 'es': "{player_name} saqueó del cadáver de {corpse_name}"},
|
||||
|
||||
# Equipment
|
||||
'unequip_equip': {'en': "Unequipped {old}, equipped {new}", 'es': "Desequipado {old}, equipado {new}"},
|
||||
'equipped': {'en': "Equipped {item}", 'es': "Equipado {item}"},
|
||||
'unequipped': {'en': "Unequipped {item}", 'es': "Desequipado {item}"},
|
||||
'unequip_dropped': {'en': "Unequipped {item} (dropped to ground - inventory full)", 'es': "Desequipado {item} (tirado al suelo - inventario lleno)"},
|
||||
'repaired_success': {'en': "Repaired {item}! Restored {amount} durability.", 'es': "¡Reparado {item}! Restaurados {amount} puntos de durabilidad."},
|
||||
|
||||
# Characters/Auth
|
||||
'character_created': {'en': "Character created successfully", 'es': "Personaje creado con éxito"},
|
||||
'character_deleted': {'en': "Character '{name}' deleted successfully", 'es': "Personaje '{name}' eliminado con éxito"},
|
||||
'email_updated': {'en': "Email updated successfully", 'es': "Email actualizado con éxito"},
|
||||
'password_updated': {'en': "Password updated successfully", 'es': "Contraseña actualizada con éxito"},
|
||||
|
||||
# Inspection
|
||||
'exhausted_inspect': {'en': "You're too exhausted to inspect the area thoroughly. Wait for your stamina to regenerate.", 'es': "Estás demasiado exhausto para inspeccionar. Espera a recuperar stamina."},
|
||||
'inspecting_title': {'en': "🔍 **Inspecting {name}**\n", 'es': "🔍 **Inspeccionando {name}**\n"},
|
||||
'interactables_title': {'en': "**Interactables:**", 'es': "**Objetos interactuables:**"},
|
||||
'npcs_title': {'en': "**NPCs:**", 'es': "**NPCs:**"},
|
||||
'items_ground_title': {'en': "**Items on ground:**", 'es': "**Objetos en el suelo:**"},
|
||||
|
||||
# Interaction
|
||||
'not_enough_stamina': {'en': "Not enough stamina. Need {cost}, have {current}.", 'es': "No tienes suficiente stamina. Necesitas {cost}, tienes {current}."},
|
||||
'costs_stamina': {'en': "Costs {cost} stamina", 'es': "Cuesta {cost} de aguante"},
|
||||
'cooldown_wait': {'en': "This action is still on cooldown. Wait {seconds} seconds.", 'es': "Esta acción está en enfriamiento. Espera {seconds} segundos."},
|
||||
'object_not_found': {'en': "Object not found", 'es': "Objeto no encontrado"},
|
||||
'action_not_found': {'en': "Action not found", 'es': "Acción no encontrada"},
|
||||
'action_no_outcomes': {'en': "Action has no defined outcomes", 'es': "La acción no tiene resultados definidos"},
|
||||
'interactable_cooldown': {'en': "{user} used {action} on {interactable}", 'es': "{user} usó {action} en {interactable}"},
|
||||
|
||||
# Item Usage
|
||||
'item_used': {'en': "Used {name}", 'es': "Usado {name}"},
|
||||
'no_item': {'en': "You don't have this item", 'es': "No tienes este objeto"},
|
||||
'cannot_use': {'en': "This item cannot be used", 'es': "Este objeto no se puede usar"},
|
||||
'cured': {'en': "Cured", 'es': "Curado"},
|
||||
|
||||
# Status Effects
|
||||
'statusDamage': {'en': "You took {damage} damage from status effects", 'es': "Has recibido {damage} de daño por efectos de estado"},
|
||||
'statusHeal': {'en': "You recovered {heal} HP from status effects", 'es': "Has recuperado {heal} vida por efectos de estado"},
|
||||
'diedFromStatus': {'en': "You died from status effects", 'es': "Has muerto por efectos de estado"},
|
||||
}
|
||||
|
||||
def get_game_message(key: str, lang: str = 'en', **kwargs) -> str:
|
||||
"""Get and format a localized game message."""
|
||||
messages = GAME_MESSAGES.get(key, {})
|
||||
template = messages.get(lang) or messages.get('en') or key
|
||||
try:
|
||||
return template.format(**kwargs)
|
||||
except KeyError:
|
||||
return template
|
||||
|
||||
DIRECTION_TRANSLATIONS = {
|
||||
'north': {'en': 'north', 'es': 'norte'},
|
||||
'south': {'en': 'south', 'es': 'sur'},
|
||||
'east': {'en': 'east', 'es': 'este'},
|
||||
'west': {'en': 'west', 'es': 'oeste'},
|
||||
'northeast': {'en': 'northeast', 'es': 'noreste'},
|
||||
'northwest': {'en': 'northwest', 'es': 'noroeste'},
|
||||
'southeast': {'en': 'southeast', 'es': 'sureste'},
|
||||
'southwest': {'en': 'southwest', 'es': 'suroeste'},
|
||||
}
|
||||
|
||||
def translate_travel_message(direction: str, location_name: str, lang: str = 'en') -> str:
|
||||
"""Translate a travel message to the user's language."""
|
||||
dir_translated = DIRECTION_TRANSLATIONS.get(direction, {}).get(lang, direction)
|
||||
|
||||
if lang == 'es':
|
||||
return f"Viajas al {dir_translated} hacia {location_name}."
|
||||
else:
|
||||
return f"You travel {dir_translated} to {location_name}."
|
||||
|
||||
|
||||
import json
|
||||
|
||||
def create_combat_message(message_type: str, origin: str = "neutral", **data) -> dict:
|
||||
"""Create a structured combat message object.
|
||||
|
||||
Args:
|
||||
message_type: Type of combat message (combat_start, player_attack, etc.)
|
||||
origin: Origin of the event - "player", "enemy", or "neutral"
|
||||
**data: Dynamic data for the message (damage, npc_name, etc.)
|
||||
|
||||
Returns:
|
||||
Dictionary with 'type', 'origin', and 'data' fields
|
||||
"""
|
||||
return {
|
||||
"type": message_type,
|
||||
"origin": origin,
|
||||
"data": data
|
||||
}
|
||||
|
||||
def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float:
|
||||
"""
|
||||
Calculate distance between two points using Euclidean distance.
|
||||
@@ -182,7 +330,7 @@ async def reduce_armor_durability(player_id: int, damage_taken: int, items_manag
|
||||
# which cascades to the inventory row.
|
||||
|
||||
broken_armor.append({
|
||||
'name': armor['item_def'].name,
|
||||
'name': get_locale_string(armor['item_def'].name),
|
||||
'emoji': getattr(armor['item_def'], 'emoji', '🛡️')
|
||||
})
|
||||
|
||||
@@ -214,7 +362,7 @@ async def consume_tool_durability(user_id: int, tools: list, inventory: list, it
|
||||
'unique_item_id': inv_item['unique_item_id'],
|
||||
'item_id': inv_item['item_id'],
|
||||
'durability': unique_item['durability'],
|
||||
'name': item_def.name,
|
||||
'name': get_locale_string(item_def.name),
|
||||
'emoji': getattr(item_def, 'emoji', '🔧')
|
||||
})
|
||||
|
||||
@@ -243,3 +391,36 @@ async def consume_tool_durability(user_id: int, tools: list, inventory: list, it
|
||||
await db.decrease_unique_item_durability(tool['unique_item_id'], durability_cost)
|
||||
|
||||
return True, "", consumed_tools
|
||||
|
||||
|
||||
async def enrich_character_data(char: Dict[str, Any], items_manager: ItemsManager) -> Dict[str, Any]:
|
||||
"""
|
||||
Add calculated stats (weight, volume) to character data.
|
||||
"""
|
||||
# Calculate weight and volume
|
||||
inventory = await db.get_inventory(char['id'])
|
||||
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, items_manager)
|
||||
|
||||
return {
|
||||
"id": char["id"],
|
||||
"name": char["name"],
|
||||
"level": char["level"],
|
||||
"xp": char["xp"],
|
||||
"hp": char["hp"],
|
||||
"max_hp": char["max_hp"],
|
||||
"stamina": char["stamina"],
|
||||
"max_stamina": char["max_stamina"],
|
||||
"strength": char["strength"],
|
||||
"agility": char["agility"],
|
||||
"endurance": char["endurance"],
|
||||
"intellect": char["intellect"],
|
||||
"location_id": char["location_id"],
|
||||
"avatar_data": char.get("avatar_data"),
|
||||
"last_played_at": char.get("last_played_at"),
|
||||
"created_at": char.get("created_at"),
|
||||
# Add calculated capacity
|
||||
"weight": round(current_weight, 1),
|
||||
"max_weight": round(max_weight, 1),
|
||||
"volume": round(current_volume, 1),
|
||||
"max_volume": round(max_volume, 1),
|
||||
}
|
||||
|
||||
@@ -79,7 +79,8 @@ class InitiateCombatRequest(BaseModel):
|
||||
|
||||
|
||||
class CombatActionRequest(BaseModel):
|
||||
action: str # 'attack', 'defend', 'flee'
|
||||
action: str # 'attack', 'defend', 'flee', 'use_item'
|
||||
item_id: Optional[str] = None # For use_item action
|
||||
|
||||
|
||||
class PvPCombatInitiateRequest(BaseModel):
|
||||
@@ -91,7 +92,8 @@ class PvPAcknowledgeRequest(BaseModel):
|
||||
|
||||
|
||||
class PvPCombatActionRequest(BaseModel):
|
||||
action: str # 'attack', 'defend', 'flee'
|
||||
action: str # 'attack', 'defend', 'flee', 'use_item'
|
||||
item_id: Optional[str] = None # For use_item action
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -4,14 +4,14 @@ Loads game data from JSON files without bot dependencies.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional
|
||||
from typing import Dict, List, Any, Optional, Union
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Outcome:
|
||||
"""Represents an outcome of an action"""
|
||||
text: str
|
||||
text: Union[str, Dict[str, str]]
|
||||
items_reward: Dict[str, int] = field(default_factory=dict)
|
||||
damage_taken: int = 0
|
||||
|
||||
@@ -20,7 +20,7 @@ class Outcome:
|
||||
class Action:
|
||||
"""Represents an action that can be performed on an interactable"""
|
||||
id: str
|
||||
label: str
|
||||
label: Union[str, Dict[str, str]]
|
||||
stamina_cost: int = 2
|
||||
outcomes: Dict[str, Outcome] = field(default_factory=dict)
|
||||
|
||||
@@ -32,9 +32,11 @@ class Action:
|
||||
class Interactable:
|
||||
"""Represents an interactable object"""
|
||||
id: str
|
||||
name: str
|
||||
name: Union[str, Dict[str, str]]
|
||||
image_path: str = ""
|
||||
actions: List[Action] = field(default_factory=list)
|
||||
unlocked_by: str = ""
|
||||
locked: bool = False
|
||||
|
||||
def add_action(self, action: Action):
|
||||
self.actions.append(action)
|
||||
@@ -52,8 +54,8 @@ class Exit:
|
||||
class Location:
|
||||
"""Represents a location in the game world"""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
name: Union[str, Dict[str, str]]
|
||||
description: Union[str, Dict[str, str]]
|
||||
image_path: str = ""
|
||||
exits: Dict[str, str] = field(default_factory=dict) # direction -> destination_id
|
||||
exit_stamina: Dict[str, int] = field(default_factory=dict) # direction -> stamina_cost
|
||||
@@ -63,6 +65,8 @@ class Location:
|
||||
x: float = 0.0 # X coordinate for distance calculations
|
||||
y: float = 0.0 # Y coordinate for distance calculations
|
||||
danger_level: int = 0 # Danger level (0-5)
|
||||
unlocked_by: str = ""
|
||||
locked: bool = False
|
||||
|
||||
def add_exit(self, direction: str, destination: str, stamina_cost: int = 5):
|
||||
self.exits[direction] = destination
|
||||
@@ -114,9 +118,14 @@ class WorldLoader:
|
||||
interactable = Interactable(
|
||||
id=template_id,
|
||||
name=template_data.get('name', 'Unknown'),
|
||||
image_path=template_data.get('image_path', '')
|
||||
image_path=template_data.get('image_path', ''),
|
||||
unlocked_by=instance_data.get('unlocked_by', template_data.get('unlocked_by', '')),
|
||||
)
|
||||
|
||||
# Set locked status if unlocked_by is present
|
||||
if interactable.unlocked_by:
|
||||
interactable.locked = True
|
||||
|
||||
# Get actions from template
|
||||
template_actions = template_data.get('actions', {})
|
||||
|
||||
@@ -211,9 +220,14 @@ class WorldLoader:
|
||||
y=float(loc_data.get('y', 0.0)),
|
||||
danger_level=danger_level,
|
||||
tags=loc_data.get('tags', []),
|
||||
npcs=loc_data.get('npcs', [])
|
||||
npcs=loc_data.get('npcs', []),
|
||||
unlocked_by=loc_data.get('unlocked_by', '')
|
||||
)
|
||||
|
||||
# Set locked status if unlocked_by is present
|
||||
if location.unlocked_by:
|
||||
location.locked = True
|
||||
|
||||
# Add exits
|
||||
for direction, destination in loc_data.get('exits', {}).items():
|
||||
location.add_exit(direction, destination)
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
"""
|
||||
Action handlers for button callbacks.
|
||||
This module contains organized handler functions for different types of player actions.
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
import random
|
||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from telegram.ext import ContextTypes
|
||||
from . import keyboards, logic
|
||||
from .api_client import api_client
|
||||
from .utils import format_stat_bar
|
||||
from data.world_loader import game_world
|
||||
from data.items import ITEMS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# UTILITY FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
async def check_and_redirect_if_in_combat(query, user_id: int, player: dict) -> bool:
|
||||
"""
|
||||
Check if player is in combat and redirect to combat view if so.
|
||||
Returns True if player is in combat (and was redirected), False otherwise.
|
||||
"""
|
||||
combat_data = await api_client.get_combat(user_id)
|
||||
if combat_data:
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(combat_data['npc_id'])
|
||||
|
||||
message = f"⚔️ You're in combat with {npc_def.emoji} {npc_def.name}!\n"
|
||||
message += format_stat_bar("Your HP", "❤️", player['hp'], player['max_hp']) + "\n"
|
||||
message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n"
|
||||
message += "🎯 Your turn!" if combat_data['turn'] == 'player' else "⏳ Enemy's turn..."
|
||||
|
||||
keyboard = await keyboards.combat_keyboard(user_id)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboard,
|
||||
image_path=npc_def.image_url if npc_def else None
|
||||
)
|
||||
await query.answer("⚔️ You're in combat! Finish or flee first.", show_alert=True)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_player_status_text(player_id: int) -> str:
|
||||
"""Generate player status text with location and stats.
|
||||
|
||||
Args:
|
||||
player_id: The unique database ID of the player (not telegram_id)
|
||||
"""
|
||||
from .api_client import api_client
|
||||
|
||||
player = await api_client.get_player_by_id(player_id)
|
||||
if not player:
|
||||
return "Could not find player data."
|
||||
|
||||
location = game_world.get_location(player["location_id"])
|
||||
if not location:
|
||||
return "Error: Player is in an unknown location."
|
||||
|
||||
# Get inventory from API
|
||||
inv_result = await api_client.get_inventory(player_id)
|
||||
inventory = inv_result.get('inventory', [])
|
||||
weight, volume = logic.calculate_inventory_load(inventory)
|
||||
max_weight, max_volume = logic.get_player_capacity(inventory, player)
|
||||
|
||||
# Get equipped items
|
||||
equipped_items = []
|
||||
for item in inventory:
|
||||
if item.get('is_equipped'):
|
||||
item_def = ITEMS.get(item['item_id'], {})
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
equipped_items.append(f"{emoji} {item_def.get('name', 'Unknown')}")
|
||||
|
||||
# Build status with visual bars
|
||||
status = f"<b>📍 Location:</b> {location.name}\n"
|
||||
status += f"{format_stat_bar('HP', '❤️', player['hp'], player['max_hp'])}\n"
|
||||
status += f"{format_stat_bar('Stamina', '⚡', player['stamina'], player['max_stamina'])}\n"
|
||||
status += f"🎒 <b>Load:</b> {weight}/{max_weight} kg | {volume}/{max_volume} vol\n"
|
||||
|
||||
if equipped_items:
|
||||
status += f"⚔️ <b>Equipped:</b> {', '.join(equipped_items)}\n"
|
||||
|
||||
status += "━━━━━━━━━━━━━━━━━━━━\n"
|
||||
status += f"<i>{location.description}</i>"
|
||||
return status
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# INSPECTION & WORLD INTERACTION HANDLERS
|
||||
# ============================================================================
|
||||
|
||||
async def handle_inspect_area(query, user_id: int, player: dict, data: list = None):
|
||||
"""Handle inspect area action - show NPCs and interactables in current location."""
|
||||
# Check if player is in combat and redirect if so
|
||||
if await check_and_redirect_if_in_combat(query, user_id, player):
|
||||
return
|
||||
|
||||
await query.answer()
|
||||
location_id = player['location_id']
|
||||
location = game_world.get_location(location_id)
|
||||
dropped_items = await api_client.get_dropped_items_in_location(location_id)
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(location_id)
|
||||
|
||||
keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies)
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="You scan the area. You notice...",
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
|
||||
|
||||
async def handle_attack_wandering(query, user_id: int, player: dict, data: list):
|
||||
"""Handle attacking a wandering enemy."""
|
||||
enemy_db_id = int(data[1])
|
||||
await query.answer()
|
||||
|
||||
# Get the enemy from database
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(player['location_id'])
|
||||
enemy_data = next((e for e in wandering_enemies if e['id'] == enemy_db_id), None)
|
||||
|
||||
if not enemy_data:
|
||||
await query.answer("That enemy has already moved on!", show_alert=True)
|
||||
# Refresh inspect menu
|
||||
location_id = player['location_id']
|
||||
location = game_world.get_location(location_id)
|
||||
dropped_items = await api_client.get_dropped_items_in_location(location_id)
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(location_id)
|
||||
keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies)
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="You scan the area. You notice...",
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
return
|
||||
|
||||
npc_id = enemy_data['npc_id']
|
||||
|
||||
# Remove enemy from wandering table (they're now in combat)
|
||||
await api_client.remove_wandering_enemy(enemy_db_id)
|
||||
|
||||
from data.npcs import NPCS
|
||||
from bot import combat
|
||||
|
||||
# Initiate combat
|
||||
combat_data = await combat.initiate_combat(
|
||||
user_id, npc_id, player['location_id'], from_wandering_enemy=True
|
||||
)
|
||||
|
||||
if combat_data:
|
||||
npc_def = NPCS.get(npc_id)
|
||||
message = f"⚔️ You engage the {npc_def.emoji} {npc_def.name}!\n\n"
|
||||
message += f"{npc_def.description}\n\n"
|
||||
message += format_stat_bar("Your HP", "❤️", player['hp'], player['max_hp']) + "\n"
|
||||
message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n"
|
||||
message += "🎯 Your turn! What will you do?"
|
||||
|
||||
keyboard = await keyboards.combat_keyboard(user_id)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboard,
|
||||
image_path=npc_def.image_url if npc_def else None
|
||||
)
|
||||
else:
|
||||
await query.answer("Failed to initiate combat.", show_alert=True)
|
||||
|
||||
|
||||
async def handle_inspect_interactable(query, user_id: int, player: dict, data: list):
|
||||
"""Handle inspecting an interactable object."""
|
||||
# Check if player is in combat and redirect if so
|
||||
if await check_and_redirect_if_in_combat(query, user_id, player):
|
||||
return
|
||||
|
||||
location_id, instance_id = data[1], data[2]
|
||||
|
||||
location = game_world.get_location(location_id)
|
||||
if not location:
|
||||
await query.answer("Location not found.", show_alert=True)
|
||||
return
|
||||
|
||||
interactable = location.get_interactable(instance_id)
|
||||
if not interactable:
|
||||
await query.answer("Object not found.", show_alert=False)
|
||||
return
|
||||
|
||||
# Check if ALL actions are on cooldown
|
||||
all_on_cooldown = True
|
||||
for action_id in interactable.actions.keys():
|
||||
cooldown_key = f"{instance_id}:{action_id}"
|
||||
if await api_client.get_cooldown(cooldown_key) == 0:
|
||||
all_on_cooldown = False
|
||||
break
|
||||
|
||||
if all_on_cooldown and len(interactable.actions) > 0:
|
||||
await query.answer(
|
||||
f"The {interactable.name} has already been searched. Try again later.",
|
||||
show_alert=False
|
||||
)
|
||||
return
|
||||
|
||||
# Show action menu
|
||||
await query.answer()
|
||||
image_path = interactable.image_path if interactable else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=f"You focus on the {interactable.name}. What do you do?",
|
||||
reply_markup=await keyboards.actions_keyboard(location_id, instance_id),
|
||||
image_path=image_path
|
||||
)
|
||||
|
||||
|
||||
async def handle_action(query, user_id: int, player: dict, data: list):
|
||||
"""Handle performing an action on an interactable object."""
|
||||
# Check if player is in combat and redirect if so
|
||||
if await check_and_redirect_if_in_combat(query, user_id, player):
|
||||
return
|
||||
|
||||
location_id, instance_id, action_id = data[1], data[2], data[3]
|
||||
cooldown_key = f"{instance_id}:{action_id}"
|
||||
cooldown = await api_client.get_cooldown(cooldown_key)
|
||||
|
||||
if cooldown > 0:
|
||||
await query.answer("Someone got to it just before you!", show_alert=False)
|
||||
return
|
||||
|
||||
location = game_world.get_location(location_id)
|
||||
if not location:
|
||||
await query.answer("Location not found.", show_alert=True)
|
||||
return
|
||||
|
||||
action_obj = location.get_interactable(instance_id).get_action(action_id)
|
||||
|
||||
if player['stamina'] < action_obj.stamina_cost:
|
||||
await query.answer("You are too tired to do that!", show_alert=False)
|
||||
return
|
||||
|
||||
await query.answer()
|
||||
|
||||
# Set cooldown
|
||||
await api_client.set_cooldown(cooldown_key)
|
||||
|
||||
# Resolve action
|
||||
outcome = logic.resolve_action(player, action_obj)
|
||||
new_stamina = player['stamina'] - action_obj.stamina_cost
|
||||
new_hp = player['hp'] - outcome.damage_taken
|
||||
await api_client.update_player(user_id, {"stamina": new_stamina, "hp": new_hp})
|
||||
|
||||
# Build detailed action result
|
||||
result_details = [f"<i>{outcome.text}</i>"]
|
||||
|
||||
if action_obj.stamina_cost > 0:
|
||||
result_details.append(f"⚡️ <b>Stamina:</b> -{action_obj.stamina_cost}")
|
||||
|
||||
if outcome.damage_taken > 0:
|
||||
result_details.append(f"❤️ <b>HP:</b> -{outcome.damage_taken}")
|
||||
|
||||
# Add items gained
|
||||
if outcome.items_reward:
|
||||
items_text = []
|
||||
items_failed = []
|
||||
for item_id, quantity in outcome.items_reward.items():
|
||||
can_add, reason = await logic.can_add_item_to_inventory(user_id, item_id, quantity)
|
||||
|
||||
if can_add:
|
||||
await api_client.add_item_to_inventory(user_id, item_id, quantity)
|
||||
item_def = ITEMS.get(item_id, {})
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
item_name = item_def.get('name', item_id)
|
||||
items_text.append(f"{emoji} {item_name} x{quantity}")
|
||||
else:
|
||||
item_def = ITEMS.get(item_id, {})
|
||||
item_name = item_def.get('name', item_id)
|
||||
items_failed.append(f"{item_name} ({reason})")
|
||||
|
||||
if items_text:
|
||||
result_details.append(f"🎁 <b>Gained:</b> {', '.join(items_text)}")
|
||||
if items_failed:
|
||||
result_details.append(f"⚠️ <b>Couldn't take:</b> {', '.join(items_failed)}")
|
||||
|
||||
final_text = await get_player_status_text(user_id)
|
||||
final_text += f"\n\n<b>━━━ Action Result ━━━</b>\n" + "\n".join(result_details)
|
||||
|
||||
# Get location image for the result screen
|
||||
current_location = game_world.get_location(player['location_id'])
|
||||
location_image = current_location.image_path if current_location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=final_text,
|
||||
reply_markup=keyboards.main_menu_keyboard(),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NAVIGATION & MOVEMENT HANDLERS
|
||||
# ============================================================================
|
||||
|
||||
async def handle_main_menu(query, user_id: int, player: dict, data: list = None):
|
||||
"""Return to main menu."""
|
||||
await query.answer()
|
||||
status_text = await get_player_status_text(user_id)
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=status_text,
|
||||
reply_markup=keyboards.main_menu_keyboard(),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_move_menu(query, user_id: int, player: dict, data: list = None):
|
||||
"""Show movement options menu."""
|
||||
# Check if player is in combat and redirect if so
|
||||
if await check_and_redirect_if_in_combat(query, user_id, player):
|
||||
return
|
||||
|
||||
await query.answer()
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="Where do you want to go?",
|
||||
reply_markup=await keyboards.move_keyboard(player['location_id'], user_id),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_move(query, user_id: int, player: dict, data: list):
|
||||
"""Handle player movement to a new location."""
|
||||
# Check if player is in combat and redirect if so
|
||||
if await check_and_redirect_if_in_combat(query, user_id, player):
|
||||
return
|
||||
|
||||
destination_id = data[1]
|
||||
|
||||
# Use API to move player
|
||||
from .api_client import api_client
|
||||
result = await api_client.move_player(player['id'], destination_id)
|
||||
|
||||
if not result.get('success'):
|
||||
await query.answer(result.get('message', 'Cannot move there!'), show_alert=True)
|
||||
return
|
||||
|
||||
await query.answer(result.get('message', 'Moving...'), show_alert=False)
|
||||
|
||||
# Refresh player data from API using unique id
|
||||
player = await api_client.get_player_by_id(user_id)
|
||||
|
||||
# Check for random NPC encounter
|
||||
from data.npcs import NPCS, get_random_npc_for_location, get_location_encounter_rate
|
||||
encounter_rate = get_location_encounter_rate(destination_id)
|
||||
|
||||
if random.random() < encounter_rate:
|
||||
from bot import combat
|
||||
logger.info(f"Encounter triggered at {destination_id} (rate: {encounter_rate})")
|
||||
|
||||
npc_id = get_random_npc_for_location(destination_id)
|
||||
|
||||
if npc_id:
|
||||
combat_data = await combat.initiate_combat(user_id, npc_id, destination_id)
|
||||
|
||||
if combat_data:
|
||||
npc_def = NPCS.get(npc_id)
|
||||
message = f"⚠️ A {npc_def.emoji} {npc_def.name} appears!\n\n"
|
||||
message += f"{npc_def.description}\n\n"
|
||||
message += format_stat_bar("Your HP", "❤️", player['hp'], player['max_hp']) + "\n"
|
||||
message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n"
|
||||
message += "🎯 Your turn! What will you do?"
|
||||
|
||||
keyboard = await keyboards.combat_keyboard(user_id)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboard,
|
||||
image_path=npc_def.image_url if npc_def else None
|
||||
)
|
||||
return
|
||||
|
||||
status_text = await get_player_status_text(user_id)
|
||||
new_location = game_world.get_location(destination_id)
|
||||
location_image = new_location.image_path if new_location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=status_text,
|
||||
reply_markup=keyboards.main_menu_keyboard(),
|
||||
image_path=location_image
|
||||
)
|
||||
@@ -1,198 +0,0 @@
|
||||
"""
|
||||
API Client for Telegram Bot
|
||||
Connects bot to FastAPI game server instead of using direct database access
|
||||
"""
|
||||
|
||||
import os
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://echoes_of_the_ashes_api:8000")
|
||||
API_INTERNAL_KEY = os.getenv("API_INTERNAL_KEY", "internal-bot-access-key-change-me")
|
||||
|
||||
|
||||
class GameAPIClient:
|
||||
"""Client for interacting with the FastAPI game server"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = API_BASE_URL
|
||||
self.headers = {
|
||||
"X-Internal-Key": API_INTERNAL_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client"""
|
||||
await self.client.aclose()
|
||||
|
||||
# ==================== Player Management ====================
|
||||
|
||||
async def get_player(self, telegram_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get player by telegram ID"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.base_url}/api/internal/player/telegram/{telegram_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting player: {e}")
|
||||
return None
|
||||
|
||||
async def create_player(self, telegram_id: int, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Create a new player"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/internal/player",
|
||||
headers=self.headers,
|
||||
json={"telegram_id": telegram_id, "name": name}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error creating player: {e}")
|
||||
return None
|
||||
|
||||
async def update_player(self, telegram_id: int, updates: Dict[str, Any]) -> bool:
|
||||
"""Update player data"""
|
||||
try:
|
||||
response = await self.client.patch(
|
||||
f"{self.base_url}/api/internal/player/telegram/{telegram_id}",
|
||||
headers=self.headers,
|
||||
json=updates
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error updating player: {e}")
|
||||
return False
|
||||
|
||||
# ==================== Location & Movement ====================
|
||||
|
||||
async def get_location(self, location_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get location details"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.base_url}/api/internal/location/{location_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting location: {e}")
|
||||
return None
|
||||
|
||||
async def move_player(self, telegram_id: int, direction: str) -> Optional[Dict[str, Any]]:
|
||||
"""Move player in a direction"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/internal/player/telegram/{telegram_id}/move",
|
||||
headers=self.headers,
|
||||
json={"direction": direction}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
# Return error details
|
||||
return {"success": False, "error": e.response.json().get("detail", str(e))}
|
||||
except Exception as e:
|
||||
print(f"Error moving player: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ==================== Combat ====================
|
||||
|
||||
async def start_combat(self, telegram_id: int, npc_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Start combat with an NPC"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/internal/combat/start",
|
||||
headers=self.headers,
|
||||
json={"telegram_id": telegram_id, "npc_id": npc_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error starting combat: {e}")
|
||||
return None
|
||||
|
||||
async def get_combat(self, telegram_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get active combat state"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.base_url}/api/internal/combat/telegram/{telegram_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting combat: {e}")
|
||||
return None
|
||||
|
||||
async def combat_action(self, telegram_id: int, action: str) -> Optional[Dict[str, Any]]:
|
||||
"""Perform a combat action (attack, defend, flee)"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/internal/combat/telegram/{telegram_id}/action",
|
||||
headers=self.headers,
|
||||
json={"action": action}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error performing combat action: {e}")
|
||||
return None
|
||||
|
||||
# ==================== Inventory ====================
|
||||
|
||||
async def get_inventory(self, telegram_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get player's inventory"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.base_url}/api/internal/player/telegram/{telegram_id}/inventory",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting inventory: {e}")
|
||||
return None
|
||||
|
||||
async def use_item(self, telegram_id: int, item_db_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Use an item from inventory"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/internal/player/telegram/{telegram_id}/use_item",
|
||||
headers=self.headers,
|
||||
json={"item_db_id": item_db_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error using item: {e}")
|
||||
return None
|
||||
|
||||
async def equip_item(self, telegram_id: int, item_db_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Equip/unequip an item"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/internal/player/telegram/{telegram_id}/equip",
|
||||
headers=self.headers,
|
||||
json={"item_db_id": item_db_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error equipping item: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Global API client instance
|
||||
api_client = GameAPIClient()
|
||||
@@ -1,623 +0,0 @@
|
||||
"""
|
||||
API client for the bot to communicate with the standalone API.
|
||||
All database operations now go through the API.
|
||||
"""
|
||||
import httpx
|
||||
import os
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
|
||||
class APIClient:
|
||||
"""Client for bot-to-API communication"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_url = os.getenv("API_BASE_URL", os.getenv("API_URL", "http://echoes_of_the_ashes_api:8000"))
|
||||
self.internal_key = os.getenv("API_INTERNAL_KEY", "change-this-internal-key")
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {self.internal_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client"""
|
||||
await self.client.aclose()
|
||||
|
||||
# Player operations
|
||||
async def get_player(self, telegram_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get player by Telegram ID"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/player/{telegram_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting player: {e}")
|
||||
return None
|
||||
|
||||
async def get_player_by_id(self, player_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get player by unique database ID"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/player/by_id/{player_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting player by id: {e}")
|
||||
return None
|
||||
|
||||
async def create_player(self, telegram_id: int, name: str = "Survivor") -> Optional[Dict[str, Any]]:
|
||||
"""Create a new player"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player",
|
||||
headers=self.headers,
|
||||
params={"telegram_id": telegram_id, "name": name}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error creating player: {e}")
|
||||
return None
|
||||
|
||||
# Movement operations
|
||||
async def move_player(self, player_id: int, direction: str) -> Dict[str, Any]:
|
||||
"""Move player in a direction"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/move",
|
||||
headers=self.headers,
|
||||
params={"direction": direction}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error moving player: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
# Inspection operations
|
||||
async def inspect_area(self, player_id: int) -> Dict[str, Any]:
|
||||
"""Inspect current area"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/inspect",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error inspecting area: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
# Interaction operations
|
||||
async def interact(self, player_id: int, interactable_id: str, action_id: str) -> Dict[str, Any]:
|
||||
"""Interact with an object"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/interact",
|
||||
headers=self.headers,
|
||||
params={"interactable_id": interactable_id, "action_id": action_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error interacting: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
# Inventory operations
|
||||
async def get_inventory(self, player_id: int) -> Dict[str, Any]:
|
||||
"""Get player inventory"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/inventory",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting inventory: {e}")
|
||||
return {"success": False, "inventory": []}
|
||||
|
||||
async def use_item(self, player_id: int, item_id: str) -> Dict[str, Any]:
|
||||
"""Use an item"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/use_item",
|
||||
headers=self.headers,
|
||||
params={"item_id": item_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error using item: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
async def pickup_item(self, player_id: int, item_id: str) -> Dict[str, Any]:
|
||||
"""Pick up an item"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/pickup",
|
||||
headers=self.headers,
|
||||
params={"item_id": item_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error picking up item: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
async def drop_item(self, player_id: int, item_id: str, quantity: int = 1) -> Dict[str, Any]:
|
||||
"""Drop an item"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/drop_item",
|
||||
headers=self.headers,
|
||||
params={"item_id": item_id, "quantity": quantity}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error dropping item: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
async def equip_item(self, player_id: int, item_id: str) -> Dict[str, Any]:
|
||||
"""Equip an item"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/equip",
|
||||
headers=self.headers,
|
||||
params={"item_id": item_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error equipping item: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
async def unequip_item(self, player_id: int, item_id: str) -> Dict[str, Any]:
|
||||
"""Unequip an item"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/unequip",
|
||||
headers=self.headers,
|
||||
params={"item_id": item_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error unequipping item: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
# Combat operations
|
||||
async def get_combat(self, player_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get active combat for player"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/combat",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting combat: {e}")
|
||||
return None
|
||||
|
||||
async def create_combat(self, player_id: int, npc_id: str, npc_hp: int, npc_max_hp: int, location_id: str, from_wandering: bool = False) -> Optional[Dict[str, Any]]:
|
||||
"""Create new combat"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/combat/create",
|
||||
headers=self.headers,
|
||||
params={
|
||||
"player_id": player_id,
|
||||
"npc_id": npc_id,
|
||||
"npc_hp": npc_hp,
|
||||
"npc_max_hp": npc_max_hp,
|
||||
"location_id": location_id,
|
||||
"from_wandering": from_wandering
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error creating combat: {e}")
|
||||
return None
|
||||
|
||||
async def update_combat(self, player_id: int, updates: Dict[str, Any]) -> bool:
|
||||
"""Update combat state"""
|
||||
try:
|
||||
response = await self.client.patch(
|
||||
f"{self.api_url}/api/internal/combat/{player_id}",
|
||||
headers=self.headers,
|
||||
json=updates
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error updating combat: {e}")
|
||||
return False
|
||||
|
||||
async def end_combat(self, player_id: int) -> bool:
|
||||
"""End combat"""
|
||||
try:
|
||||
response = await self.client.delete(
|
||||
f"{self.api_url}/api/internal/combat/{player_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error ending combat: {e}")
|
||||
return False
|
||||
|
||||
# Player update operations
|
||||
async def update_player(self, player_id: int, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Update player fields"""
|
||||
try:
|
||||
response = await self.client.patch(
|
||||
f"{self.api_url}/api/internal/player/{player_id}",
|
||||
headers=self.headers,
|
||||
json=updates
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error updating player: {e}")
|
||||
return None
|
||||
|
||||
# Dropped items operations
|
||||
async def drop_item_to_world(self, item_id: str, quantity: int, location_id: str) -> bool:
|
||||
"""Drop an item to the world"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/dropped-items",
|
||||
headers=self.headers,
|
||||
params={"item_id": item_id, "quantity": quantity, "location_id": location_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error dropping item: {e}")
|
||||
return False
|
||||
|
||||
async def get_dropped_item(self, dropped_item_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific dropped item"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/dropped-items/{dropped_item_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting dropped item: {e}")
|
||||
return None
|
||||
|
||||
async def get_dropped_items_in_location(self, location_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all dropped items in a location"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/location/{location_id}/dropped-items",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting dropped items: {e}")
|
||||
return []
|
||||
|
||||
async def update_dropped_item(self, dropped_item_id: int, quantity: int) -> bool:
|
||||
"""Update dropped item quantity"""
|
||||
try:
|
||||
response = await self.client.patch(
|
||||
f"{self.api_url}/api/internal/dropped-items/{dropped_item_id}",
|
||||
headers=self.headers,
|
||||
params={"quantity": quantity}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error updating dropped item: {e}")
|
||||
return False
|
||||
|
||||
async def remove_dropped_item(self, dropped_item_id: int) -> bool:
|
||||
"""Remove a dropped item"""
|
||||
try:
|
||||
response = await self.client.delete(
|
||||
f"{self.api_url}/api/internal/dropped-items/{dropped_item_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error removing dropped item: {e}")
|
||||
return False
|
||||
|
||||
# Corpse operations
|
||||
async def create_player_corpse(self, player_name: str, location_id: str, items: str) -> Optional[int]:
|
||||
"""Create a player corpse"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/corpses/player",
|
||||
headers=self.headers,
|
||||
params={"player_name": player_name, "location_id": location_id, "items": items}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('corpse_id')
|
||||
except Exception as e:
|
||||
print(f"Error creating player corpse: {e}")
|
||||
return None
|
||||
|
||||
async def get_player_corpse(self, corpse_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get a player corpse"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/corpses/player/{corpse_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting player corpse: {e}")
|
||||
return None
|
||||
|
||||
async def update_player_corpse(self, corpse_id: int, items: str) -> bool:
|
||||
"""Update player corpse items"""
|
||||
try:
|
||||
response = await self.client.patch(
|
||||
f"{self.api_url}/api/internal/corpses/player/{corpse_id}",
|
||||
headers=self.headers,
|
||||
params={"items": items}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error updating player corpse: {e}")
|
||||
return False
|
||||
|
||||
async def remove_player_corpse(self, corpse_id: int) -> bool:
|
||||
"""Remove a player corpse"""
|
||||
try:
|
||||
response = await self.client.delete(
|
||||
f"{self.api_url}/api/internal/corpses/player/{corpse_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error removing player corpse: {e}")
|
||||
return False
|
||||
|
||||
async def create_npc_corpse(self, npc_id: str, location_id: str, loot_remaining: str) -> Optional[int]:
|
||||
"""Create an NPC corpse"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/corpses/npc",
|
||||
headers=self.headers,
|
||||
params={"npc_id": npc_id, "location_id": location_id, "loot_remaining": loot_remaining}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('corpse_id')
|
||||
except Exception as e:
|
||||
print(f"Error creating NPC corpse: {e}")
|
||||
return None
|
||||
|
||||
async def get_npc_corpse(self, corpse_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get an NPC corpse"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/corpses/npc/{corpse_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting NPC corpse: {e}")
|
||||
return None
|
||||
|
||||
async def update_npc_corpse(self, corpse_id: int, loot_remaining: str) -> bool:
|
||||
"""Update NPC corpse loot"""
|
||||
try:
|
||||
response = await self.client.patch(
|
||||
f"{self.api_url}/api/internal/corpses/npc/{corpse_id}",
|
||||
headers=self.headers,
|
||||
params={"loot_remaining": loot_remaining}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error updating NPC corpse: {e}")
|
||||
return False
|
||||
|
||||
async def remove_npc_corpse(self, corpse_id: int) -> bool:
|
||||
"""Remove an NPC corpse"""
|
||||
try:
|
||||
response = await self.client.delete(
|
||||
f"{self.api_url}/api/internal/corpses/npc/{corpse_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error removing NPC corpse: {e}")
|
||||
return False
|
||||
|
||||
# Wandering enemies operations
|
||||
async def spawn_wandering_enemy(self, npc_id: str, location_id: str, current_hp: int, max_hp: int) -> Optional[int]:
|
||||
"""Spawn a wandering enemy"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/wandering-enemies",
|
||||
headers=self.headers,
|
||||
params={"npc_id": npc_id, "location_id": location_id, "current_hp": current_hp, "max_hp": max_hp}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('enemy_id')
|
||||
except Exception as e:
|
||||
print(f"Error spawning wandering enemy: {e}")
|
||||
return None
|
||||
|
||||
async def get_wandering_enemies_in_location(self, location_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all wandering enemies in a location"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/location/{location_id}/wandering-enemies",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting wandering enemies: {e}")
|
||||
return []
|
||||
|
||||
async def remove_wandering_enemy(self, enemy_id: int) -> bool:
|
||||
"""Remove a wandering enemy"""
|
||||
try:
|
||||
response = await self.client.delete(
|
||||
f"{self.api_url}/api/internal/wandering-enemies/{enemy_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error removing wandering enemy: {e}")
|
||||
return False
|
||||
|
||||
async def get_inventory_item(self, item_db_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific inventory item by database ID"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/inventory/item/{item_db_id}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting inventory item: {e}")
|
||||
return None
|
||||
|
||||
# Cooldown operations
|
||||
async def get_cooldown(self, cooldown_key: str) -> int:
|
||||
"""Get remaining cooldown time in seconds"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/cooldown/{cooldown_key}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('remaining_seconds', 0)
|
||||
except Exception as e:
|
||||
print(f"Error getting cooldown: {e}")
|
||||
return 0
|
||||
|
||||
async def set_cooldown(self, cooldown_key: str, duration_seconds: int = 600) -> bool:
|
||||
"""Set a cooldown"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/cooldown/{cooldown_key}",
|
||||
headers=self.headers,
|
||||
params={"duration_seconds": duration_seconds}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error setting cooldown: {e}")
|
||||
return False
|
||||
|
||||
# Corpse list operations
|
||||
async def get_player_corpses_in_location(self, location_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all player corpses in a location"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/location/{location_id}/corpses/player",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting player corpses: {e}")
|
||||
return []
|
||||
|
||||
async def get_npc_corpses_in_location(self, location_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all NPC corpses in a location"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/location/{location_id}/corpses/npc",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting NPC corpses: {e}")
|
||||
return []
|
||||
|
||||
# Image cache operations
|
||||
async def get_cached_image(self, image_path: str) -> Optional[str]:
|
||||
"""Get cached telegram file ID for an image"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/image-cache/{image_path}",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('telegram_file_id')
|
||||
except Exception as e:
|
||||
# Not found is expected, not an error
|
||||
return None
|
||||
|
||||
async def cache_image(self, image_path: str, telegram_file_id: str) -> bool:
|
||||
"""Cache a telegram file ID for an image"""
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/api/internal/image-cache",
|
||||
headers=self.headers,
|
||||
params={"image_path": image_path, "telegram_file_id": telegram_file_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get('success', False)
|
||||
except Exception as e:
|
||||
print(f"Error caching image: {e}")
|
||||
return False
|
||||
|
||||
# Status effects operations
|
||||
async def get_player_status_effects(self, player_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get player status effects"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/api/internal/player/{player_id}/status-effects",
|
||||
headers=self.headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Error getting status effects: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# Global API client instance
|
||||
api_client = APIClient()
|
||||
@@ -1,201 +0,0 @@
|
||||
"""
|
||||
Background tasks for the bot.
|
||||
Handles periodic maintenance, regeneration, and processing.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from bot import database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def decay_dropped_items(shutdown_event):
|
||||
"""A background task that periodically cleans up old dropped items."""
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
# Wait for 5 minutes before the next cleanup
|
||||
await asyncio.wait_for(shutdown_event.wait(), timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
start_time = time.time()
|
||||
logger.info("Running item decay task...")
|
||||
|
||||
# Set decay time to 1 hour (3600 seconds)
|
||||
decay_seconds = 3600
|
||||
timestamp_limit = int(time.time()) - decay_seconds
|
||||
items_removed = await database.remove_expired_dropped_items(timestamp_limit)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if items_removed > 0:
|
||||
logger.info(f"Decayed and removed {items_removed} old items in {elapsed:.2f}s")
|
||||
|
||||
|
||||
async def regenerate_stamina(shutdown_event):
|
||||
"""A background task that periodically regenerates stamina for all players."""
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
# Wait for 5 minutes before the next regeneration cycle
|
||||
await asyncio.wait_for(shutdown_event.wait(), timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
start_time = time.time()
|
||||
logger.info("Running stamina regeneration...")
|
||||
|
||||
players_updated = await database.regenerate_all_players_stamina()
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if players_updated > 0:
|
||||
logger.info(f"Regenerated stamina for {players_updated} players in {elapsed:.2f}s")
|
||||
|
||||
# Alert if regeneration is taking too long (potential scaling issue)
|
||||
if elapsed > 5.0:
|
||||
logger.warning(f"⚠️ Stamina regeneration took {elapsed:.2f}s (threshold: 5s) - check database load!")
|
||||
|
||||
|
||||
async def check_combat_timers(shutdown_event):
|
||||
"""A background task that checks for idle combat turns and auto-attacks."""
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
# Wait for 30 seconds before next check
|
||||
await asyncio.wait_for(shutdown_event.wait(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
start_time = time.time()
|
||||
# Check for combats idle for more than 5 minutes (300 seconds)
|
||||
idle_threshold = time.time() - 300
|
||||
idle_combats = await database.get_all_idle_combats(idle_threshold)
|
||||
|
||||
if idle_combats:
|
||||
logger.info(f"Processing {len(idle_combats)} idle combats...")
|
||||
|
||||
for combat in idle_combats:
|
||||
try:
|
||||
from bot import combat as combat_logic
|
||||
# Force end player's turn and let NPC attack
|
||||
if combat['turn'] == 'player':
|
||||
await database.update_combat(combat['player_id'], {
|
||||
'turn': 'npc',
|
||||
'turn_started_at': time.time()
|
||||
})
|
||||
# NPC attacks
|
||||
await combat_logic.npc_attack(combat['player_id'])
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing idle combat: {e}")
|
||||
|
||||
# Log performance for monitoring
|
||||
if idle_combats:
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(f"Processed {len(idle_combats)} idle combats in {elapsed:.2f}s")
|
||||
|
||||
# Warn if taking too long (potential scaling issue)
|
||||
if elapsed > 10.0:
|
||||
logger.warning(f"⚠️ Combat timer check took {elapsed:.2f}s (threshold: 10s) - consider batching!")
|
||||
|
||||
|
||||
async def decay_corpses(shutdown_event):
|
||||
"""A background task that removes old corpses."""
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
# Wait for 10 minutes before next cleanup
|
||||
await asyncio.wait_for(shutdown_event.wait(), timeout=600)
|
||||
except asyncio.TimeoutError:
|
||||
start_time = time.time()
|
||||
logger.info("Running corpse decay...")
|
||||
|
||||
# Player corpses decay after 24 hours
|
||||
player_corpse_limit = time.time() - (24 * 3600)
|
||||
player_corpses_removed = await database.remove_expired_player_corpses(player_corpse_limit)
|
||||
|
||||
# NPC corpses decay after 2 hours
|
||||
npc_corpse_limit = time.time() - (2 * 3600)
|
||||
npc_corpses_removed = await database.remove_expired_npc_corpses(npc_corpse_limit)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if player_corpses_removed > 0 or npc_corpses_removed > 0:
|
||||
logger.info(f"Decayed {player_corpses_removed} player corpses and {npc_corpses_removed} NPC corpses in {elapsed:.2f}s")
|
||||
|
||||
|
||||
async def process_status_effects(shutdown_event):
|
||||
"""
|
||||
A background task that applies damage from persistent status effects.
|
||||
Runs every 5 minutes to process status effect ticks.
|
||||
"""
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
# Wait for 5 minutes before next processing cycle
|
||||
await asyncio.wait_for(shutdown_event.wait(), timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
start_time = time.time()
|
||||
logger.info("Running status effects processor...")
|
||||
|
||||
try:
|
||||
# Decrement all status effect ticks and get affected players
|
||||
affected_players = await database.decrement_all_status_effect_ticks()
|
||||
|
||||
if not affected_players:
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(f"No active status effects to process ({elapsed:.3f}s)")
|
||||
continue
|
||||
|
||||
# Process each affected player
|
||||
deaths = 0
|
||||
damage_dealt = 0
|
||||
|
||||
for player_id in affected_players:
|
||||
try:
|
||||
# Get current status effects (after decrement)
|
||||
effects = await database.get_player_status_effects(player_id)
|
||||
|
||||
if not effects:
|
||||
continue
|
||||
|
||||
# Calculate total damage
|
||||
from bot.status_utils import calculate_status_damage
|
||||
total_damage = calculate_status_damage(effects)
|
||||
|
||||
if total_damage > 0:
|
||||
damage_dealt += total_damage
|
||||
player = await database.get_player(player_id)
|
||||
|
||||
if not player or player['is_dead']:
|
||||
continue
|
||||
|
||||
new_hp = max(0, player['hp'] - total_damage)
|
||||
|
||||
# Check if player died from status effects
|
||||
if new_hp <= 0:
|
||||
await database.update_player(player_id, {'hp': 0, 'is_dead': True})
|
||||
deaths += 1
|
||||
|
||||
# Create player corpse
|
||||
inventory = await database.get_inventory(player_id)
|
||||
await database.create_player_corpse(
|
||||
player_name=player['name'],
|
||||
location_id=player['location_id'],
|
||||
items=inventory
|
||||
)
|
||||
|
||||
# Remove status effects from dead player
|
||||
await database.remove_all_status_effects(player_id)
|
||||
|
||||
logger.info(f"Player {player['name']} (ID: {player_id}) died from status effects")
|
||||
else:
|
||||
# Apply damage
|
||||
await database.update_player(player_id, {'hp': new_hp})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing status effects for player {player_id}: {e}")
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
f"Processed status effects for {len(affected_players)} players "
|
||||
f"({damage_dealt} total damage, {deaths} deaths) in {elapsed:.3f}s"
|
||||
)
|
||||
|
||||
# Warn if taking too long (potential scaling issue)
|
||||
if elapsed > 5.0:
|
||||
logger.warning(
|
||||
f"⚠️ Status effects processing took {elapsed:.3f}s (threshold: 5s) "
|
||||
f"- {len(affected_players)} players affected"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in status effects processor: {e}")
|
||||
527
bot/combat.py
@@ -1,527 +0,0 @@
|
||||
"""
|
||||
Combat system logic for turn-based NPC encounters.
|
||||
"""
|
||||
|
||||
import random
|
||||
import json
|
||||
import time
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from bot.api_client import api_client
|
||||
from bot.utils import format_stat_bar
|
||||
from data.npcs import NPCS, STATUS_EFFECTS
|
||||
from data.items import ITEMS
|
||||
|
||||
|
||||
# XP curve for leveling
|
||||
def xp_for_level(level: int) -> int:
|
||||
"""Calculate XP needed to reach a level."""
|
||||
if level <= 1:
|
||||
return 0 # Level 1 starts at 0 XP
|
||||
return int(100 * (level ** 1.5))
|
||||
|
||||
|
||||
async def calculate_player_damage(player: dict) -> int:
|
||||
"""Calculate player's damage output based on stats and equipped weapon."""
|
||||
base_damage = 5
|
||||
strength_bonus = player['strength'] // 2
|
||||
level_bonus = player['level']
|
||||
|
||||
# Check for equipped weapon
|
||||
inventory = await api_client.get_inventory(player['telegram_id'])
|
||||
weapon_damage = 0
|
||||
|
||||
for item in inventory:
|
||||
if item.get('is_equipped'):
|
||||
item_def = ITEMS.get(item['item_id'], {})
|
||||
if item_def.get('type') == 'weapon':
|
||||
# Get weapon damage range
|
||||
damage_min = item_def.get('damage_min', 0)
|
||||
damage_max = item_def.get('damage_max', 0)
|
||||
weapon_damage = random.randint(damage_min, damage_max)
|
||||
break
|
||||
|
||||
# Random variance
|
||||
variance = random.randint(-2, 2)
|
||||
|
||||
return max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
|
||||
|
||||
|
||||
def calculate_npc_damage(npc_def, npc_hp: int, npc_max_hp: int) -> int:
|
||||
"""Calculate NPC's damage output."""
|
||||
base_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
||||
|
||||
# Enraged bonus if low HP
|
||||
hp_percent = npc_hp / npc_max_hp
|
||||
if hp_percent < 0.3:
|
||||
base_damage = int(base_damage * 1.5)
|
||||
|
||||
return max(1, base_damage)
|
||||
|
||||
|
||||
async def initiate_combat(player_id: int, npc_id: str, location_id: str, from_wandering_enemy: bool = False) -> Dict:
|
||||
"""
|
||||
Start a new combat encounter.
|
||||
Args:
|
||||
player_id: Telegram user ID
|
||||
npc_id: NPC definition ID
|
||||
location_id: Where combat is happening
|
||||
from_wandering_enemy: If True, enemy will respawn if player flees or dies
|
||||
Returns combat state dict.
|
||||
"""
|
||||
npc_def = NPCS.get(npc_id)
|
||||
if not npc_def:
|
||||
return None
|
||||
|
||||
# Randomize NPC HP
|
||||
npc_hp = random.randint(npc_def.hp_min, npc_def.hp_max)
|
||||
|
||||
# Create combat in database
|
||||
combat_id = await api_client.create_combat(
|
||||
player_id=player_id,
|
||||
npc_id=npc_id,
|
||||
npc_hp=npc_hp,
|
||||
npc_max_hp=npc_hp,
|
||||
location_id=location_id,
|
||||
from_wandering_enemy=from_wandering_enemy
|
||||
)
|
||||
|
||||
return await api_client.get_combat(player_id)
|
||||
|
||||
|
||||
async def player_attack(player_id: int) -> Tuple[str, bool, bool]:
|
||||
"""
|
||||
Player attacks the NPC.
|
||||
Returns: (message, npc_died, player_turn_ended)
|
||||
"""
|
||||
combat = await api_client.get_combat(player_id)
|
||||
if not combat or combat['turn'] != 'player':
|
||||
return ("It's not your turn!", False, False)
|
||||
|
||||
player = await api_client.get_player(player_id)
|
||||
npc_def = NPCS.get(combat['npc_id'])
|
||||
|
||||
if not player or not npc_def:
|
||||
return ("Combat error!", False, False)
|
||||
|
||||
# Check if player is stunned
|
||||
player_effects = json.loads(combat['player_status_effects'])
|
||||
is_stunned = any(effect['name'] == 'Stunned' for effect in player_effects)
|
||||
if is_stunned:
|
||||
# Update status effects
|
||||
player_effects = update_status_effects(player_effects)
|
||||
await api_client.update_combat(player_id, {
|
||||
'turn': 'npc',
|
||||
'turn_started_at': time.time(),
|
||||
'player_status_effects': json.dumps(player_effects)
|
||||
})
|
||||
return ("⚠️ You're stunned and cannot attack! The enemy seizes the opportunity!", False, True)
|
||||
|
||||
# Calculate damage
|
||||
raw_damage = await calculate_player_damage(player)
|
||||
actual_damage = max(1, raw_damage - npc_def.defense)
|
||||
|
||||
new_npc_hp = max(0, combat['npc_hp'] - actual_damage)
|
||||
|
||||
# Check for critical hit (10% chance)
|
||||
is_crit = random.random() < 0.1
|
||||
if is_crit:
|
||||
actual_damage = int(actual_damage * 1.5)
|
||||
new_npc_hp = max(0, combat['npc_hp'] - actual_damage)
|
||||
|
||||
message = "━━━ YOUR TURN ━━━\n"
|
||||
message += f"⚔️ You attack the {npc_def.name} for {actual_damage} damage!"
|
||||
if is_crit:
|
||||
message += " 💥 CRITICAL HIT!"
|
||||
|
||||
# Check for status effect infliction (5% chance to stun)
|
||||
npc_effects = json.loads(combat['npc_status_effects'])
|
||||
if random.random() < 0.05:
|
||||
npc_effects.append({
|
||||
'name': 'Stunned',
|
||||
'turns_remaining': 1,
|
||||
'damage_per_turn': 0
|
||||
})
|
||||
message += f"\n🌟 You stunned the {npc_def.name}!"
|
||||
|
||||
# Apply status effect damage to player
|
||||
player_effects, status_damage, status_messages = apply_status_effects(player_effects)
|
||||
if status_damage > 0:
|
||||
new_player_hp = max(0, player['hp'] - status_damage)
|
||||
await api_client.update_player(player_id, {'hp': new_player_hp})
|
||||
message += f"\n{status_messages}"
|
||||
|
||||
if new_player_hp <= 0:
|
||||
await handle_player_death(player_id)
|
||||
return (message + "\n\n💀 You have died from your wounds...", True, True)
|
||||
|
||||
# Check if NPC died
|
||||
if new_npc_hp <= 0:
|
||||
await api_client.update_combat(player_id, {
|
||||
'npc_hp': 0,
|
||||
'npc_status_effects': json.dumps(npc_effects),
|
||||
'player_status_effects': json.dumps(player_effects)
|
||||
})
|
||||
|
||||
# Handle victory
|
||||
victory_msg = await handle_npc_death(player_id, combat, npc_def)
|
||||
return (message + "\n\n" + victory_msg, True, True)
|
||||
|
||||
# Update combat - switch to NPC turn
|
||||
await api_client.update_combat(player_id, {
|
||||
'npc_hp': new_npc_hp,
|
||||
'turn': 'npc',
|
||||
'turn_started_at': time.time(),
|
||||
'npc_status_effects': json.dumps(npc_effects),
|
||||
'player_status_effects': json.dumps(player_effects)
|
||||
})
|
||||
|
||||
# Show both health bars after player's turn
|
||||
message += "\n━━━━━━━━━━━━━━━━━━━━\n"
|
||||
message += format_stat_bar("Your HP", "❤️", player['hp'], player['max_hp']) + "\n"
|
||||
message += format_stat_bar(npc_def.name, npc_def.emoji, new_npc_hp, combat['npc_max_hp'])
|
||||
|
||||
return (message, False, True)
|
||||
|
||||
|
||||
|
||||
async def npc_attack(player_id: int) -> Tuple[str, bool]:
|
||||
"""
|
||||
NPC attacks the player.
|
||||
Returns: (message, player_died)
|
||||
"""
|
||||
combat = await api_client.get_combat(player_id)
|
||||
if not combat or combat['turn'] != 'npc':
|
||||
return ("", False)
|
||||
|
||||
player = await api_client.get_player(player_id)
|
||||
npc_def = NPCS.get(combat['npc_id'])
|
||||
|
||||
if not player or not npc_def:
|
||||
return ("Combat error!", False)
|
||||
|
||||
# Check if NPC is stunned
|
||||
npc_effects = json.loads(combat['npc_status_effects'])
|
||||
is_stunned = any(effect['name'] == 'Stunned' for effect in npc_effects)
|
||||
if is_stunned:
|
||||
# Update status effects
|
||||
npc_effects = update_status_effects(npc_effects)
|
||||
await api_client.update_combat(player_id, {
|
||||
'turn': 'player',
|
||||
'turn_started_at': time.time(),
|
||||
'npc_status_effects': json.dumps(npc_effects)
|
||||
})
|
||||
return (f"⚠️ The {npc_def.name} is stunned and cannot attack!", False)
|
||||
|
||||
# Calculate damage
|
||||
damage = calculate_npc_damage(npc_def, combat['npc_hp'], combat['npc_max_hp'])
|
||||
|
||||
# Apply damage to player
|
||||
new_player_hp = max(0, player['hp'] - damage)
|
||||
await api_client.update_player(player_id, {'hp': new_player_hp})
|
||||
|
||||
message = "━━━ ENEMY TURN ━━━\n"
|
||||
message += f"💥 The {npc_def.name} attacks you for {damage} damage!"
|
||||
|
||||
# Check for status effect infliction
|
||||
player_effects = json.loads(combat['player_status_effects'])
|
||||
if random.random() < npc_def.status_inflict_chance:
|
||||
# Bleeding is most common
|
||||
player_effects.append({
|
||||
'name': 'Bleeding',
|
||||
'turns_remaining': 3,
|
||||
'damage_per_turn': 2
|
||||
})
|
||||
message += "\n🩸 You're bleeding!"
|
||||
|
||||
# Apply status effect damage to NPC
|
||||
npc_effects, status_damage, status_messages = apply_status_effects(npc_effects)
|
||||
if status_damage > 0:
|
||||
new_npc_hp = max(0, combat['npc_hp'] - status_damage)
|
||||
await api_client.update_combat(player_id, {'npc_hp': new_npc_hp})
|
||||
message += f"\n{status_messages}"
|
||||
|
||||
if new_npc_hp <= 0:
|
||||
victory_msg = await handle_npc_death(player_id, combat, npc_def)
|
||||
return (message + "\n\n" + victory_msg, False)
|
||||
|
||||
# Check if player died
|
||||
if new_player_hp <= 0:
|
||||
await handle_player_death(player_id)
|
||||
return (message + "\n\n💀 You have been slain...", True)
|
||||
|
||||
# Update combat - switch to player turn
|
||||
await api_client.update_combat(player_id, {
|
||||
'turn': 'player',
|
||||
'turn_started_at': time.time(),
|
||||
'player_status_effects': json.dumps(player_effects),
|
||||
'npc_status_effects': json.dumps(npc_effects)
|
||||
})
|
||||
|
||||
# Show both health bars after enemy's turn
|
||||
message += "\n━━━━━━━━━━━━━━━━━━━━\n"
|
||||
message += format_stat_bar("Your HP", "❤️", new_player_hp, player['max_hp']) + "\n"
|
||||
message += format_stat_bar(npc_def.name, npc_def.emoji, combat['npc_hp'], combat['npc_max_hp'])
|
||||
|
||||
return (message, False)
|
||||
|
||||
|
||||
async def flee_attempt(player_id: int) -> Tuple[str, bool, bool]:
|
||||
"""
|
||||
Player attempts to flee from combat.
|
||||
Returns: (message, fled_successfully, turn_ended)
|
||||
"""
|
||||
combat = await api_client.get_combat(player_id)
|
||||
if not combat or combat['turn'] != 'player':
|
||||
return ("It's not your turn!", False, False)
|
||||
|
||||
player = await api_client.get_player(player_id)
|
||||
npc_def = NPCS.get(combat['npc_id'])
|
||||
|
||||
# Base flee chance is 50%, modified by agility
|
||||
flee_chance = 0.5 + (player['agility'] / 100)
|
||||
|
||||
if random.random() < flee_chance:
|
||||
# Success! Check if we need to respawn the wandering enemy
|
||||
if combat.get('from_wandering_enemy', False):
|
||||
# Respawn the enemy at the same location with full HP
|
||||
await api_client.spawn_wandering_enemy(
|
||||
npc_id=combat['npc_id'],
|
||||
location_id=combat['location_id'],
|
||||
current_hp=npc_def.hp,
|
||||
max_hp=npc_def.hp
|
||||
)
|
||||
|
||||
await api_client.end_combat(player_id)
|
||||
return (f"🏃 You successfully flee from the {npc_def.name}!", True, True)
|
||||
else:
|
||||
# Failed - lose turn and NPC attacks
|
||||
message = f"❌ You failed to escape! The {npc_def.name} takes advantage!"
|
||||
|
||||
# NPC gets a free attack
|
||||
await api_client.update_combat(player_id, {
|
||||
'turn': 'npc',
|
||||
'turn_started_at': time.time()
|
||||
})
|
||||
|
||||
return (message, False, True)
|
||||
|
||||
|
||||
def update_status_effects(effects: List[Dict]) -> List[Dict]:
|
||||
"""Decrease turn counters on status effects."""
|
||||
new_effects = []
|
||||
for effect in effects:
|
||||
effect['turns_remaining'] -= 1
|
||||
if effect['turns_remaining'] > 0:
|
||||
new_effects.append(effect)
|
||||
return new_effects
|
||||
|
||||
|
||||
def apply_status_effects(effects: List[Dict]) -> Tuple[List[Dict], int, str]:
|
||||
"""
|
||||
Apply status effect damage with stacking.
|
||||
Returns: (updated_effects, total_damage, message)
|
||||
"""
|
||||
from bot.status_utils import stack_status_effects
|
||||
|
||||
if not effects:
|
||||
return effects, 0, ""
|
||||
|
||||
# Convert effect format if needed (name -> effect_name, damage_per_turn -> damage_per_tick)
|
||||
normalized_effects = []
|
||||
for effect in effects:
|
||||
normalized = {
|
||||
'effect_name': effect.get('name', effect.get('effect_name', 'Unknown')),
|
||||
'effect_icon': effect.get('icon', effect.get('effect_icon', '❓')),
|
||||
'damage_per_tick': effect.get('damage_per_turn', effect.get('damage_per_tick', 0)),
|
||||
'ticks_remaining': effect.get('turns_remaining', effect.get('ticks_remaining', 0))
|
||||
}
|
||||
normalized_effects.append(normalized)
|
||||
|
||||
# Stack effects
|
||||
stacked = stack_status_effects(normalized_effects)
|
||||
|
||||
total_damage = 0
|
||||
messages = []
|
||||
|
||||
for name, data in stacked.items():
|
||||
if data['total_damage'] > 0:
|
||||
total_damage += data['total_damage']
|
||||
# Show stacked damage
|
||||
if data['stacks'] > 1:
|
||||
messages.append(f"{data['icon']} {name.replace('_', ' ').title()}: -{data['total_damage']} HP (×{data['stacks']})")
|
||||
else:
|
||||
messages.append(f"{data['icon']} {name.replace('_', ' ').title()}: -{data['total_damage']} HP")
|
||||
|
||||
return effects, total_damage, "\n".join(messages)
|
||||
|
||||
|
||||
async def handle_npc_death(player_id: int, combat: Dict, npc_def) -> str:
|
||||
"""Handle NPC death - give XP, drop loot, create corpse."""
|
||||
player = await api_client.get_player(player_id)
|
||||
|
||||
# Give XP
|
||||
new_xp = player['xp'] + npc_def.xp_reward
|
||||
level_up_msg = ""
|
||||
|
||||
# Check for level up
|
||||
current_level = player['level']
|
||||
xp_needed = xp_for_level(current_level + 1)
|
||||
|
||||
if new_xp >= xp_needed:
|
||||
new_level = current_level + 1
|
||||
# Give stat points instead of auto-allocating
|
||||
# Players get 5 points per level to spend as they wish
|
||||
points_gained = 5
|
||||
new_unspent_points = player.get('unspent_points', 0) + points_gained
|
||||
|
||||
await api_client.update_player(player_id, {
|
||||
'xp': new_xp,
|
||||
'level': new_level,
|
||||
'hp': player['max_hp'], # Heal on level up
|
||||
'stamina': player['max_stamina'], # Restore stamina on level up
|
||||
'unspent_points': new_unspent_points
|
||||
})
|
||||
|
||||
level_up_msg = f"\n\n🎉 LEVEL UP! You are now level {new_level}!"
|
||||
level_up_msg += f"\n⭐ You have {points_gained} stat points to spend!"
|
||||
level_up_msg += f"\n❤️ Fully healed and stamina restored!"
|
||||
level_up_msg += f"\n\n💡 Check your profile to spend your points!"
|
||||
else:
|
||||
await api_client.update_player(player_id, {'xp': new_xp})
|
||||
|
||||
# Drop loot
|
||||
loot_msg = "\n\n💰 Loot dropped:"
|
||||
loot_items = []
|
||||
for loot_item in npc_def.loot_table:
|
||||
if random.random() < loot_item.drop_chance:
|
||||
quantity = random.randint(loot_item.quantity_min, loot_item.quantity_max)
|
||||
await api_client.drop_item_to_world(
|
||||
loot_item.item_id,
|
||||
quantity,
|
||||
combat['location_id']
|
||||
)
|
||||
item_def = ITEMS.get(loot_item.item_id, {})
|
||||
loot_msg += f"\n{item_def.get('emoji', '❔')} {item_def.get('name', 'Unknown')} x{quantity}"
|
||||
loot_items.append(loot_item.item_id)
|
||||
|
||||
if not loot_items:
|
||||
loot_msg += "\nNothing..."
|
||||
|
||||
# Create corpse if it has corpse loot
|
||||
if npc_def.corpse_loot:
|
||||
corpse_loot_json = json.dumps([{
|
||||
'item_id': cl.item_id,
|
||||
'quantity_min': cl.quantity_min,
|
||||
'quantity_max': cl.quantity_max,
|
||||
'required_tool': cl.required_tool
|
||||
} for cl in npc_def.corpse_loot])
|
||||
|
||||
await api_client.create_npc_corpse(
|
||||
npc_id=combat['npc_id'],
|
||||
location_id=combat['location_id'],
|
||||
loot_remaining=corpse_loot_json
|
||||
)
|
||||
loot_msg += f"\n\n{npc_def.emoji} The corpse can be scavenged for more resources."
|
||||
|
||||
# End combat
|
||||
await api_client.end_combat(player_id)
|
||||
|
||||
message = f"🏆 Victory! {npc_def.death_message}"
|
||||
message += f"\n+{npc_def.xp_reward} XP"
|
||||
message += level_up_msg
|
||||
message += loot_msg
|
||||
|
||||
return message
|
||||
|
||||
|
||||
async def handle_player_death(player_id: int):
|
||||
"""Handle player death - create corpse bag with all items."""
|
||||
player = await api_client.get_player(player_id)
|
||||
inventory_items = await api_client.get_inventory(player_id)
|
||||
|
||||
# Check if combat was with a wandering enemy that should respawn
|
||||
combat = await api_client.get_combat(player_id)
|
||||
if combat and combat.get('from_wandering_enemy', False):
|
||||
# Respawn the enemy at the same location with full HP
|
||||
npc_def = NPCS.get(combat['npc_id'])
|
||||
await api_client.spawn_wandering_enemy(
|
||||
npc_id=combat['npc_id'],
|
||||
location_id=combat['location_id'],
|
||||
current_hp=npc_def.hp,
|
||||
max_hp=npc_def.hp
|
||||
)
|
||||
|
||||
# Create corpse bag if player has items
|
||||
if inventory_items:
|
||||
items_json = json.dumps([{
|
||||
'item_id': item['item_id'],
|
||||
'quantity': item['quantity']
|
||||
} for item in inventory_items])
|
||||
|
||||
await api_client.create_player_corpse(
|
||||
player_name=player['name'],
|
||||
location_id=player['location_id'],
|
||||
items=items_json
|
||||
)
|
||||
|
||||
# Remove all items from player
|
||||
for item in inventory_items:
|
||||
await api_client.remove_item_from_inventory(item['id'], item['quantity'])
|
||||
|
||||
# Mark player as dead and end any combat
|
||||
await api_client.update_player(player_id, {'is_dead': True, 'hp': 0})
|
||||
await api_client.end_combat(player_id)
|
||||
|
||||
|
||||
async def use_item_in_combat(player_id: int, item_db_id: int) -> Tuple[str, bool]:
|
||||
"""
|
||||
Use a consumable item during combat.
|
||||
Returns: (message, turn_ended)
|
||||
"""
|
||||
combat = await api_client.get_combat(player_id)
|
||||
if not combat or combat['turn'] != 'player':
|
||||
return ("It's not your turn!", False)
|
||||
|
||||
item_data = await api_client.get_inventory_item(item_db_id)
|
||||
if not item_data or item_data['player_id'] != player_id:
|
||||
return ("You don't have that item!", False)
|
||||
|
||||
item_def = ITEMS.get(item_data['item_id'])
|
||||
if not item_def or item_def.get('type') != 'consumable':
|
||||
return ("That item cannot be used in combat!", False)
|
||||
|
||||
player = await api_client.get_player(player_id)
|
||||
|
||||
# Apply consumable effects
|
||||
message = f"💊 Used {item_def['name']}!"
|
||||
|
||||
hp_restore = item_def.get('hp_restore', 0)
|
||||
stamina_restore = item_def.get('stamina_restore', 0)
|
||||
|
||||
updates = {}
|
||||
if hp_restore > 0:
|
||||
new_hp = min(player['hp'] + hp_restore, player['max_hp'])
|
||||
updates['hp'] = new_hp
|
||||
message += f"\n❤️ +{hp_restore} HP"
|
||||
|
||||
if stamina_restore > 0:
|
||||
new_stamina = min(player['stamina'] + stamina_restore, player['max_stamina'])
|
||||
updates['stamina'] = new_stamina
|
||||
message += f"\n⚡ +{stamina_restore} Stamina"
|
||||
|
||||
if updates:
|
||||
await api_client.update_player(player_id, updates)
|
||||
|
||||
# Remove item from inventory
|
||||
if item_data['quantity'] > 1:
|
||||
await api_client.update_inventory_item(item_db_id, item_data['quantity'] - 1)
|
||||
else:
|
||||
await api_client.remove_item_from_inventory(item_db_id, 1)
|
||||
|
||||
# Using an item ends your turn
|
||||
await api_client.update_combat(player_id, {
|
||||
'turn': 'npc',
|
||||
'turn_started_at': time.time()
|
||||
})
|
||||
|
||||
return (message, True)
|
||||
@@ -1,165 +0,0 @@
|
||||
"""
|
||||
Combat-related action handlers.
|
||||
"""
|
||||
import logging
|
||||
from . import keyboards
|
||||
from .api_client import api_client
|
||||
from .utils import format_stat_bar
|
||||
from data.world_loader import game_world
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_combat_attack(query, user_id: int, player: dict, data: list = None):
|
||||
"""Handle player attack action in combat."""
|
||||
from bot import combat
|
||||
await query.answer()
|
||||
|
||||
message, npc_died, turn_ended = await combat.player_attack(user_id)
|
||||
|
||||
if npc_died:
|
||||
# Combat ended - return to main menu
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboards.main_menu_keyboard(),
|
||||
image_path=location_image
|
||||
)
|
||||
elif turn_ended:
|
||||
# NPC's turn - auto-attack
|
||||
npc_message, player_died = await combat.npc_attack(user_id)
|
||||
message += "\n\n" + npc_message
|
||||
|
||||
if player_died:
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(query, text=message, reply_markup=None)
|
||||
else:
|
||||
combat_data = await api_client.get_combat(user_id)
|
||||
if combat_data:
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(combat_data['npc_id'])
|
||||
keyboard = await keyboards.combat_keyboard(user_id)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboard,
|
||||
image_path=npc_def.image_url if npc_def else None
|
||||
)
|
||||
else:
|
||||
await query.answer(message, show_alert=False)
|
||||
|
||||
|
||||
async def handle_combat_flee(query, user_id: int, player: dict, data: list = None):
|
||||
"""Handle flee attempt from combat."""
|
||||
from bot import combat
|
||||
await query.answer()
|
||||
|
||||
message, fled, turn_ended = await combat.flee_attempt(user_id)
|
||||
|
||||
if fled:
|
||||
# Successfully fled - return to main menu
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboards.main_menu_keyboard(),
|
||||
image_path=location_image
|
||||
)
|
||||
elif turn_ended:
|
||||
# Failed to flee - NPC attacks
|
||||
npc_message, player_died = await combat.npc_attack(user_id)
|
||||
message += "\n\n" + npc_message
|
||||
|
||||
if player_died:
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(query, text=message, reply_markup=None)
|
||||
else:
|
||||
combat_data = await api_client.get_combat(user_id)
|
||||
if combat_data:
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(combat_data['npc_id'])
|
||||
keyboard = await keyboards.combat_keyboard(user_id)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboard,
|
||||
image_path=npc_def.image_url if npc_def else None
|
||||
)
|
||||
else:
|
||||
await query.answer(message, show_alert=False)
|
||||
|
||||
|
||||
async def handle_combat_use_item_menu(query, user_id: int, player: dict, data: list = None):
|
||||
"""Show menu of usable items during combat."""
|
||||
await query.answer()
|
||||
|
||||
|
||||
async def handle_combat_use_item(query, user_id: int, player: dict, data: list):
|
||||
"""Use an item during combat."""
|
||||
from bot import combat
|
||||
item_db_id = int(data[1])
|
||||
|
||||
message, turn_ended = await combat.use_item_in_combat(user_id, item_db_id)
|
||||
await query.answer(message, show_alert=False)
|
||||
|
||||
if turn_ended:
|
||||
# NPC's turn
|
||||
npc_message, player_died = await combat.npc_attack(user_id)
|
||||
|
||||
if player_died:
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message + "\n\n" + npc_message,
|
||||
reply_markup=None
|
||||
)
|
||||
else:
|
||||
combat_data = await api_client.get_combat(user_id)
|
||||
if combat_data:
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(combat_data['npc_id'])
|
||||
keyboard = await keyboards.combat_keyboard(user_id)
|
||||
full_message = message + "\n\n" + npc_message + "\n\n🎯 Your turn!"
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=full_message,
|
||||
reply_markup=keyboard,
|
||||
image_path=npc_def.image_url if npc_def else None
|
||||
)
|
||||
|
||||
|
||||
async def handle_combat_back(query, user_id: int, player: dict, data: list = None):
|
||||
"""Return to combat menu from item selection."""
|
||||
await query.answer()
|
||||
combat_data = await api_client.get_combat(user_id)
|
||||
|
||||
if combat_data:
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(combat_data['npc_id'])
|
||||
keyboard = await keyboards.combat_keyboard(user_id)
|
||||
|
||||
message = f"⚔️ Combat with {npc_def.emoji} {npc_def.name}!\n"
|
||||
message += format_stat_bar("Your HP", "❤️", player['hp'], player['max_hp']) + "\n"
|
||||
message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n"
|
||||
message += "🎯 Your turn!" if combat_data['turn'] == 'player' else "⏳ Enemy's turn..."
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=message,
|
||||
reply_markup=keyboard,
|
||||
image_path=npc_def.image_url if npc_def else None
|
||||
)
|
||||
109
bot/commands.py
@@ -1,109 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Command handlers for the Telegram bot.
|
||||
Handles slash commands like /start, /export_map, /spawn_stats.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
from io import BytesIO
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from . import keyboards
|
||||
from .api_client import api_client
|
||||
from .utils import admin_only
|
||||
from .action_handlers import get_player_status_text
|
||||
from data.world_loader import game_world
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /start command - initialize or show player status."""
|
||||
from .api_client import api_client
|
||||
|
||||
user = update.effective_user
|
||||
player = await api_client.get_player(user.id)
|
||||
|
||||
if not player:
|
||||
player = await api_client.create_player(user.id, user.first_name)
|
||||
await update.message.reply_html(
|
||||
f"Welcome, {user.mention_html()}! Your story is just beginning."
|
||||
)
|
||||
|
||||
# Get player status and location image
|
||||
player = await api_client.get_player(user.id)
|
||||
status_text = await get_player_status_text(user.id)
|
||||
location = game_world.get_location(player['location_id'])
|
||||
|
||||
# Send with image if available
|
||||
if location and location.image_path:
|
||||
cached_file_id = await api_client.get_cached_image(location.image_path)
|
||||
if cached_file_id:
|
||||
await update.message.reply_photo(
|
||||
photo=cached_file_id,
|
||||
caption=status_text,
|
||||
reply_markup=keyboards.main_menu_keyboard(),
|
||||
parse_mode='HTML'
|
||||
)
|
||||
elif os.path.exists(location.image_path):
|
||||
with open(location.image_path, 'rb') as img_file:
|
||||
msg = await update.message.reply_photo(
|
||||
photo=img_file,
|
||||
caption=status_text,
|
||||
reply_markup=keyboards.main_menu_keyboard(),
|
||||
parse_mode='HTML'
|
||||
)
|
||||
if msg.photo:
|
||||
await api_client.cache_image(location.image_path, msg.photo[-1].file_id)
|
||||
else:
|
||||
await update.message.reply_html(
|
||||
status_text,
|
||||
reply_markup=keyboards.main_menu_keyboard()
|
||||
)
|
||||
else:
|
||||
await update.message.reply_html(
|
||||
status_text,
|
||||
reply_markup=keyboards.main_menu_keyboard()
|
||||
)
|
||||
|
||||
|
||||
@admin_only
|
||||
async def export_map(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Export map data as JSON for external visualization."""
|
||||
from data.world_loader import export_map_data
|
||||
|
||||
map_data = export_map_data()
|
||||
json_str = json.dumps(map_data, indent=2)
|
||||
|
||||
# Send as text file
|
||||
file = BytesIO(json_str.encode('utf-8'))
|
||||
file.name = "map_data.json"
|
||||
|
||||
await update.message.reply_document(
|
||||
document=file,
|
||||
filename="map_data.json",
|
||||
caption="🗺️ Game Map Data\n\nThis JSON file contains all locations, coordinates, and connections.\nYou can use it to visualize the game map in external tools."
|
||||
)
|
||||
|
||||
|
||||
@admin_only
|
||||
async def spawn_stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Show wandering enemy spawn statistics (debug command)."""
|
||||
from bot.spawn_manager import get_spawn_stats
|
||||
|
||||
stats = await get_spawn_stats()
|
||||
|
||||
text = "📊 <b>Wandering Enemy Statistics</b>\n\n"
|
||||
text += f"<b>Total Active Enemies:</b> {stats['total_active']}\n\n"
|
||||
|
||||
if stats['by_location']:
|
||||
text += "<b>Enemies by Location:</b>\n"
|
||||
for loc_id, count in stats['by_location'].items():
|
||||
location = game_world.get_location(loc_id)
|
||||
loc_name = location.name if location else loc_id
|
||||
text += f"• {loc_name}: {count}\n"
|
||||
else:
|
||||
text += "<i>No wandering enemies currently active.</i>"
|
||||
|
||||
await update.message.reply_html(text)
|
||||
@@ -1,235 +0,0 @@
|
||||
"""
|
||||
Corpse looting handlers (player and NPC corpses).
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
import random
|
||||
from . import keyboards, logic
|
||||
from .api_client import api_client
|
||||
from data.world_loader import game_world
|
||||
from data.items import ITEMS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_loot_player_corpse(query, user_id: int, player: dict, data: list):
|
||||
"""Show player corpse loot menu."""
|
||||
corpse_id = int(data[1])
|
||||
corpse = await api_client.get_player_corpse(corpse_id)
|
||||
|
||||
if not corpse:
|
||||
await query.answer("Corpse not found.", show_alert=False)
|
||||
return
|
||||
|
||||
items = json.loads(corpse['items'])
|
||||
keyboard = keyboards.player_corpse_loot_keyboard(corpse_id, items)
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
await query.answer()
|
||||
text = f"🎒 {corpse['player_name']}'s bag\n\nYou find the remains of another survivor..."
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
|
||||
|
||||
async def handle_take_corpse_item(query, user_id: int, player: dict, data: list):
|
||||
"""Take an item from a player corpse."""
|
||||
corpse_id = int(data[1])
|
||||
item_index = int(data[2])
|
||||
|
||||
corpse = await api_client.get_player_corpse(corpse_id)
|
||||
if not corpse:
|
||||
await query.answer("Corpse not found.", show_alert=False)
|
||||
return
|
||||
|
||||
items = json.loads(corpse['items'])
|
||||
if item_index >= len(items):
|
||||
await query.answer("Item not found.", show_alert=False)
|
||||
return
|
||||
|
||||
item_data = items[item_index]
|
||||
item_def = ITEMS.get(item_data['item_id'], {})
|
||||
|
||||
# Check inventory capacity
|
||||
can_add, reason = await logic.can_add_item_to_inventory(
|
||||
user_id, item_data['item_id'], item_data['quantity']
|
||||
)
|
||||
|
||||
if not can_add:
|
||||
await query.answer(reason, show_alert=False)
|
||||
return
|
||||
|
||||
# Add to inventory
|
||||
await api_client.add_item_to_inventory(user_id, item_data['item_id'], item_data['quantity'])
|
||||
|
||||
# Remove from corpse
|
||||
items.pop(item_index)
|
||||
|
||||
if items:
|
||||
await api_client.update_player_corpse(corpse_id, json.dumps(items))
|
||||
keyboard = keyboards.player_corpse_loot_keyboard(corpse_id, items)
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
await query.answer(f"Took {item_def.get('name', 'Unknown')}.", show_alert=False)
|
||||
text = f"🎒 {corpse['player_name']}'s bag"
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
else:
|
||||
# Bag is empty, remove it
|
||||
await api_client.remove_player_corpse(corpse_id)
|
||||
await query.answer(
|
||||
f"Took {item_def.get('name', 'Unknown')}. The bag is now empty.",
|
||||
show_alert=False
|
||||
)
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
dropped_items = await api_client.get_dropped_items_in_location(player['location_id'])
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(player['location_id'])
|
||||
keyboard = await keyboards.inspect_keyboard(player['location_id'], dropped_items, wandering_enemies)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="You scan the area. You notice...",
|
||||
reply_markup=keyboard,
|
||||
image_path=location.image_path if location else None
|
||||
)
|
||||
|
||||
|
||||
async def handle_scavenge_npc_corpse(query, user_id: int, player: dict, data: list):
|
||||
"""Show NPC corpse scavenging menu."""
|
||||
corpse_id = int(data[1])
|
||||
corpse = await api_client.get_npc_corpse(corpse_id)
|
||||
|
||||
if not corpse:
|
||||
await query.answer("Corpse not found.", show_alert=False)
|
||||
return
|
||||
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(corpse['npc_id'])
|
||||
loot_items = json.loads(corpse['loot_remaining'])
|
||||
keyboard = keyboards.npc_corpse_scavenge_keyboard(corpse_id, loot_items)
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
await query.answer()
|
||||
text = f"🔪 {npc_def.emoji} {npc_def.name} Corpse\n\n{npc_def.description}"
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
|
||||
|
||||
async def handle_scavenge_corpse_item(query, user_id: int, player: dict, data: list):
|
||||
"""Scavenge a specific item from an NPC corpse."""
|
||||
corpse_id = int(data[1])
|
||||
loot_index = int(data[2])
|
||||
|
||||
corpse = await api_client.get_npc_corpse(corpse_id)
|
||||
if not corpse:
|
||||
await query.answer("Corpse not found.", show_alert=False)
|
||||
return
|
||||
|
||||
loot_items = json.loads(corpse['loot_remaining'])
|
||||
if loot_index >= len(loot_items):
|
||||
await query.answer("Nothing to scavenge here.", show_alert=False)
|
||||
return
|
||||
|
||||
loot_data = loot_items[loot_index]
|
||||
required_tool = loot_data.get('required_tool')
|
||||
|
||||
# Check if player has required tool
|
||||
if required_tool:
|
||||
inventory_items = await api_client.get_inventory(user_id)
|
||||
has_tool = any(item['item_id'] == required_tool for item in inventory_items)
|
||||
|
||||
if not has_tool:
|
||||
tool_def = ITEMS.get(required_tool, {})
|
||||
await query.answer(
|
||||
f"You need a {tool_def.get('name', 'tool')} to scavenge this.",
|
||||
show_alert=False
|
||||
)
|
||||
return
|
||||
|
||||
# Determine quantity
|
||||
quantity = random.randint(loot_data['quantity_min'], loot_data['quantity_max'])
|
||||
item_def = ITEMS.get(loot_data['item_id'], {})
|
||||
|
||||
# Check inventory capacity
|
||||
can_add, reason = await logic.can_add_item_to_inventory(
|
||||
user_id, loot_data['item_id'], quantity
|
||||
)
|
||||
|
||||
if not can_add:
|
||||
await query.answer(reason, show_alert=False)
|
||||
return
|
||||
|
||||
# Add to inventory
|
||||
await api_client.add_item_to_inventory(user_id, loot_data['item_id'], quantity)
|
||||
|
||||
# Remove from corpse
|
||||
loot_items.pop(loot_index)
|
||||
|
||||
if loot_items:
|
||||
await api_client.update_npc_corpse(corpse_id, json.dumps(loot_items))
|
||||
keyboard = keyboards.npc_corpse_scavenge_keyboard(corpse_id, loot_items)
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
await query.answer(
|
||||
f"Scavenged {quantity}x {item_def.get('name', 'Unknown')}.",
|
||||
show_alert=False
|
||||
)
|
||||
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(corpse['npc_id'])
|
||||
text = f"🔪 {npc_def.emoji} {npc_def.name} Corpse"
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
else:
|
||||
# Nothing left, remove corpse
|
||||
await api_client.remove_npc_corpse(corpse_id)
|
||||
await query.answer(
|
||||
f"Scavenged {quantity}x {item_def.get('name', 'Unknown')}. Nothing left on the corpse.",
|
||||
show_alert=False
|
||||
)
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
dropped_items = await api_client.get_dropped_items_in_location(player['location_id'])
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(player['location_id'])
|
||||
keyboard = await keyboards.inspect_keyboard(player['location_id'], dropped_items, wandering_enemies)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="You scan the area. You notice...",
|
||||
reply_markup=keyboard,
|
||||
image_path=location.image_path if location else None
|
||||
)
|
||||
729
bot/database.py
@@ -1,729 +0,0 @@
|
||||
import time
|
||||
import os
|
||||
from typing import Set
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy import (
|
||||
MetaData, Table, Column, Integer, String, Boolean, ForeignKey, Float, UniqueConstraint,
|
||||
)
|
||||
|
||||
DB_USER, DB_PASS, DB_NAME, DB_HOST, DB_PORT = os.getenv("POSTGRES_USER"), os.getenv("POSTGRES_PASSWORD"), os.getenv("POSTGRES_DB"), os.getenv("POSTGRES_HOST"), os.getenv("POSTGRES_PORT")
|
||||
DATABASE_URL = f"postgresql+psycopg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||
engine = create_async_engine(DATABASE_URL)
|
||||
metadata = MetaData()
|
||||
|
||||
# ... (players, inventory, dropped_items tables are unchanged) ...
|
||||
players = Table(
|
||||
"players",
|
||||
metadata,
|
||||
Column("telegram_id", Integer, primary_key=True),
|
||||
Column("id", Integer, unique=True, autoincrement=True), # Web users ID
|
||||
Column("username", String(50), unique=True, nullable=True), # Web users username
|
||||
Column("password_hash", String(255), nullable=True), # Web users password hash
|
||||
Column("name", String, default="Survivor"),
|
||||
Column("hp", Integer, default=100),
|
||||
Column("max_hp", Integer, default=100),
|
||||
Column("stamina", Integer, default=20),
|
||||
Column("max_stamina", Integer, default=20),
|
||||
Column("strength", Integer, default=5),
|
||||
Column("agility", Integer, default=5),
|
||||
Column("endurance", Integer, default=5),
|
||||
Column("intellect", Integer, default=5),
|
||||
Column("location_id", String, default="start_point"),
|
||||
Column("is_dead", Boolean, default=False),
|
||||
Column("level", Integer, default=1),
|
||||
Column("xp", Integer, default=0),
|
||||
Column("unspent_points", Integer, default=0)
|
||||
)
|
||||
inventory = Table("inventory", metadata, Column("id", Integer, primary_key=True, autoincrement=True), Column("player_id", Integer, ForeignKey("players.telegram_id", ondelete="CASCADE")), Column("item_id", String), Column("quantity", Integer, default=1), Column("is_equipped", Boolean, default=False))
|
||||
dropped_items = Table("dropped_items", metadata, Column("id", Integer, primary_key=True, autoincrement=True), Column("item_id", String), Column("quantity", Integer, default=1), Column("location_id", String), Column("drop_timestamp", Float))
|
||||
|
||||
# Combat-related tables
|
||||
active_combats = Table(
|
||||
"active_combats",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("player_id", Integer, ForeignKey("players.telegram_id", ondelete="CASCADE"), unique=True),
|
||||
Column("npc_id", String, nullable=False),
|
||||
Column("npc_hp", Integer, nullable=False),
|
||||
Column("npc_max_hp", Integer, nullable=False),
|
||||
Column("turn", String, nullable=False), # "player" or "npc"
|
||||
Column("turn_started_at", Float, nullable=False),
|
||||
Column("player_status_effects", String, default=""), # JSON string
|
||||
Column("npc_status_effects", String, default=""), # JSON string
|
||||
Column("location_id", String, nullable=False),
|
||||
Column("from_wandering_enemy", Boolean, default=False), # If True, respawn on flee/death
|
||||
)
|
||||
|
||||
player_corpses = Table(
|
||||
"player_corpses",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("player_name", String, nullable=False),
|
||||
Column("location_id", String, nullable=False),
|
||||
Column("items", String, nullable=False), # JSON string of items
|
||||
Column("death_timestamp", Float, nullable=False),
|
||||
)
|
||||
|
||||
npc_corpses = Table(
|
||||
"npc_corpses",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("npc_id", String, nullable=False),
|
||||
Column("location_id", String, nullable=False),
|
||||
Column("loot_remaining", String, nullable=False), # JSON string
|
||||
Column("death_timestamp", Float, nullable=False),
|
||||
)
|
||||
|
||||
interactable_cooldowns = Table(
|
||||
"interactable_cooldowns",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("interactable_instance_id", String, nullable=False, unique=True), # Renamed for clarity
|
||||
Column("expiry_timestamp", Float, nullable=False),
|
||||
)
|
||||
|
||||
# Table to cache Telegram file IDs for images
|
||||
image_cache = Table(
|
||||
"image_cache",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("image_path", String, nullable=False, unique=True), # Local file path
|
||||
Column("telegram_file_id", String, nullable=False), # Telegram's file_id for reuse
|
||||
Column("uploaded_at", Float, nullable=False),
|
||||
)
|
||||
|
||||
# Wandering enemies table - managed by spawn system
|
||||
wandering_enemies = Table(
|
||||
"wandering_enemies",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("npc_id", String, nullable=False),
|
||||
Column("location_id", String, nullable=False),
|
||||
Column("spawn_timestamp", Float, nullable=False),
|
||||
Column("despawn_timestamp", Float, nullable=False), # When this enemy should despawn
|
||||
)
|
||||
|
||||
# Persistent status effects table
|
||||
player_status_effects = Table(
|
||||
"player_status_effects",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("player_id", Integer, ForeignKey("players.telegram_id", ondelete="CASCADE"), nullable=False),
|
||||
Column("effect_name", String(50), nullable=False),
|
||||
Column("effect_icon", String(10), nullable=False),
|
||||
Column("damage_per_tick", Integer, nullable=False, default=0),
|
||||
Column("ticks_remaining", Integer, nullable=False),
|
||||
Column("applied_at", Float, nullable=False),
|
||||
)
|
||||
|
||||
async def create_tables():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(metadata.create_all)
|
||||
|
||||
# ... (All other database functions are unchanged except the cooldown ones) ...
|
||||
async def get_player(telegram_id: int = None, player_id: int = None, username: str = None):
|
||||
"""Get player by telegram_id, player_id (web users), or username."""
|
||||
async with engine.connect() as conn:
|
||||
if telegram_id is not None:
|
||||
result = await conn.execute(players.select().where(players.c.telegram_id == telegram_id))
|
||||
elif player_id is not None:
|
||||
result = await conn.execute(players.select().where(players.c.id == player_id))
|
||||
elif username is not None:
|
||||
result = await conn.execute(players.select().where(players.c.username == username))
|
||||
else:
|
||||
return None
|
||||
row = result.first()
|
||||
return row._asdict() if row else None
|
||||
|
||||
async def create_player(telegram_id: int = None, name: str = "Survivor", username: str = None, password_hash: str = None):
|
||||
"""Create a player (Telegram or web user)."""
|
||||
async with engine.connect() as conn:
|
||||
values = {
|
||||
"name": name,
|
||||
"telegram_id": telegram_id,
|
||||
"username": username,
|
||||
"password_hash": password_hash,
|
||||
}
|
||||
result = await conn.execute(players.insert().values(**values))
|
||||
await conn.commit()
|
||||
|
||||
# For telegram users, the primary key is telegram_id
|
||||
# For web users, we need to get the auto-generated id
|
||||
if telegram_id:
|
||||
# Add starting inventory for Telegram users
|
||||
await conn.execute(inventory.insert().values(player_id=telegram_id, item_id="tattered_rucksack", is_equipped=True))
|
||||
await conn.commit()
|
||||
|
||||
# Return the created player
|
||||
if telegram_id:
|
||||
return await get_player(telegram_id=telegram_id)
|
||||
elif username:
|
||||
return await get_player(username=username)
|
||||
|
||||
async def update_player(telegram_id: int = None, player_id: int = None, updates: dict = None):
|
||||
"""Update player by telegram_id (Telegram users) or player_id (web users)."""
|
||||
if updates is None:
|
||||
updates = {}
|
||||
async with engine.connect() as conn:
|
||||
if telegram_id is not None:
|
||||
await conn.execute(players.update().where(players.c.telegram_id == telegram_id).values(**updates))
|
||||
elif player_id is not None:
|
||||
await conn.execute(players.update().where(players.c.id == player_id).values(**updates))
|
||||
else:
|
||||
raise ValueError("Must provide either telegram_id or player_id")
|
||||
await conn.commit()
|
||||
async def get_inventory(player_id: int):
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(inventory.select().where(inventory.c.player_id == player_id))
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
async def get_inventory_item(item_db_id: int):
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(inventory.select().where(inventory.c.id == item_db_id))
|
||||
row = result.first()
|
||||
return row._asdict() if row else None
|
||||
async def add_item_to_inventory(player_id: int, item_id: str, quantity: int = 1):
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(inventory.select().where(inventory.c.player_id == player_id, inventory.c.item_id == item_id))
|
||||
existing_item = result.first()
|
||||
if existing_item: stmt = inventory.update().where(inventory.c.id == existing_item.id).values(quantity=inventory.c.quantity + quantity)
|
||||
else: stmt = inventory.insert().values(player_id=player_id, item_id=item_id, quantity=quantity)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def add_equipped_item_to_inventory(player_id: int, item_id: str) -> int:
|
||||
"""Add a single equipped item to inventory and return its ID."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = inventory.insert().values(
|
||||
player_id=player_id,
|
||||
item_id=item_id,
|
||||
quantity=1,
|
||||
is_equipped=True
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
return result.inserted_primary_key[0]
|
||||
|
||||
async def update_inventory_item(item_db_id: int, quantity: int = None, is_equipped: bool = None):
|
||||
"""Update inventory item properties."""
|
||||
async with engine.connect() as conn:
|
||||
updates = {}
|
||||
if quantity is not None:
|
||||
updates['quantity'] = quantity
|
||||
if is_equipped is not None:
|
||||
updates['is_equipped'] = is_equipped
|
||||
|
||||
if updates:
|
||||
stmt = inventory.update().where(inventory.c.id == item_db_id).values(**updates)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def remove_item_from_inventory(item_db_id: int, quantity: int = 1):
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(inventory.select().where(inventory.c.id == item_db_id))
|
||||
item_data = result.first()
|
||||
if not item_data: return
|
||||
if item_data.quantity > quantity: stmt = inventory.update().where(inventory.c.id == item_db_id).values(quantity=inventory.c.quantity - quantity)
|
||||
else: stmt = inventory.delete().where(inventory.c.id == item_db_id)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
async def drop_item_to_world(item_id: str, quantity: int, location_id: str):
|
||||
"""Drop item to world. Combines with existing stacks of same item in same location."""
|
||||
async with engine.connect() as conn:
|
||||
# Check if this item already exists in this location
|
||||
result = await conn.execute(
|
||||
dropped_items.select().where(
|
||||
(dropped_items.c.item_id == item_id) &
|
||||
(dropped_items.c.location_id == location_id)
|
||||
)
|
||||
)
|
||||
existing_item = result.first()
|
||||
|
||||
if existing_item:
|
||||
# Stack exists, add to it
|
||||
new_quantity = existing_item.quantity + quantity
|
||||
stmt = dropped_items.update().where(dropped_items.c.id == existing_item.id).values(
|
||||
quantity=new_quantity,
|
||||
drop_timestamp=time.time() # Update timestamp
|
||||
)
|
||||
else:
|
||||
# Create new stack
|
||||
stmt = dropped_items.insert().values(
|
||||
item_id=item_id,
|
||||
quantity=quantity,
|
||||
location_id=location_id,
|
||||
drop_timestamp=time.time()
|
||||
)
|
||||
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
async def get_dropped_items_in_location(location_id: str):
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(dropped_items.select().where(dropped_items.c.location_id == location_id).limit(10))
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
async def get_dropped_item(dropped_item_id: int):
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(dropped_items.select().where(dropped_items.c.id == dropped_item_id))
|
||||
row = result.first()
|
||||
return row._asdict() if row else None
|
||||
async def remove_dropped_item(dropped_item_id: int):
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(dropped_items.delete().where(dropped_items.c.id == dropped_item_id))
|
||||
await conn.commit()
|
||||
|
||||
async def update_dropped_item(dropped_item_id: int, new_quantity: int):
|
||||
"""Update the quantity of a dropped item."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = dropped_items.update().where(dropped_items.c.id == dropped_item_id).values(quantity=new_quantity)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def remove_expired_dropped_items(timestamp_limit: float) -> int:
|
||||
async with engine.connect() as conn:
|
||||
stmt = dropped_items.delete().where(dropped_items.c.drop_timestamp < timestamp_limit)
|
||||
result = await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
async def regenerate_all_players_stamina() -> int:
|
||||
"""
|
||||
Regenerate stamina for all active players using a single optimized query.
|
||||
|
||||
Recovery formula:
|
||||
- Base recovery: 1 stamina per cycle (5 minutes)
|
||||
- Endurance bonus: +1 stamina per 10 endurance points
|
||||
- Example: 5 endurance = 1 stamina, 15 endurance = 2 stamina, 25 endurance = 3 stamina
|
||||
- Only regenerates up to max_stamina
|
||||
- Only regenerates for living players
|
||||
|
||||
PERFORMANCE: Single SQL query, scales to 100K+ players efficiently.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
async with engine.connect() as conn:
|
||||
# Single UPDATE query with database-side calculation
|
||||
# Much more efficient than fetching all players and updating individually
|
||||
stmt = text("""
|
||||
UPDATE players
|
||||
SET stamina = LEAST(
|
||||
stamina + 1 + (endurance / 10),
|
||||
max_stamina
|
||||
)
|
||||
WHERE is_dead = FALSE
|
||||
AND stamina < max_stamina
|
||||
""")
|
||||
|
||||
result = await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
COOLDOWN_DURATION = 300
|
||||
async def set_cooldown(instance_id: str):
|
||||
expiry_time = time.time() + COOLDOWN_DURATION
|
||||
async with engine.connect() as conn:
|
||||
update_stmt = interactable_cooldowns.update().where(interactable_cooldowns.c.interactable_instance_id == instance_id).values(expiry_timestamp=expiry_time)
|
||||
result = await conn.execute(update_stmt)
|
||||
if result.rowcount == 0:
|
||||
insert_stmt = interactable_cooldowns.insert().values(interactable_instance_id=instance_id, expiry_timestamp=expiry_time)
|
||||
await conn.execute(insert_stmt)
|
||||
await conn.commit()
|
||||
|
||||
# --- Combat Functions ---
|
||||
async def create_combat(player_id: int, npc_id: str, npc_hp: int, npc_max_hp: int, location_id: str, from_wandering_enemy: bool = False):
|
||||
"""Start a new combat encounter."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = active_combats.insert().values(
|
||||
player_id=player_id,
|
||||
npc_id=npc_id,
|
||||
npc_hp=npc_hp,
|
||||
npc_max_hp=npc_max_hp,
|
||||
turn="player",
|
||||
turn_started_at=time.time(),
|
||||
location_id=location_id,
|
||||
player_status_effects="[]",
|
||||
npc_status_effects="[]",
|
||||
from_wandering_enemy=from_wandering_enemy
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
return result.inserted_primary_key[0]
|
||||
|
||||
async def get_combat(player_id: int):
|
||||
"""Get active combat for a player."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = active_combats.select().where(active_combats.c.player_id == player_id)
|
||||
result = await conn.execute(stmt)
|
||||
row = result.first()
|
||||
return row._asdict() if row else None
|
||||
|
||||
async def update_combat(player_id: int, updates: dict):
|
||||
"""Update combat state."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = active_combats.update().where(active_combats.c.player_id == player_id).values(**updates)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def end_combat(player_id: int):
|
||||
"""Remove active combat."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = active_combats.delete().where(active_combats.c.player_id == player_id)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def get_all_idle_combats(idle_threshold: float):
|
||||
"""Get all combats where the turn has been idle too long."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = active_combats.select().where(active_combats.c.turn_started_at < idle_threshold)
|
||||
result = await conn.execute(stmt)
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
|
||||
async def create_player_corpse(player_name: str, location_id: str, items: str):
|
||||
"""Create a player corpse bag."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = player_corpses.insert().values(
|
||||
player_name=player_name,
|
||||
location_id=location_id,
|
||||
items=items,
|
||||
death_timestamp=time.time()
|
||||
)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def get_player_corpses_in_location(location_id: str):
|
||||
"""Get all player corpses in a location."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = player_corpses.select().where(player_corpses.c.location_id == location_id)
|
||||
result = await conn.execute(stmt)
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
|
||||
async def get_player_corpse(corpse_id: int):
|
||||
"""Get a specific player corpse."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = player_corpses.select().where(player_corpses.c.id == corpse_id)
|
||||
result = await conn.execute(stmt)
|
||||
row = result.first()
|
||||
return row._asdict() if row else None
|
||||
|
||||
async def update_player_corpse(corpse_id: int, items: str):
|
||||
"""Update items in a player corpse."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = player_corpses.update().where(player_corpses.c.id == corpse_id).values(items=items)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def remove_player_corpse(corpse_id: int):
|
||||
"""Remove a player corpse."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = player_corpses.delete().where(player_corpses.c.id == corpse_id)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def remove_expired_player_corpses(timestamp_limit: float) -> int:
|
||||
"""Remove old player corpses."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = player_corpses.delete().where(player_corpses.c.death_timestamp < timestamp_limit)
|
||||
result = await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
async def create_npc_corpse(npc_id: str, location_id: str, loot_remaining: str):
|
||||
"""Create an NPC corpse for scavenging."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = npc_corpses.insert().values(
|
||||
npc_id=npc_id,
|
||||
location_id=location_id,
|
||||
loot_remaining=loot_remaining,
|
||||
death_timestamp=time.time()
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
return result.inserted_primary_key[0]
|
||||
|
||||
async def get_npc_corpses_in_location(location_id: str):
|
||||
"""Get all NPC corpses in a location."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = npc_corpses.select().where(npc_corpses.c.location_id == location_id)
|
||||
result = await conn.execute(stmt)
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
|
||||
async def get_npc_corpse(corpse_id: int):
|
||||
"""Get a specific NPC corpse."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = npc_corpses.select().where(npc_corpses.c.id == corpse_id)
|
||||
result = await conn.execute(stmt)
|
||||
row = result.first()
|
||||
return row._asdict() if row else None
|
||||
|
||||
async def update_npc_corpse(corpse_id: int, loot_remaining: str):
|
||||
"""Update loot in an NPC corpse."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = npc_corpses.update().where(npc_corpses.c.id == corpse_id).values(loot_remaining=loot_remaining)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def remove_npc_corpse(corpse_id: int):
|
||||
"""Remove an NPC corpse."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = npc_corpses.delete().where(npc_corpses.c.id == corpse_id)
|
||||
await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
|
||||
async def remove_expired_npc_corpses(timestamp_limit: float) -> int:
|
||||
"""Remove old NPC corpses."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = npc_corpses.delete().where(npc_corpses.c.death_timestamp < timestamp_limit)
|
||||
result = await conn.execute(stmt)
|
||||
await conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
async def get_cooldown(instance_id: str) -> int:
|
||||
async with engine.connect() as conn:
|
||||
stmt = interactable_cooldowns.select().where(interactable_cooldowns.c.interactable_instance_id == instance_id)
|
||||
result = await conn.execute(stmt)
|
||||
cooldown = result.first()
|
||||
if cooldown and cooldown.expiry_timestamp > time.time():
|
||||
return int(cooldown.expiry_timestamp - time.time())
|
||||
return 0
|
||||
|
||||
async def get_cooldowns_for_location(location_id: str) -> Set[str]:
|
||||
"""Get all active cooldown instance IDs for a location by checking the prefix."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = interactable_cooldowns.select().where(
|
||||
interactable_cooldowns.c.interactable_instance_id.startswith(location_id + "_"),
|
||||
interactable_cooldowns.c.expiry_timestamp > time.time()
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
return {row.interactable_instance_id for row in result.fetchall()}
|
||||
|
||||
# --- Image Cache Functions ---
|
||||
async def get_cached_image(image_path: str):
|
||||
"""Get the Telegram file_id for a cached image."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = image_cache.select().where(image_cache.c.image_path == image_path)
|
||||
result = await conn.execute(stmt)
|
||||
row = result.first()
|
||||
return row.telegram_file_id if row else None
|
||||
|
||||
async def cache_image(image_path: str, telegram_file_id: str):
|
||||
"""Store a Telegram file_id for an image path."""
|
||||
async with engine.connect() as conn:
|
||||
# Check if already exists
|
||||
stmt = image_cache.select().where(image_cache.c.image_path == image_path)
|
||||
result = await conn.execute(stmt)
|
||||
existing = result.first()
|
||||
|
||||
if existing:
|
||||
# Update existing entry
|
||||
update_stmt = image_cache.update().where(
|
||||
image_cache.c.image_path == image_path
|
||||
).values(telegram_file_id=telegram_file_id, uploaded_at=time.time())
|
||||
await conn.execute(update_stmt)
|
||||
else:
|
||||
# Insert new entry
|
||||
insert_stmt = image_cache.insert().values(
|
||||
image_path=image_path,
|
||||
telegram_file_id=telegram_file_id,
|
||||
uploaded_at=time.time()
|
||||
)
|
||||
await conn.execute(insert_stmt)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
# --- Wandering Enemies Functions ---
|
||||
async def spawn_wandering_enemy(npc_id: str, location_id: str, lifetime_seconds: int = 600):
|
||||
"""Spawn a wandering enemy at a location. Lifetime defaults to 10 minutes."""
|
||||
async with engine.connect() as conn:
|
||||
current_time = time.time()
|
||||
despawn_time = current_time + lifetime_seconds
|
||||
|
||||
await conn.execute(wandering_enemies.insert().values(
|
||||
npc_id=npc_id,
|
||||
location_id=location_id,
|
||||
spawn_timestamp=current_time,
|
||||
despawn_timestamp=despawn_time
|
||||
))
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def get_wandering_enemies_in_location(location_id: str):
|
||||
"""Get all active wandering enemies at a location."""
|
||||
async with engine.connect() as conn:
|
||||
current_time = time.time()
|
||||
stmt = wandering_enemies.select().where(
|
||||
wandering_enemies.c.location_id == location_id,
|
||||
wandering_enemies.c.despawn_timestamp > current_time
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
|
||||
|
||||
async def remove_wandering_enemy(enemy_id: int):
|
||||
"""Remove a wandering enemy (when engaged in combat or manually despawned)."""
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(wandering_enemies.delete().where(wandering_enemies.c.id == enemy_id))
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def cleanup_expired_wandering_enemies():
|
||||
"""Remove all expired wandering enemies."""
|
||||
async with engine.connect() as conn:
|
||||
current_time = time.time()
|
||||
result = await conn.execute(
|
||||
wandering_enemies.delete().where(wandering_enemies.c.despawn_timestamp <= current_time)
|
||||
)
|
||||
await conn.commit()
|
||||
return result.rowcount # Number of enemies despawned
|
||||
|
||||
|
||||
async def get_wandering_enemy_count_in_location(location_id: str) -> int:
|
||||
"""Count active wandering enemies at a location."""
|
||||
async with engine.connect() as conn:
|
||||
current_time = time.time()
|
||||
from sqlalchemy import func
|
||||
stmt = wandering_enemies.select().where(
|
||||
wandering_enemies.c.location_id == location_id,
|
||||
wandering_enemies.c.despawn_timestamp > current_time
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
return len(result.fetchall())
|
||||
|
||||
|
||||
async def get_all_active_wandering_enemies():
|
||||
"""Get all active wandering enemies across all locations."""
|
||||
async with engine.connect() as conn:
|
||||
current_time = time.time()
|
||||
stmt = wandering_enemies.select().where(
|
||||
wandering_enemies.c.despawn_timestamp > current_time
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STATUS EFFECTS
|
||||
# ============================================================================
|
||||
|
||||
async def get_player_status_effects(player_id: int):
|
||||
"""Get all active status effects for a player."""
|
||||
async with engine.connect() as conn:
|
||||
stmt = player_status_effects.select().where(
|
||||
player_status_effects.c.player_id == player_id,
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
)
|
||||
result = await conn.execute(stmt)
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
|
||||
|
||||
async def add_status_effect(player_id: int, effect_name: str, effect_icon: str,
|
||||
damage_per_tick: int, ticks_remaining: int):
|
||||
"""Add a new status effect to a player."""
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(
|
||||
player_status_effects.insert().values(
|
||||
player_id=player_id,
|
||||
effect_name=effect_name,
|
||||
effect_icon=effect_icon,
|
||||
damage_per_tick=damage_per_tick,
|
||||
ticks_remaining=ticks_remaining,
|
||||
applied_at=time.time()
|
||||
)
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def update_status_effect_ticks(effect_id: int, ticks_remaining: int):
|
||||
"""Update the remaining ticks for a status effect."""
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(
|
||||
player_status_effects.update().where(
|
||||
player_status_effects.c.id == effect_id
|
||||
).values(ticks_remaining=ticks_remaining)
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def remove_status_effect(effect_id: int):
|
||||
"""Remove a specific status effect."""
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(
|
||||
player_status_effects.delete().where(player_status_effects.c.id == effect_id)
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def remove_all_status_effects(player_id: int):
|
||||
"""Remove all status effects from a player."""
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(
|
||||
player_status_effects.delete().where(player_status_effects.c.player_id == player_id)
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def remove_status_effects_by_name(player_id: int, effect_name: str, count: int = 1):
|
||||
"""
|
||||
Remove a specific number of status effects by name for a player.
|
||||
Used for treatment items that cure specific effects.
|
||||
Returns the number of effects actually removed.
|
||||
"""
|
||||
async with engine.connect() as conn:
|
||||
# Get the effects to remove
|
||||
stmt = player_status_effects.select().where(
|
||||
player_status_effects.c.player_id == player_id,
|
||||
player_status_effects.c.effect_name == effect_name,
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
).limit(count)
|
||||
result = await conn.execute(stmt)
|
||||
effects_to_remove = result.fetchall()
|
||||
|
||||
# Remove them
|
||||
effect_ids = [row.id for row in effects_to_remove]
|
||||
if effect_ids:
|
||||
await conn.execute(
|
||||
player_status_effects.delete().where(
|
||||
player_status_effects.c.id.in_(effect_ids)
|
||||
)
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
return len(effect_ids)
|
||||
|
||||
|
||||
async def get_all_players_with_status_effects():
|
||||
"""Get all player IDs that have active status effects (for background processing)."""
|
||||
async with engine.connect() as conn:
|
||||
from sqlalchemy import distinct
|
||||
stmt = player_status_effects.select().with_only_columns(
|
||||
distinct(player_status_effects.c.player_id)
|
||||
).where(player_status_effects.c.ticks_remaining > 0)
|
||||
result = await conn.execute(stmt)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def decrement_all_status_effect_ticks():
|
||||
"""
|
||||
Decrement ticks for all active status effects and return affected player IDs.
|
||||
Used by background processor.
|
||||
"""
|
||||
async with engine.connect() as conn:
|
||||
# Get player IDs with effects before updating
|
||||
from sqlalchemy import distinct
|
||||
stmt = player_status_effects.select().with_only_columns(
|
||||
distinct(player_status_effects.c.player_id)
|
||||
).where(player_status_effects.c.ticks_remaining > 0)
|
||||
result = await conn.execute(stmt)
|
||||
affected_players = [row[0] for row in result.fetchall()]
|
||||
|
||||
# Decrement ticks
|
||||
await conn.execute(
|
||||
player_status_effects.update().where(
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
).values(ticks_remaining=player_status_effects.c.ticks_remaining - 1)
|
||||
)
|
||||
|
||||
# Remove expired effects
|
||||
await conn.execute(
|
||||
player_status_effects.delete().where(player_status_effects.c.ticks_remaining <= 0)
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
return affected_players
|
||||
174
bot/handlers.py
@@ -1,174 +0,0 @@
|
||||
"""
|
||||
Main handlers for the Telegram bot.
|
||||
This module contains the core button callback routing.
|
||||
All other functionality is organized in separate modules:
|
||||
- action_handlers.py - World interaction handlers
|
||||
- inventory_handlers.py - Inventory management
|
||||
- combat_handlers.py - Combat actions
|
||||
- profile_handlers.py - Character stats
|
||||
- corpse_handlers.py - Looting system
|
||||
- pickup_handlers.py - Item collection
|
||||
- message_utils.py - Message sending/editing utilities
|
||||
- commands.py - Slash command handlers
|
||||
"""
|
||||
import logging
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from .message_utils import send_or_edit_with_image
|
||||
|
||||
# Import organized action handlers
|
||||
from .action_handlers import (
|
||||
handle_inspect_area,
|
||||
handle_attack_wandering,
|
||||
handle_inspect_interactable,
|
||||
handle_action,
|
||||
handle_main_menu,
|
||||
handle_move_menu,
|
||||
handle_move
|
||||
)
|
||||
from .inventory_handlers import (
|
||||
handle_inventory_menu,
|
||||
handle_inventory_item,
|
||||
handle_inventory_use,
|
||||
handle_inventory_drop,
|
||||
handle_inventory_equip,
|
||||
handle_inventory_unequip
|
||||
)
|
||||
from .pickup_handlers import (
|
||||
handle_pickup_menu,
|
||||
handle_pickup
|
||||
)
|
||||
from .combat_handlers import (
|
||||
handle_combat_attack,
|
||||
handle_combat_flee,
|
||||
handle_combat_use_item_menu,
|
||||
handle_combat_use_item,
|
||||
handle_combat_back
|
||||
)
|
||||
from .profile_handlers import (
|
||||
handle_profile,
|
||||
handle_spend_points_menu,
|
||||
handle_spend_point
|
||||
)
|
||||
from .corpse_handlers import (
|
||||
handle_loot_player_corpse,
|
||||
handle_take_corpse_item,
|
||||
handle_scavenge_npc_corpse,
|
||||
handle_scavenge_corpse_item
|
||||
)
|
||||
|
||||
# Import command handlers (for main.py to register)
|
||||
from .commands import start, export_map, spawn_stats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HANDLER REGISTRY
|
||||
# ============================================================================
|
||||
|
||||
# Map of action types to their handler functions
|
||||
# All handlers have signature: async def handle_*(query, user_id, player, data=None)
|
||||
HANDLER_MAP = {
|
||||
# Inspection & World Interaction
|
||||
'inspect_area': handle_inspect_area,
|
||||
'inspect_area_menu': handle_inspect_area,
|
||||
'attack_wandering': handle_attack_wandering,
|
||||
'inspect': handle_inspect_interactable,
|
||||
'action': handle_action,
|
||||
|
||||
# Navigation & Menu
|
||||
'main_menu': handle_main_menu,
|
||||
'move_menu': handle_move_menu,
|
||||
'move': handle_move,
|
||||
|
||||
# Profile & Stats
|
||||
'profile': handle_profile,
|
||||
'spend_points_menu': handle_spend_points_menu,
|
||||
'spend_point': handle_spend_point,
|
||||
|
||||
# Inventory Management
|
||||
'inventory_menu': handle_inventory_menu,
|
||||
'inventory_item': handle_inventory_item,
|
||||
'inventory_use': handle_inventory_use,
|
||||
'inventory_drop': handle_inventory_drop,
|
||||
'inventory_equip': handle_inventory_equip,
|
||||
'inventory_unequip': handle_inventory_unequip,
|
||||
|
||||
# Item Pickup
|
||||
'pickup_menu': handle_pickup_menu,
|
||||
'pickup': handle_pickup,
|
||||
|
||||
# Combat Actions
|
||||
'combat_attack': handle_combat_attack,
|
||||
'combat_flee': handle_combat_flee,
|
||||
'combat_use_item_menu': handle_combat_use_item_menu,
|
||||
'combat_use_item': handle_combat_use_item,
|
||||
'combat_back': handle_combat_back,
|
||||
|
||||
# Corpse Looting
|
||||
'loot_player_corpse': handle_loot_player_corpse,
|
||||
'take_corpse_item': handle_take_corpse_item,
|
||||
'scavenge_npc_corpse': handle_scavenge_npc_corpse,
|
||||
'scavenge_corpse_item': handle_scavenge_corpse_item,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BUTTON CALLBACK ROUTER
|
||||
# ============================================================================
|
||||
|
||||
async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""
|
||||
Main router for button callbacks.
|
||||
Delegates to specific handler functions based on action type.
|
||||
All handlers have a unified signature: (query, user_id, player, data=None)
|
||||
|
||||
Note: user_id passed to handlers is actually the player's unique DB id (not telegram_id)
|
||||
"""
|
||||
from .api_client import api_client
|
||||
|
||||
query = update.callback_query
|
||||
telegram_id = query.from_user.id
|
||||
data = query.data.split(':')
|
||||
action_type = data[0]
|
||||
|
||||
# Get player by telegram_id and translate to unique id
|
||||
player = await api_client.get_player(telegram_id)
|
||||
if not player or player['is_dead']:
|
||||
await query.answer()
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="💀 Your journey has ended. You died in the wasteland. Create a new character with /start to begin again.",
|
||||
reply_markup=None
|
||||
)
|
||||
return
|
||||
|
||||
# From now on, use player's unique database id
|
||||
user_id = player['id']
|
||||
|
||||
# Check if player is in combat - restrict most actions
|
||||
combat = await api_client.get_combat(user_id)
|
||||
allowed_in_combat = {
|
||||
'combat_attack', 'combat_flee', 'combat_use_item_menu',
|
||||
'combat_use_item', 'combat_back', 'no_op'
|
||||
}
|
||||
if combat and action_type not in allowed_in_combat:
|
||||
await query.answer("You're in combat! Focus on the fight!", show_alert=False)
|
||||
return
|
||||
|
||||
# Route to appropriate handler
|
||||
if action_type == 'no_op':
|
||||
await query.answer()
|
||||
return
|
||||
|
||||
handler = HANDLER_MAP.get(action_type)
|
||||
if handler:
|
||||
try:
|
||||
await handler(query, user_id, player, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling button action {action_type}: {e}", exc_info=True)
|
||||
await query.answer("An error occurred. Please try again.", show_alert=True)
|
||||
else:
|
||||
logger.warning(f"Unknown action type: {action_type}")
|
||||
await query.answer("Unknown action", show_alert=False)
|
||||
@@ -1,338 +0,0 @@
|
||||
"""
|
||||
Inventory-related action handlers.
|
||||
"""
|
||||
import logging
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from . import keyboards, logic
|
||||
from data.world_loader import game_world
|
||||
from data.items import ITEMS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_inventory_menu(query, user_id: int, player: dict, data: list = None):
|
||||
"""Display player inventory with item management options."""
|
||||
from .utils import format_stat_bar
|
||||
from .api_client import api_client
|
||||
await query.answer()
|
||||
|
||||
# Get inventory from API
|
||||
inv_result = await api_client.get_inventory(player['id'])
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
current_weight, current_volume = logic.calculate_inventory_load(inventory_items)
|
||||
max_weight, max_volume = logic.get_player_capacity(inventory_items, player)
|
||||
|
||||
text = "<b>🎒 Your Inventory:</b>\n"
|
||||
text += f"{format_stat_bar('HP', '❤️', player['hp'], player['max_hp'])}\n"
|
||||
text += f"{format_stat_bar('Stamina', '⚡', player['stamina'], player['max_stamina'])}\n"
|
||||
text += f"📊 Weight: {current_weight}/{max_weight} kg\n"
|
||||
text += f"📦 Volume: {current_volume}/{max_volume} vol\n"
|
||||
|
||||
if not inventory_items:
|
||||
text += "\n<i>Your inventory is empty.</i>"
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboards.inventory_keyboard(inventory_items),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_inventory_item(query, user_id: int, player: dict, data: list):
|
||||
"""Show details for a specific inventory item.
|
||||
|
||||
Note: item_db_id is the inventory row id from the API response.
|
||||
We need to get the full inventory and find the item by id.
|
||||
"""
|
||||
from .api_client import api_client
|
||||
|
||||
await query.answer()
|
||||
item_db_id = int(data[1])
|
||||
|
||||
# Get inventory from API
|
||||
inv_result = await api_client.get_inventory(user_id)
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
|
||||
# Find the specific item
|
||||
item = next((i for i in inventory_items if i['id'] == item_db_id), None)
|
||||
if not item:
|
||||
await query.answer("Item not found in inventory", show_alert=True)
|
||||
return
|
||||
|
||||
emoji = item.get('emoji', '❔')
|
||||
|
||||
# Build item details text
|
||||
text = f"{emoji} <b>{item.get('name', 'Unknown')}</b>\n"
|
||||
|
||||
description = item.get('description')
|
||||
if description:
|
||||
text += f"<i>{description}</i>\n\n"
|
||||
else:
|
||||
text += "\n"
|
||||
|
||||
text += f"<b>Weight:</b> {item.get('weight', 0)} kg | <b>Volume:</b> {item.get('volume', 0)} vol\n"
|
||||
|
||||
# Add weapon stats if applicable
|
||||
if item.get('type') == 'weapon':
|
||||
text += f"<b>Damage:</b> {item.get('damage_min', 0)}-{item.get('damage_max', 0)}\n"
|
||||
|
||||
# Add consumable effects if applicable
|
||||
if item.get('type') == 'consumable':
|
||||
effects = []
|
||||
if item.get('hp_restore'):
|
||||
effects.append(f"❤️ +{item.get('hp_restore')} HP")
|
||||
if item.get('stamina_restore'):
|
||||
effects.append(f"⚡ +{item.get('stamina_restore')} Stamina")
|
||||
if effects:
|
||||
text += f"<b>Effects:</b> {', '.join(effects)}\n"
|
||||
|
||||
# Add equipped status
|
||||
if item.get('is_equipped'):
|
||||
text += "\n✅ <b>Currently Equipped</b>"
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboards.inventory_item_actions_keyboard(
|
||||
item_db_id, item, item.get('is_equipped', False), item['quantity']
|
||||
),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_inventory_use(query, user_id: int, player: dict, data: list):
|
||||
"""Use a consumable item from inventory."""
|
||||
from .utils import format_stat_bar
|
||||
from .api_client import api_client
|
||||
|
||||
item_db_id = int(data[1])
|
||||
|
||||
# Get inventory from API to find the item
|
||||
inv_result = await api_client.get_inventory(user_id)
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
item = next((i for i in inventory_items if i['id'] == item_db_id), None)
|
||||
|
||||
if not item:
|
||||
await query.answer("Item not found.", show_alert=False)
|
||||
return
|
||||
|
||||
if item.get('type') != 'consumable':
|
||||
await query.answer("This item cannot be used.", show_alert=False)
|
||||
return
|
||||
|
||||
await query.answer()
|
||||
|
||||
# Use the API to use the item
|
||||
result = await api_client.use_item(user_id, item['item_id'])
|
||||
|
||||
if not result.get('success'):
|
||||
await query.answer(result.get('message', 'Failed to use item'), show_alert=True)
|
||||
return
|
||||
|
||||
# Refresh player data to get updated stats
|
||||
player = await api_client.get_player_by_id(user_id)
|
||||
|
||||
# Get updated inventory
|
||||
inv_result = await api_client.get_inventory(user_id)
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
current_weight, current_volume = logic.calculate_inventory_load(inventory_items)
|
||||
max_weight, max_volume = logic.get_player_capacity(inventory_items, player)
|
||||
|
||||
# Build status section with HP/Stamina bars
|
||||
text = "<b>🎒 Your Inventory:</b>\n"
|
||||
text += f"{format_stat_bar('HP', '❤️', player['hp'], player['max_hp'])}\n"
|
||||
text += f"{format_stat_bar('Stamina', '⚡', player['stamina'], player['max_stamina'])}\n"
|
||||
text += f"📊 Weight: {current_weight}/{max_weight} kg\n"
|
||||
text += f"📦 Volume: {current_volume}/{max_volume} vol\n"
|
||||
text += "━━━━━━━━━━━━━━━━━━━━\n"
|
||||
|
||||
# Build result message from API response
|
||||
text += result.get('message', 'Item used.')
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboards.inventory_keyboard(inventory_items),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_inventory_drop(query, user_id: int, player: dict, data: list):
|
||||
"""Drop an item from inventory to the world."""
|
||||
from .api_client import api_client
|
||||
|
||||
item_db_id = int(data[1])
|
||||
drop_amount_str = data[2] if len(data) > 2 else None
|
||||
|
||||
# Get inventory to find the item
|
||||
inv_result = await api_client.get_inventory(user_id)
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
item = next((i for i in inventory_items if i['id'] == item_db_id), None)
|
||||
|
||||
if not item:
|
||||
await query.answer("Item not found.", show_alert=False)
|
||||
return
|
||||
|
||||
# Determine how much to drop
|
||||
if drop_amount_str is None or drop_amount_str == "all":
|
||||
drop_amount = item['quantity']
|
||||
else:
|
||||
drop_amount = min(int(drop_amount_str), item['quantity'])
|
||||
|
||||
# Use API to drop item
|
||||
result = await api_client.drop_item(user_id, item['item_id'], drop_amount)
|
||||
|
||||
if result.get('success'):
|
||||
await query.answer(result.get('message', f"Dropped {drop_amount}x {item['name']}"), show_alert=False)
|
||||
else:
|
||||
await query.answer(result.get('message', 'Failed to drop item'), show_alert=True)
|
||||
return
|
||||
|
||||
# Get updated inventory
|
||||
inv_result = await api_client.get_inventory(user_id)
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
current_weight, current_volume = logic.calculate_inventory_load(inventory_items)
|
||||
max_weight, max_volume = logic.get_player_capacity(inventory_items, player)
|
||||
|
||||
text = "<b>🎒 Your Inventory:</b>\n"
|
||||
text += f"📊 Weight: {current_weight}/{max_weight} kg\n"
|
||||
text += f"📦 Volume: {current_volume}/{max_volume} vol\n\n"
|
||||
|
||||
if not inventory_items:
|
||||
text += "It's empty."
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboards.inventory_keyboard(inventory_items),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_inventory_equip(query, user_id: int, player: dict, data: list):
|
||||
"""Equip an item from inventory."""
|
||||
from .api_client import api_client
|
||||
|
||||
item_db_id = int(data[1])
|
||||
|
||||
# Get inventory to find the item
|
||||
inv_result = await api_client.get_inventory(user_id)
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
item = next((i for i in inventory_items if i['id'] == item_db_id), None)
|
||||
|
||||
if not item:
|
||||
await query.answer("Item not found.", show_alert=False)
|
||||
return
|
||||
|
||||
if not item.get('equippable'):
|
||||
await query.answer("This item cannot be equipped.", show_alert=False)
|
||||
return
|
||||
|
||||
# Use API to equip item
|
||||
result = await api_client.equip_item(user_id, item['item_id'])
|
||||
|
||||
if not result.get('success'):
|
||||
await query.answer(result.get('message', 'Failed to equip item'), show_alert=True)
|
||||
return
|
||||
|
||||
await query.answer(result.get('message', f"Equipped {item['name']}!"), show_alert=False)
|
||||
|
||||
# Refresh the item view
|
||||
emoji = item.get('emoji', '❔')
|
||||
text = f"{emoji} <b>{item.get('name', 'Unknown')}</b>\n"
|
||||
|
||||
description = item.get('description')
|
||||
if description:
|
||||
text += f"<i>{description}</i>\n\n"
|
||||
else:
|
||||
text += "\n"
|
||||
|
||||
text += f"<b>Weight:</b> {item.get('weight', 0)} kg | <b>Volume:</b> {item.get('volume', 0)} vol\n"
|
||||
|
||||
if item.get('type') == 'weapon':
|
||||
text += f"<b>Damage:</b> {item.get('damage_min', 0)}-{item.get('damage_max', 0)}\n"
|
||||
|
||||
text += "\n✅ <b>Currently Equipped</b>"
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboards.inventory_item_actions_keyboard(
|
||||
item_db_id, item, True, item['quantity']
|
||||
),
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_inventory_unequip(query, user_id: int, player: dict, data: list):
|
||||
"""Unequip an item."""
|
||||
from .api_client import api_client
|
||||
|
||||
item_db_id = int(data[1])
|
||||
|
||||
# Get inventory to find the item
|
||||
inv_result = await api_client.get_inventory(user_id)
|
||||
inventory_items = inv_result.get('inventory', [])
|
||||
item = next((i for i in inventory_items if i['id'] == item_db_id), None)
|
||||
|
||||
if not item:
|
||||
await query.answer("Item not found.", show_alert=False)
|
||||
return
|
||||
|
||||
# Use API to unequip item
|
||||
result = await api_client.unequip_item(user_id, item['item_id'])
|
||||
|
||||
if not result.get('success'):
|
||||
await query.answer(result.get('message', 'Failed to unequip item'), show_alert=True)
|
||||
return
|
||||
|
||||
await query.answer(result.get('message', f"Unequipped {item['name']}."), show_alert=False)
|
||||
|
||||
# Refresh the item view
|
||||
emoji = item.get('emoji', '❔')
|
||||
text = f"{emoji} <b>{item.get('name', 'Unknown')}</b>\n"
|
||||
|
||||
description = item.get('description')
|
||||
if description:
|
||||
text += f"<i>{description}</i>\n\n"
|
||||
else:
|
||||
text += "\n"
|
||||
|
||||
text += f"<b>Weight:</b> {item.get('weight', 0)} kg | <b>Volume:</b> {item.get('volume', 0)} vol\n"
|
||||
|
||||
if item.get('type') == 'weapon':
|
||||
text += f"<b>Damage:</b> {item.get('damage_min', 0)}-{item.get('damage_max', 0)}\n"
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboards.inventory_item_actions_keyboard(
|
||||
item_db_id, item, False, item['quantity']
|
||||
),
|
||||
image_path=location_image
|
||||
)
|
||||
607
bot/keyboards.py
@@ -1,607 +0,0 @@
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from data.world_loader import game_world
|
||||
from data.items import ITEMS
|
||||
|
||||
# ... (main_menu_keyboard, move_keyboard are unchanged) ...
|
||||
def main_menu_keyboard() -> InlineKeyboardMarkup:
|
||||
keyboard = [[InlineKeyboardButton("🗺️ Move", callback_data="move_menu"), InlineKeyboardButton("👀 Inspect Area", callback_data="inspect_area")], [InlineKeyboardButton("👤 Profile", callback_data="profile"), InlineKeyboardButton("🎒 Inventory", callback_data="inventory_menu")]]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def move_keyboard(current_location_id: str, player_id: int) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create a movement keyboard with stamina costs.
|
||||
Layout:
|
||||
[ North (⚡5) ]
|
||||
[ West (⚡5) ] [ East (⚡5) ]
|
||||
[ South (⚡5) ]
|
||||
[ Other exits (inside, down, etc.) ]
|
||||
[ Back ]
|
||||
"""
|
||||
from bot import logic
|
||||
from bot.api_client import api_client
|
||||
|
||||
keyboard = []
|
||||
location = game_world.get_location(current_location_id)
|
||||
player = await api_client.get_player(player_id)
|
||||
inventory = await api_client.get_inventory(player_id)
|
||||
|
||||
if location and player:
|
||||
# Dictionary to hold direction buttons
|
||||
compass_directions = {}
|
||||
other_exits = []
|
||||
|
||||
for direction, destination_id in location.exits.items():
|
||||
destination = game_world.get_location(destination_id)
|
||||
if destination:
|
||||
# Calculate stamina cost for this specific route
|
||||
stamina_cost = logic.calculate_travel_stamina_cost(player, inventory, location, destination)
|
||||
|
||||
# Map direction to emoji and label
|
||||
direction_lower = direction.lower()
|
||||
if direction_lower == "north":
|
||||
emoji = "⬆️"
|
||||
compass_directions["north"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "south":
|
||||
emoji = "⬇️"
|
||||
compass_directions["south"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "east":
|
||||
emoji = "➡️"
|
||||
compass_directions["east"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "west":
|
||||
emoji = "⬅️"
|
||||
compass_directions["west"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "northeast":
|
||||
emoji = "↗️"
|
||||
compass_directions["northeast"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "northwest":
|
||||
emoji = "↖️"
|
||||
compass_directions["northwest"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "southeast":
|
||||
emoji = "↘️"
|
||||
compass_directions["southeast"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "southwest":
|
||||
emoji = "↙️"
|
||||
compass_directions["southwest"] = InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
)
|
||||
elif direction_lower == "inside":
|
||||
emoji = "🚪"
|
||||
other_exits.append(InlineKeyboardButton(
|
||||
f"{emoji} Enter {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
))
|
||||
elif direction_lower == "outside":
|
||||
emoji = "🚪"
|
||||
other_exits.append(InlineKeyboardButton(
|
||||
f"{emoji} Exit to {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
))
|
||||
elif direction_lower == "down":
|
||||
emoji = "⬇️"
|
||||
other_exits.append(InlineKeyboardButton(
|
||||
f"{emoji} Descend to {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
))
|
||||
elif direction_lower == "up":
|
||||
emoji = "⬆️"
|
||||
other_exits.append(InlineKeyboardButton(
|
||||
f"{emoji} Ascend to {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
))
|
||||
else:
|
||||
# Generic fallback for any other direction
|
||||
emoji = "🔀"
|
||||
other_exits.append(InlineKeyboardButton(
|
||||
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||
callback_data=f"move:{destination_id}"
|
||||
))
|
||||
|
||||
# Build compass layout
|
||||
# Row 1: Northwest, North, Northeast
|
||||
top_row = []
|
||||
if "northwest" in compass_directions:
|
||||
top_row.append(compass_directions["northwest"])
|
||||
if "north" in compass_directions:
|
||||
top_row.append(compass_directions["north"])
|
||||
if "northeast" in compass_directions:
|
||||
top_row.append(compass_directions["northeast"])
|
||||
if top_row:
|
||||
keyboard.append(top_row)
|
||||
|
||||
# Row 2: West and/or East
|
||||
middle_row = []
|
||||
if "west" in compass_directions:
|
||||
middle_row.append(compass_directions["west"])
|
||||
if "east" in compass_directions:
|
||||
middle_row.append(compass_directions["east"])
|
||||
if middle_row:
|
||||
keyboard.append(middle_row)
|
||||
|
||||
# Row 3: Southwest, South, Southeast
|
||||
bottom_row = []
|
||||
if "southwest" in compass_directions:
|
||||
bottom_row.append(compass_directions["southwest"])
|
||||
if "south" in compass_directions:
|
||||
bottom_row.append(compass_directions["south"])
|
||||
if "southeast" in compass_directions:
|
||||
bottom_row.append(compass_directions["southeast"])
|
||||
if bottom_row:
|
||||
keyboard.append(bottom_row)
|
||||
|
||||
# Add other exits (inside, outside, up, down, etc.)
|
||||
for exit_button in other_exits:
|
||||
keyboard.append([exit_button])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="main_menu")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def inspect_keyboard(location_id: str, dropped_items: list, wandering_enemies: list = None) -> InlineKeyboardMarkup:
|
||||
from bot.api_client import api_client
|
||||
from data.npcs import NPCS
|
||||
|
||||
keyboard = []
|
||||
location = game_world.get_location(location_id)
|
||||
|
||||
# Show wandering enemies first if present (in pairs, emoji only)
|
||||
if wandering_enemies:
|
||||
row = []
|
||||
for enemy in wandering_enemies:
|
||||
npc_def = NPCS.get(enemy['npc_id'])
|
||||
if npc_def:
|
||||
button = InlineKeyboardButton(
|
||||
f"⚠️ {npc_def.emoji} {npc_def.name}",
|
||||
callback_data=f"attack_wandering:{enemy['id']}"
|
||||
)
|
||||
row.append(button)
|
||||
if len(row) == 2:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row: # Add remaining enemy if odd number
|
||||
keyboard.append(row)
|
||||
if wandering_enemies:
|
||||
keyboard.append([InlineKeyboardButton("--- Environment ---", callback_data="no_op")])
|
||||
|
||||
# Show interactables in pairs when text is short enough
|
||||
if location:
|
||||
row = []
|
||||
for instance_id, interactable in location.interactables.items():
|
||||
label = interactable.name
|
||||
# Check if ANY action is available (not on cooldown)
|
||||
has_available_action = False
|
||||
for action_id in interactable.actions.keys():
|
||||
cooldown_key = f"{instance_id}:{action_id}"
|
||||
if await api_client.get_cooldown(cooldown_key) == 0:
|
||||
has_available_action = True
|
||||
break
|
||||
if not has_available_action and len(interactable.actions) > 0:
|
||||
label += " ⏳"
|
||||
|
||||
# Include location_id in callback data for efficient lookup
|
||||
button = InlineKeyboardButton(label, callback_data=f"inspect:{location_id}:{instance_id}")
|
||||
|
||||
# If text is short (< 20 chars), try to pair it
|
||||
if len(label) < 20:
|
||||
row.append(button)
|
||||
if len(row) == 2:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
else:
|
||||
# Long text, add any pending row first, then add this one alone
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
keyboard.append([button])
|
||||
|
||||
# Add remaining button if odd number
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
|
||||
# Show player corpse bags
|
||||
player_corpses = await api_client.get_player_corpses_in_location(location_id)
|
||||
if player_corpses:
|
||||
keyboard.append([InlineKeyboardButton("--- Fallen survivors ---", callback_data="no_op")])
|
||||
row = []
|
||||
for corpse in player_corpses:
|
||||
button = InlineKeyboardButton(
|
||||
f"🎒 {corpse['player_name']}'s bag",
|
||||
callback_data=f"loot_player_corpse:{corpse['id']}"
|
||||
)
|
||||
row.append(button)
|
||||
if len(row) == 2:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
|
||||
# Show NPC corpses
|
||||
npc_corpses = await api_client.get_npc_corpses_in_location(location_id)
|
||||
if npc_corpses:
|
||||
if not player_corpses: # Only add separator if not already added
|
||||
keyboard.append([InlineKeyboardButton("--- Corpses ---", callback_data="no_op")])
|
||||
row = []
|
||||
for corpse in npc_corpses:
|
||||
from data.npcs import NPCS
|
||||
npc_def = NPCS.get(corpse['npc_id'])
|
||||
if npc_def:
|
||||
button = InlineKeyboardButton(
|
||||
f"{npc_def.emoji} {npc_def.name}",
|
||||
callback_data=f"scavenge_npc_corpse:{corpse['id']}"
|
||||
)
|
||||
row.append(button)
|
||||
if len(row) == 2:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
|
||||
if dropped_items:
|
||||
keyboard.append([InlineKeyboardButton("--- Items on the ground ---", callback_data="no_op")])
|
||||
row = []
|
||||
for item in dropped_items:
|
||||
item_def = ITEMS.get(item['item_id'], {})
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
quantity_text = f" x{item['quantity']}" if item['quantity'] > 1 else ""
|
||||
button = InlineKeyboardButton(
|
||||
f"{emoji} {item_def.get('name', 'Unknown')}{quantity_text}",
|
||||
callback_data=f"pickup_menu:{item['id']}"
|
||||
)
|
||||
row.append(button)
|
||||
if len(row) == 2:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="main_menu")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
def pickup_options_keyboard(item_id: int, item_name: str, quantity: int) -> InlineKeyboardMarkup:
|
||||
"""Create pickup options keyboard with x1, x5, x10, and All options."""
|
||||
keyboard = []
|
||||
|
||||
if quantity == 1:
|
||||
# Just show a single "Pick" button for single items
|
||||
keyboard.append([InlineKeyboardButton("📦 Pick", callback_data=f"pickup:{item_id}:1")])
|
||||
else:
|
||||
# Build pickup row with available options
|
||||
pickup_row = [InlineKeyboardButton("📦 Pick x1", callback_data=f"pickup:{item_id}:1")]
|
||||
|
||||
if quantity >= 5:
|
||||
pickup_row.append(InlineKeyboardButton("📦 Pick x5", callback_data=f"pickup:{item_id}:5"))
|
||||
if quantity >= 10:
|
||||
pickup_row.append(InlineKeyboardButton("📦 Pick x10", callback_data=f"pickup:{item_id}:10"))
|
||||
|
||||
# Split into rows if more than 2 buttons
|
||||
if len(pickup_row) > 2:
|
||||
keyboard.append(pickup_row[:2])
|
||||
keyboard.append(pickup_row[2:])
|
||||
else:
|
||||
keyboard.append(pickup_row)
|
||||
|
||||
# Add "Pick All" option
|
||||
keyboard.append([InlineKeyboardButton(f"📦 Pick All ({quantity})", callback_data=f"pickup:{item_id}:all")])
|
||||
|
||||
# Back button
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="inspect_area")])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def actions_keyboard(location_id: str, instance_id: str) -> InlineKeyboardMarkup:
|
||||
from bot.api_client import api_client
|
||||
keyboard = []
|
||||
|
||||
location = game_world.get_location(location_id)
|
||||
|
||||
if location:
|
||||
interactable = location.get_interactable(instance_id)
|
||||
if interactable:
|
||||
for action_id, action in interactable.actions.items():
|
||||
cooldown_key = f"{instance_id}:{action_id}"
|
||||
cooldown = await api_client.get_cooldown(cooldown_key)
|
||||
label = action.label
|
||||
# Add stamina cost to the label
|
||||
if action.stamina_cost > 0:
|
||||
label += f" (⚡{action.stamina_cost})"
|
||||
if cooldown > 0:
|
||||
label += " ⏳"
|
||||
keyboard.append([InlineKeyboardButton(label, callback_data=f"action:{location_id}:{instance_id}:{action_id}")])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data=f"inspect_area_menu:{location_id}")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
# ... (inventory_keyboard, inventory_item_actions_keyboard are unchanged) ...
|
||||
def inventory_keyboard(inventory_items: list) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
if inventory_items:
|
||||
# Categorize and sort items
|
||||
# Group items by item_id and equipped status to handle stacking properly
|
||||
item_groups = {}
|
||||
|
||||
for item in inventory_items:
|
||||
item_def = ITEMS.get(item['item_id'], {})
|
||||
item_type = item_def.get('type', 'resource')
|
||||
item_name = item_def.get('name', 'Unknown')
|
||||
is_equipped = item.get('is_equipped', False)
|
||||
|
||||
# Create a unique key for grouping: item_id + equipped status
|
||||
group_key = (item['item_id'], is_equipped)
|
||||
|
||||
if group_key not in item_groups:
|
||||
item_groups[group_key] = {
|
||||
'name': item_name,
|
||||
'def': item_def,
|
||||
'type': item_type,
|
||||
'is_equipped': is_equipped,
|
||||
'items': []
|
||||
}
|
||||
item_groups[group_key]['items'].append(item)
|
||||
|
||||
# Categorize groups
|
||||
equipped = []
|
||||
consumables = []
|
||||
weapons = []
|
||||
equipment = []
|
||||
resources = []
|
||||
quest_items = []
|
||||
|
||||
for group_key, group_data in item_groups.items():
|
||||
item_name = group_data['name']
|
||||
item_def = group_data['def']
|
||||
item_type = group_data['type']
|
||||
is_equipped = group_data['is_equipped']
|
||||
items_list = group_data['items']
|
||||
|
||||
# Calculate total quantity and weight/volume for this group
|
||||
total_quantity = sum(itm['quantity'] for itm in items_list)
|
||||
weight_per_item = item_def.get('weight', 0)
|
||||
volume_per_item = item_def.get('volume', 0)
|
||||
total_weight = weight_per_item * total_quantity
|
||||
total_volume = volume_per_item * total_quantity
|
||||
|
||||
# Use the first item's ID for the callback (they're all the same item type)
|
||||
first_item_id = items_list[0]['id']
|
||||
|
||||
# Create item data tuple: (name, item_def, first_item_id, quantity, weight, volume, is_equipped)
|
||||
item_tuple = (item_name, item_def, first_item_id, total_quantity, total_weight, total_volume, is_equipped)
|
||||
|
||||
# Only equipped items go to equipped section
|
||||
if is_equipped:
|
||||
equipped.append(item_tuple)
|
||||
elif item_type == 'consumable':
|
||||
consumables.append(item_tuple)
|
||||
elif item_type == 'weapon':
|
||||
weapons.append(item_tuple)
|
||||
elif item_type == 'equipment':
|
||||
equipment.append(item_tuple)
|
||||
elif item_type == 'quest':
|
||||
quest_items.append(item_tuple)
|
||||
else:
|
||||
resources.append(item_tuple)
|
||||
|
||||
# Sort each category alphabetically by name
|
||||
equipped.sort(key=lambda x: x[0])
|
||||
consumables.sort(key=lambda x: x[0])
|
||||
weapons.sort(key=lambda x: x[0])
|
||||
equipment.sort(key=lambda x: x[0])
|
||||
resources.sort(key=lambda x: x[0])
|
||||
quest_items.sort(key=lambda x: x[0])
|
||||
|
||||
# Build keyboard sections
|
||||
def add_section(section_name, items_list):
|
||||
if items_list:
|
||||
keyboard.append([InlineKeyboardButton(f"--- {section_name} ---", callback_data="no_op")])
|
||||
row = []
|
||||
for item_name, item_def, item_id, quantity, weight, volume, is_equipped in items_list:
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
quantity_text = f" x{quantity}" if quantity > 1 else ""
|
||||
equipped_marker = " ✓" if is_equipped else ""
|
||||
# Round to 2 decimals
|
||||
weight_vol_text = f" ({weight:.2f}kg, {volume:.2f}vol)" if quantity > 0 else ""
|
||||
|
||||
button = InlineKeyboardButton(
|
||||
f"{emoji} {item_name}{quantity_text}{equipped_marker}{weight_vol_text}",
|
||||
callback_data=f"inventory_item:{item_id}"
|
||||
)
|
||||
row.append(button)
|
||||
if len(row) == 2:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
# Add remaining item if odd number
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
|
||||
# Add sections in order
|
||||
add_section("Equipped", equipped)
|
||||
add_section("Consumables", consumables)
|
||||
add_section("Weapons", weapons)
|
||||
add_section("Equipment", equipment)
|
||||
add_section("Resources", resources)
|
||||
add_section("Quest Items", quest_items)
|
||||
|
||||
if not keyboard:
|
||||
keyboard.append([InlineKeyboardButton("--- Inventory is empty ---", callback_data="no_op")])
|
||||
else:
|
||||
keyboard.append([InlineKeyboardButton("--- Inventory is empty ---", callback_data="no_op")])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="main_menu")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
def inventory_item_actions_keyboard(item_db_id: int, item_def: dict, is_equipped: bool = False, quantity: int = 1) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
# Use button for consumables
|
||||
if item_def.get('type') == 'consumable':
|
||||
keyboard.append([InlineKeyboardButton("➡️ Use Item", callback_data=f"inventory_use:{item_db_id}")])
|
||||
|
||||
# Equip/Unequip button for weapons and equipment
|
||||
if item_def.get('type') in ["weapon", "equipment"]:
|
||||
if is_equipped:
|
||||
keyboard.append([InlineKeyboardButton("❌ Unequip", callback_data=f"inventory_unequip:{item_db_id}")])
|
||||
else:
|
||||
keyboard.append([InlineKeyboardButton("✅ Equip", callback_data=f"inventory_equip:{item_db_id}")])
|
||||
|
||||
# Drop buttons - simplified for single items
|
||||
if quantity == 1:
|
||||
# Just show a single "Drop" button
|
||||
keyboard.append([InlineKeyboardButton("🗑️ Drop", callback_data=f"inventory_drop:{item_db_id}:all")])
|
||||
else:
|
||||
# Show x1, x5, x10 options based on quantity
|
||||
drop_row = [InlineKeyboardButton("🗑️ Drop x1", callback_data=f"inventory_drop:{item_db_id}:1")]
|
||||
if quantity >= 5:
|
||||
drop_row.append(InlineKeyboardButton("🗑️ Drop x5", callback_data=f"inventory_drop:{item_db_id}:5"))
|
||||
if quantity >= 10:
|
||||
drop_row.append(InlineKeyboardButton("🗑️ Drop x10", callback_data=f"inventory_drop:{item_db_id}:10"))
|
||||
|
||||
# Split into rows if more than 2 buttons
|
||||
if len(drop_row) > 2:
|
||||
keyboard.append(drop_row[:2])
|
||||
keyboard.append(drop_row[2:])
|
||||
else:
|
||||
keyboard.append(drop_row)
|
||||
|
||||
# Add "Drop All" option
|
||||
keyboard.append([InlineKeyboardButton(f"🗑️ Drop All ({quantity})", callback_data=f"inventory_drop:{item_db_id}:all")])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back to Inventory", callback_data="inventory_menu")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def combat_keyboard(player_id: int) -> InlineKeyboardMarkup:
|
||||
"""Create combat action keyboard."""
|
||||
from bot.api_client import api_client
|
||||
keyboard = []
|
||||
|
||||
# Attack option
|
||||
keyboard.append([InlineKeyboardButton("⚔️ Attack", callback_data="combat_attack")])
|
||||
|
||||
# Flee option
|
||||
keyboard.append([InlineKeyboardButton("🏃 Try to Flee", callback_data="combat_flee")])
|
||||
|
||||
# Use item option (show consumables)
|
||||
inventory_items = await api_client.get_inventory(player_id)
|
||||
consumables = [item for item in inventory_items if ITEMS.get(item['item_id'], {}).get('type') == 'consumable']
|
||||
|
||||
if consumables:
|
||||
keyboard.append([InlineKeyboardButton("💊 Use Item", callback_data="combat_use_item_menu")])
|
||||
|
||||
# Profile button (no effect on turn, just info)
|
||||
keyboard.append([InlineKeyboardButton("👤 Profile", callback_data="profile")])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
async def combat_items_keyboard(player_id: int) -> InlineKeyboardMarkup:
|
||||
"""Show consumable items during combat."""
|
||||
from bot.api_client import api_client
|
||||
keyboard = []
|
||||
|
||||
inventory_items = await api_client.get_inventory(player_id)
|
||||
consumables = [item for item in inventory_items if ITEMS.get(item['item_id'], {}).get('type') == 'consumable']
|
||||
|
||||
if consumables:
|
||||
keyboard.append([InlineKeyboardButton("--- Select item to use ---", callback_data="no_op")])
|
||||
for item in consumables:
|
||||
item_def = ITEMS.get(item['item_id'], {})
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
keyboard.append([InlineKeyboardButton(
|
||||
f"{emoji} {item_def.get('name', 'Unknown')} x{item['quantity']}",
|
||||
callback_data=f"combat_use_item:{item['id']}"
|
||||
)])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back to Combat", callback_data="combat_back")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
def corpse_keyboard(corpse_id: int, corpse_type: str) -> InlineKeyboardMarkup:
|
||||
"""Create keyboard for interacting with corpses."""
|
||||
keyboard = []
|
||||
|
||||
if corpse_type == "player":
|
||||
keyboard.append([InlineKeyboardButton("🎒 Loot Bag", callback_data=f"loot_player_corpse:{corpse_id}")])
|
||||
else: # NPC corpse
|
||||
keyboard.append([InlineKeyboardButton("🔪 Scavenge Corpse", callback_data=f"scavenge_npc_corpse:{corpse_id}")])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="inspect_area")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
def player_corpse_loot_keyboard(corpse_id: int, items: list) -> InlineKeyboardMarkup:
|
||||
"""Show items in a player corpse bag."""
|
||||
keyboard = []
|
||||
|
||||
if items:
|
||||
keyboard.append([InlineKeyboardButton("--- Take items ---", callback_data="no_op")])
|
||||
for i, item_data in enumerate(items):
|
||||
item_def = ITEMS.get(item_data['item_id'], {})
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
keyboard.append([InlineKeyboardButton(
|
||||
f"{emoji} Take {item_def.get('name', 'Unknown')} x{item_data['quantity']}",
|
||||
callback_data=f"take_corpse_item:{corpse_id}:{i}"
|
||||
)])
|
||||
else:
|
||||
keyboard.append([InlineKeyboardButton("--- Bag is empty ---", callback_data="no_op")])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="inspect_area")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
def npc_corpse_scavenge_keyboard(corpse_id: int, loot_items: list) -> InlineKeyboardMarkup:
|
||||
"""Show scavenging options for NPC corpse."""
|
||||
keyboard = []
|
||||
|
||||
if loot_items:
|
||||
keyboard.append([InlineKeyboardButton("--- Scavenge for materials ---", callback_data="no_op")])
|
||||
for i, loot_data in enumerate(loot_items):
|
||||
item_def = ITEMS.get(loot_data['item_id'], {})
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
|
||||
label = f"{emoji} {item_def.get('name', 'Unknown')}"
|
||||
if loot_data.get('required_tool'):
|
||||
tool_def = ITEMS.get(loot_data['required_tool'], {})
|
||||
label += f" (needs {tool_def.get('name', 'tool')})"
|
||||
|
||||
keyboard.append([InlineKeyboardButton(
|
||||
label,
|
||||
callback_data=f"scavenge_corpse_item:{corpse_id}:{i}"
|
||||
)])
|
||||
else:
|
||||
keyboard.append([InlineKeyboardButton("--- Nothing left to scavenge ---", callback_data="no_op")])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="inspect_area")])
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
def spend_points_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Create keyboard for spending stat points."""
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton("❤️ Max HP (+10)", callback_data="spend_point:max_hp"),
|
||||
InlineKeyboardButton("⚡ Stamina (+5)", callback_data="spend_point:max_stamina")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton("💪 Strength (+1)", callback_data="spend_point:strength"),
|
||||
InlineKeyboardButton("🏃 Agility (+1)", callback_data="spend_point:agility")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton("💚 Endurance (+1)", callback_data="spend_point:endurance"),
|
||||
InlineKeyboardButton("🧠 Intellect (+1)", callback_data="spend_point:intellect")
|
||||
],
|
||||
[InlineKeyboardButton("⬅️ Back to Profile", callback_data="profile")]
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
119
bot/logic.py
@@ -1,119 +0,0 @@
|
||||
import random
|
||||
from typing import Tuple, Dict, Any
|
||||
|
||||
from data.items import ITEMS
|
||||
from data.models import Action, Outcome
|
||||
|
||||
def calculate_inventory_load(player_inventory: list) -> Tuple[float, float]:
|
||||
"""Calculates the total weight and volume of a player's inventory."""
|
||||
total_weight = 0.0
|
||||
total_volume = 0.0
|
||||
for item in player_inventory:
|
||||
item_def = ITEMS.get(item["item_id"])
|
||||
if item_def:
|
||||
total_weight += item_def["weight"] * item["quantity"]
|
||||
total_volume += item_def["volume"] * item["quantity"]
|
||||
return round(total_weight, 2), round(total_volume, 2)
|
||||
|
||||
def get_player_capacity(player_inventory: list, player_stats: dict) -> Tuple[float, float]:
|
||||
"""Calculates the total carrying capacity of a player."""
|
||||
base_weight_cap = player_stats['strength'] * 5 # Example formula
|
||||
base_volume_cap = player_stats['strength'] * 2 # Example formula
|
||||
|
||||
for item in player_inventory:
|
||||
if item["is_equipped"]:
|
||||
item_def = ITEMS.get(item["item_id"])
|
||||
if item_def and item_def.get("type") == "equipment":
|
||||
effects = item_def.get("effects", {})
|
||||
base_weight_cap += effects.get("capacity_weight", 0)
|
||||
base_volume_cap += effects.get("capacity_volume", 0)
|
||||
|
||||
return base_weight_cap, base_volume_cap
|
||||
|
||||
def resolve_action(player_stats: dict, action_obj: Action) -> Outcome:
|
||||
"""
|
||||
Resolves a player action, like searching, based on stats and luck.
|
||||
Returns the resulting Outcome object.
|
||||
"""
|
||||
# A simple success chance calculation
|
||||
base_chance = 50 + (player_stats.get('intellect', 5) * 2)
|
||||
roll = random.randint(1, 100)
|
||||
|
||||
outcome_key = "failure"
|
||||
if roll <= 5 and "critical_failure" in action_obj.outcomes:
|
||||
outcome_key = "critical_failure"
|
||||
elif roll <= base_chance and "success" in action_obj.outcomes:
|
||||
outcome_key = "success"
|
||||
|
||||
return action_obj.outcomes.get(outcome_key, action_obj.outcomes["failure"])
|
||||
|
||||
async def can_add_item_to_inventory(user_id: int, item_id: str, quantity: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if an item can be added to the player's inventory.
|
||||
Returns (can_add, reason_if_not)
|
||||
"""
|
||||
from .api_client import api_client
|
||||
|
||||
player = await api_client.get_player(user_id)
|
||||
if not player:
|
||||
return False, "Player not found."
|
||||
|
||||
inventory = await api_client.get_inventory(user_id)
|
||||
item_def = ITEMS.get(item_id)
|
||||
|
||||
if not item_def:
|
||||
return False, "Invalid item."
|
||||
|
||||
# Calculate current and projected weight/volume
|
||||
current_weight, current_volume = calculate_inventory_load(inventory)
|
||||
max_weight, max_volume = get_player_capacity(inventory, player)
|
||||
|
||||
item_weight = item_def["weight"] * quantity
|
||||
item_volume = item_def["volume"] * quantity
|
||||
|
||||
new_weight = current_weight + item_weight
|
||||
new_volume = current_volume + item_volume
|
||||
|
||||
if new_weight > max_weight:
|
||||
return False, f"Too heavy! ({new_weight:.1f}/{max_weight:.1f} kg)"
|
||||
|
||||
if new_volume > max_volume:
|
||||
return False, f"Not enough space! ({new_volume:.1f}/{max_volume:.1f} vol)"
|
||||
|
||||
return True, ""
|
||||
|
||||
def calculate_travel_stamina_cost(player: dict, inventory: list, from_location, to_location) -> int:
|
||||
"""
|
||||
Calculate stamina cost for traveling between locations.
|
||||
Based on distance, endurance (reduces cost), and carried weight (increases cost).
|
||||
|
||||
Args:
|
||||
player: Player stats dictionary
|
||||
inventory: Player's inventory list
|
||||
from_location: Location object being traveled from
|
||||
to_location: Location object being traveled to
|
||||
"""
|
||||
from data.travel_helpers import calculate_base_stamina_cost
|
||||
|
||||
# Get base cost from shared helper (used by map and game)
|
||||
distance_cost = calculate_base_stamina_cost(from_location, to_location)
|
||||
|
||||
# Endurance reduces cost (each point reduces by 0.5)
|
||||
endurance_reduction = player['endurance'] * 0.5
|
||||
|
||||
# Calculate weight burden
|
||||
current_weight, _ = calculate_inventory_load(inventory)
|
||||
max_weight, _ = get_player_capacity(inventory, player)
|
||||
|
||||
# Weight penalty: if carrying more than 50% capacity, add extra cost
|
||||
weight_ratio = current_weight / max_weight if max_weight > 0 else 0
|
||||
weight_penalty = 0
|
||||
|
||||
if weight_ratio > 0.5:
|
||||
# Each 10% over 50% adds 1 stamina
|
||||
weight_penalty = int((weight_ratio - 0.5) * 10)
|
||||
|
||||
# Calculate final cost (minimum 3)
|
||||
final_cost = max(3, int(distance_cost - endurance_reduction + weight_penalty))
|
||||
|
||||
return final_cost
|
||||
@@ -1,121 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Message utility functions for sending and editing Telegram messages.
|
||||
Handles image caching, smooth transitions, and message editing logic.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from telegram import InlineKeyboardMarkup, InputMediaPhoto
|
||||
from telegram.error import BadRequest
|
||||
from .api_client import api_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_or_edit_with_image(query, text: str, reply_markup: InlineKeyboardMarkup,
|
||||
image_path: str = None, parse_mode: str = 'HTML'):
|
||||
"""
|
||||
Send a message with an image (as caption) or edit existing message.
|
||||
Uses edit_message_media for smooth transitions when changing images.
|
||||
|
||||
Args:
|
||||
query: The callback query object
|
||||
text: Message text/caption
|
||||
reply_markup: Inline keyboard markup
|
||||
image_path: Optional path to image file
|
||||
parse_mode: Parse mode for text (default 'HTML')
|
||||
"""
|
||||
current_message = query.message
|
||||
has_photo = bool(current_message.photo)
|
||||
|
||||
if image_path:
|
||||
# Get or upload image
|
||||
cached_file_id = await api_client.get_cached_image(image_path)
|
||||
|
||||
if not cached_file_id and os.path.exists(image_path):
|
||||
# Upload new image
|
||||
try:
|
||||
with open(image_path, 'rb') as img_file:
|
||||
temp_msg = await current_message.reply_photo(
|
||||
photo=img_file,
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
if temp_msg.photo:
|
||||
cached_file_id = temp_msg.photo[-1].file_id
|
||||
await api_client.cache_image(image_path, cached_file_id)
|
||||
# Delete old message to keep chat clean
|
||||
try:
|
||||
await current_message.delete()
|
||||
except:
|
||||
pass
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Error uploading image: {e}")
|
||||
cached_file_id = None
|
||||
|
||||
if cached_file_id:
|
||||
# Check if current message has same photo
|
||||
if has_photo:
|
||||
current_file_id = current_message.photo[-1].file_id
|
||||
if current_file_id == cached_file_id:
|
||||
# Same image, just edit caption
|
||||
try:
|
||||
await query.edit_message_caption(
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
return
|
||||
except BadRequest as e:
|
||||
if "Message is not modified" in str(e):
|
||||
return
|
||||
else:
|
||||
# Different image - use edit_message_media for smooth transition
|
||||
try:
|
||||
media = InputMediaPhoto(
|
||||
media=cached_file_id,
|
||||
caption=text,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
await query.edit_message_media(
|
||||
media=media,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing message media: {e}")
|
||||
|
||||
# Current message has no photo - need to delete and send new
|
||||
if not has_photo:
|
||||
try:
|
||||
await current_message.delete()
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
await current_message.reply_photo(
|
||||
photo=cached_file_id,
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending cached image: {e}")
|
||||
else:
|
||||
# No image requested
|
||||
if has_photo:
|
||||
# Current message has photo, need to delete and send text-only
|
||||
try:
|
||||
await current_message.delete()
|
||||
except:
|
||||
pass
|
||||
await current_message.reply_html(text=text, reply_markup=reply_markup)
|
||||
else:
|
||||
# Both text-only, just edit
|
||||
try:
|
||||
await query.edit_message_text(text=text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||
except BadRequest as e:
|
||||
if "Message is not modified" not in str(e):
|
||||
await current_message.reply_html(text=text, reply_markup=reply_markup)
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
Pickup and item collection handlers.
|
||||
"""
|
||||
import logging
|
||||
from . import keyboards, logic
|
||||
from .api_client import api_client
|
||||
from data.world_loader import game_world
|
||||
from data.items import ITEMS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_pickup_menu(query, user_id: int, player: dict, data: list):
|
||||
"""Show pickup options for a dropped item."""
|
||||
dropped_item_id = int(data[1])
|
||||
item_to_pickup = await api_client.get_dropped_item(dropped_item_id)
|
||||
|
||||
if not item_to_pickup:
|
||||
await query.answer("Someone already picked that up!", show_alert=False)
|
||||
location_id = player['location_id']
|
||||
location = game_world.get_location(location_id)
|
||||
dropped_items = await api_client.get_dropped_items_in_location(location_id)
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(location_id)
|
||||
keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies)
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="You scan the area. You notice...",
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
return
|
||||
|
||||
item_def = ITEMS.get(item_to_pickup['item_id'], {})
|
||||
emoji = item_def.get('emoji', '❔')
|
||||
text = f"{emoji} <b>{item_def.get('name', 'Unknown')}</b>\n\n"
|
||||
text += f"Available: {item_to_pickup['quantity']}\n"
|
||||
text += f"Weight: {item_def.get('weight', 0)} kg each\n"
|
||||
text += f"Volume: {item_def.get('volume', 0)} vol each\n\n"
|
||||
text += "How many do you want to pick up?"
|
||||
|
||||
await query.answer()
|
||||
keyboard = keyboards.pickup_options_keyboard(
|
||||
dropped_item_id,
|
||||
item_def.get('name', 'Unknown'),
|
||||
item_to_pickup['quantity']
|
||||
)
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
|
||||
|
||||
async def handle_pickup(query, user_id: int, player: dict, data: list):
|
||||
"""Pick up a dropped item from the world."""
|
||||
dropped_item_id = int(data[1])
|
||||
pickup_amount_str = data[2] if len(data) > 2 else "all"
|
||||
|
||||
item_to_pickup = await api_client.get_dropped_item(dropped_item_id)
|
||||
if not item_to_pickup:
|
||||
await query.answer("Someone already picked that up!", show_alert=False)
|
||||
location_id = player['location_id']
|
||||
location = game_world.get_location(location_id)
|
||||
dropped_items = await api_client.get_dropped_items_in_location(location_id)
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(location_id)
|
||||
keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies)
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="You scan the area. You notice...",
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
return
|
||||
|
||||
# Determine how much to pick up
|
||||
if pickup_amount_str == "all":
|
||||
pickup_amount = item_to_pickup['quantity']
|
||||
else:
|
||||
pickup_amount = min(int(pickup_amount_str), item_to_pickup['quantity'])
|
||||
|
||||
# Check inventory capacity
|
||||
can_add, reason = await logic.can_add_item_to_inventory(
|
||||
user_id, item_to_pickup['item_id'], pickup_amount
|
||||
)
|
||||
|
||||
if not can_add:
|
||||
await query.answer(reason, show_alert=True)
|
||||
return
|
||||
|
||||
# Add to inventory
|
||||
await api_client.add_item_to_inventory(user_id, item_to_pickup['item_id'], pickup_amount)
|
||||
|
||||
# Update or remove dropped item
|
||||
remaining = item_to_pickup['quantity'] - pickup_amount
|
||||
item_def = ITEMS.get(item_to_pickup['item_id'], {})
|
||||
|
||||
if remaining > 0:
|
||||
await api_client.update_dropped_item(dropped_item_id, remaining)
|
||||
await query.answer(
|
||||
f"Picked up {pickup_amount}x {item_def.get('name', 'item')}. {remaining} remaining.",
|
||||
show_alert=False
|
||||
)
|
||||
else:
|
||||
await api_client.remove_dropped_item(dropped_item_id)
|
||||
await query.answer(
|
||||
f"Picked up {pickup_amount}x {item_def.get('name', 'item')}.",
|
||||
show_alert=False
|
||||
)
|
||||
|
||||
# Return to inspect area
|
||||
location_id = player['location_id']
|
||||
location = game_world.get_location(location_id)
|
||||
dropped_items = await api_client.get_dropped_items_in_location(location_id)
|
||||
wandering_enemies = await api_client.get_wandering_enemies_in_location(location_id)
|
||||
keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies)
|
||||
image_path = location.image_path if location else None
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text="You scan the area. You notice...",
|
||||
reply_markup=keyboard,
|
||||
image_path=image_path
|
||||
)
|
||||
@@ -1,169 +0,0 @@
|
||||
"""
|
||||
Profile and character stat management handlers.
|
||||
"""
|
||||
import logging
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from . import keyboards
|
||||
from data.world_loader import game_world
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_profile(query, user_id: int, player: dict, data: list = None):
|
||||
"""Display player profile with stats and level info."""
|
||||
from .utils import format_stat_bar
|
||||
await query.answer()
|
||||
from bot import combat
|
||||
from .utils import format_stat_bar, create_progress_bar
|
||||
|
||||
# Calculate stats
|
||||
xp_current = player['xp']
|
||||
xp_needed = combat.xp_for_level(player['level'] + 1)
|
||||
xp_for_current_level = combat.xp_for_level(player['level'])
|
||||
xp_progress = max(0, xp_current - xp_for_current_level)
|
||||
xp_level_requirement = xp_needed - xp_for_current_level
|
||||
progress_percent = int((xp_progress / xp_level_requirement) * 100) if xp_level_requirement > 0 else 0
|
||||
|
||||
unspent = player.get('unspent_points', 0)
|
||||
|
||||
# Build profile with visual bars
|
||||
profile_text = f"👤 <b>{player['name']}</b>\n"
|
||||
profile_text += f"━━━━━━━━━━━━━━━━━━━━\n\n"
|
||||
profile_text += f"<b>Level:</b> {player['level']}\n"
|
||||
|
||||
# XP bar
|
||||
xp_bar = create_progress_bar(xp_progress, xp_level_requirement, length=10)
|
||||
profile_text += f"⭐ XP: {xp_bar} {progress_percent}% ({xp_current}/{xp_needed})\n"
|
||||
|
||||
if unspent > 0:
|
||||
profile_text += f"💎 <b>Unspent Points:</b> {unspent}\n"
|
||||
|
||||
profile_text += f"\n{format_stat_bar('HP', '❤️', player['hp'], player['max_hp'])}\n"
|
||||
profile_text += f"{format_stat_bar('Stamina', '⚡', player['stamina'], player['max_stamina'])}\n\n"
|
||||
profile_text += f"<b>Stats:</b>\n"
|
||||
profile_text += f"💪 Strength: {player['strength']}\n"
|
||||
profile_text += f"🏃 Agility: {player['agility']}\n"
|
||||
profile_text += f"💚 Endurance: {player['endurance']}\n"
|
||||
profile_text += f"🧠 Intellect: {player['intellect']}\n\n"
|
||||
profile_text += f"<b>Combat:</b>\n"
|
||||
profile_text += f"⚔️ Base Damage: {5 + player['strength'] // 2 + player['level']}\n"
|
||||
profile_text += f"🛡️ Flee Chance: {int((0.5 + player['agility'] / 100) * 100)}%\n"
|
||||
profile_text += f"💚 Stamina Regen: {1 + player['endurance'] // 10}/cycle\n\n"
|
||||
|
||||
# Show status effects if any
|
||||
try:
|
||||
from .api_client import api_client
|
||||
status_effects = await api_client.get_player_status_effects(user_id)
|
||||
if status_effects:
|
||||
from bot.status_utils import get_status_details
|
||||
from .api_client import api_client
|
||||
# Check if player is in combat
|
||||
combat_state = await api_client.get_combat(user_id)
|
||||
in_combat = combat_state is not None
|
||||
profile_text += f"<b>Status Effects:</b>\n"
|
||||
profile_text += get_status_details(status_effects, in_combat=in_combat) + "\n\n"
|
||||
except:
|
||||
pass # Status effects not critical, skip if error
|
||||
|
||||
location = game_world.get_location(player['location_id'])
|
||||
location_image = location.image_path if location else None
|
||||
|
||||
# Add spend points button if player has unspent points
|
||||
keyboard_buttons = []
|
||||
if unspent > 0:
|
||||
keyboard_buttons.append([
|
||||
InlineKeyboardButton("⭐ Spend Stat Points", callback_data="spend_points_menu")
|
||||
])
|
||||
keyboard_buttons.append([InlineKeyboardButton("⬅️ Back", callback_data="main_menu")])
|
||||
back_keyboard = InlineKeyboardMarkup(keyboard_buttons)
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(
|
||||
query,
|
||||
text=profile_text,
|
||||
reply_markup=back_keyboard,
|
||||
image_path=location_image
|
||||
)
|
||||
|
||||
|
||||
async def handle_spend_points_menu(query, user_id: int, player: dict, data: list = None):
|
||||
"""Show menu for spending attribute points."""
|
||||
await query.answer()
|
||||
unspent = player.get('unspent_points', 0)
|
||||
|
||||
if unspent <= 0:
|
||||
await query.answer("You have no points to spend!", show_alert=False)
|
||||
return
|
||||
|
||||
text = f"⭐ <b>Spend Stat Points</b>\n\n"
|
||||
text += f"Available Points: <b>{unspent}</b>\n\n"
|
||||
text += f"Current Stats:\n"
|
||||
text += f"❤️ Max HP: {player['max_hp']} (+10 per point)\n"
|
||||
text += f"⚡ Max Stamina: {player['max_stamina']} (+5 per point)\n"
|
||||
text += f"💪 Strength: {player['strength']} (+1 per point)\n"
|
||||
text += f"🏃 Agility: {player['agility']} (+1 per point)\n"
|
||||
text += f"💚 Endurance: {player['endurance']} (+1 per point)\n"
|
||||
text += f"🧠 Intellect: {player['intellect']} (+1 per point)\n\n"
|
||||
text += f"💡 Choose wisely! Each point matters."
|
||||
|
||||
keyboard = keyboards.spend_points_keyboard()
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(query, text=text, reply_markup=keyboard)
|
||||
|
||||
|
||||
async def handle_spend_point(query, user_id: int, player: dict, data: list):
|
||||
"""Spend a stat point on a specific attribute."""
|
||||
stat_name = data[1]
|
||||
unspent = player.get('unspent_points', 0)
|
||||
|
||||
if unspent <= 0:
|
||||
await query.answer("You have no points to spend!", show_alert=False)
|
||||
return
|
||||
|
||||
# Map stat names to updates
|
||||
stat_mapping = {
|
||||
'max_hp': ('max_hp', 10, '❤️ Max HP'),
|
||||
'max_stamina': ('max_stamina', 5, '⚡ Max Stamina'),
|
||||
'strength': ('strength', 1, '💪 Strength'),
|
||||
'agility': ('agility', 1, '🏃 Agility'),
|
||||
'endurance': ('endurance', 1, '💚 Endurance'),
|
||||
'intellect': ('intellect', 1, '🧠 Intellect'),
|
||||
}
|
||||
|
||||
if stat_name not in stat_mapping:
|
||||
await query.answer("Invalid stat!", show_alert=False)
|
||||
return
|
||||
|
||||
db_field, increase, display_name = stat_mapping[stat_name]
|
||||
new_value = player[db_field] + increase
|
||||
new_unspent = unspent - 1
|
||||
|
||||
from .api_client import api_client
|
||||
await api_client.update_player(user_id, {
|
||||
db_field: new_value,
|
||||
'unspent_points': new_unspent
|
||||
})
|
||||
|
||||
# Update local player data
|
||||
player[db_field] = new_value
|
||||
player['unspent_points'] = new_unspent
|
||||
|
||||
await query.answer(f"+{increase} {display_name}!", show_alert=False)
|
||||
|
||||
# Refresh the spend points menu
|
||||
text = f"⭐ <b>Spend Stat Points</b>\n\n"
|
||||
text += f"Available Points: <b>{new_unspent}</b>\n\n"
|
||||
text += f"Current Stats:\n"
|
||||
text += f"❤️ Max HP: {player['max_hp']} (+10 per point)\n"
|
||||
text += f"⚡ Max Stamina: {player['max_stamina']} (+5 per point)\n"
|
||||
text += f"💪 Strength: {player['strength']} (+1 per point)\n"
|
||||
text += f"🏃 Agility: {player['agility']} (+1 per point)\n"
|
||||
text += f"💚 Endurance: {player['endurance']} (+1 per point)\n"
|
||||
text += f"🧠 Intellect: {player['intellect']} (+1 per point)\n\n"
|
||||
text += f"💡 Choose wisely! Each point matters."
|
||||
|
||||
keyboard = keyboards.spend_points_keyboard()
|
||||
|
||||
from .handlers import send_or_edit_with_image
|
||||
await send_or_edit_with_image(query, text=text, reply_markup=keyboard)
|
||||
@@ -1,119 +0,0 @@
|
||||
"""
|
||||
Global Wandering Enemy Spawn Manager
|
||||
Runs periodically to spawn/despawn enemies based on location danger levels.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from typing import Dict, List
|
||||
from bot import database
|
||||
from data.npcs import (
|
||||
LOCATION_SPAWNS,
|
||||
LOCATION_DANGER,
|
||||
get_random_npc_for_location,
|
||||
get_wandering_enemy_chance
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Configuration
|
||||
SPAWN_CHECK_INTERVAL = 120 # Check every 2 minutes
|
||||
ENEMY_LIFETIME = 600 # Enemies live for 10 minutes
|
||||
MAX_ENEMIES_PER_LOCATION = {
|
||||
0: 0, # Safe zones - no wandering enemies
|
||||
1: 1, # Low danger - max 1 enemy
|
||||
2: 2, # Medium danger - max 2 enemies
|
||||
3: 3, # High danger - max 3 enemies
|
||||
4: 4, # Extreme danger - max 4 enemies
|
||||
}
|
||||
|
||||
|
||||
def get_danger_level(location_id: str) -> int:
|
||||
"""Get danger level for a location."""
|
||||
danger_data = LOCATION_DANGER.get(location_id, (0, 0.0, 0.0))
|
||||
return danger_data[0]
|
||||
|
||||
|
||||
async def spawn_manager_loop():
|
||||
"""
|
||||
Main spawn manager loop.
|
||||
Runs continuously, checking spawn conditions every SPAWN_CHECK_INTERVAL seconds.
|
||||
"""
|
||||
logger.info("🎲 Spawn Manager started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(SPAWN_CHECK_INTERVAL)
|
||||
|
||||
# Clean up expired enemies first
|
||||
despawned_count = await database.cleanup_expired_wandering_enemies()
|
||||
if despawned_count > 0:
|
||||
logger.info(f"🧹 Cleaned up {despawned_count} expired wandering enemies")
|
||||
|
||||
# Process each location
|
||||
spawned_count = 0
|
||||
for location_id, spawn_table in LOCATION_SPAWNS.items():
|
||||
if not spawn_table:
|
||||
continue # Skip locations with no spawns
|
||||
|
||||
# Get danger level and max enemies for this location
|
||||
danger_level = get_danger_level(location_id)
|
||||
max_enemies = MAX_ENEMIES_PER_LOCATION.get(danger_level, 0)
|
||||
|
||||
if max_enemies == 0:
|
||||
continue # Skip safe zones
|
||||
|
||||
# Check current enemy count
|
||||
current_count = await database.get_wandering_enemy_count_in_location(location_id)
|
||||
|
||||
if current_count >= max_enemies:
|
||||
continue # Location is at capacity
|
||||
|
||||
# Calculate spawn chance based on wandering_enemy_chance
|
||||
spawn_chance = get_wandering_enemy_chance(location_id)
|
||||
|
||||
# Attempt to spawn enemies up to max capacity
|
||||
for _ in range(max_enemies - current_count):
|
||||
if random.random() < spawn_chance:
|
||||
# Spawn an enemy
|
||||
npc_id = get_random_npc_for_location(location_id)
|
||||
if npc_id:
|
||||
await database.spawn_wandering_enemy(
|
||||
npc_id=npc_id,
|
||||
location_id=location_id,
|
||||
lifetime_seconds=ENEMY_LIFETIME
|
||||
)
|
||||
spawned_count += 1
|
||||
logger.info(f"👹 Spawned {npc_id} at {location_id} (current: {current_count + 1}/{max_enemies})")
|
||||
|
||||
if spawned_count > 0:
|
||||
logger.info(f"✨ Spawn cycle complete: {spawned_count} enemies spawned")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in spawn manager loop: {e}", exc_info=True)
|
||||
# Continue running even if there's an error
|
||||
await asyncio.sleep(10)
|
||||
|
||||
|
||||
async def start_spawn_manager():
|
||||
"""Start the spawn manager as a background task."""
|
||||
asyncio.create_task(spawn_manager_loop())
|
||||
logger.info("🎮 Spawn Manager initialized")
|
||||
|
||||
|
||||
async def get_spawn_stats() -> Dict:
|
||||
"""Get statistics about current spawns (for debugging/monitoring)."""
|
||||
all_enemies = await database.get_all_active_wandering_enemies()
|
||||
|
||||
# Count by location
|
||||
location_counts = {}
|
||||
for enemy in all_enemies:
|
||||
loc = enemy['location_id']
|
||||
location_counts[loc] = location_counts.get(loc, 0) + 1
|
||||
|
||||
return {
|
||||
"total_active": len(all_enemies),
|
||||
"by_location": location_counts,
|
||||
"enemies": all_enemies
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"""
|
||||
Status effect utilities for display and management.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
def stack_status_effects(effects: list) -> dict:
|
||||
"""
|
||||
Stack status effects by name, summing damage and counting stacks.
|
||||
|
||||
Args:
|
||||
effects: List of dicts with keys: effect_name, effect_icon, damage_per_tick, ticks_remaining
|
||||
|
||||
Returns:
|
||||
Dict with keys: effect_name -> {icon, total_damage, stacks, min_ticks, effects: [list of effect dicts]}
|
||||
"""
|
||||
stacked = defaultdict(lambda: {
|
||||
'icon': '',
|
||||
'total_damage': 0,
|
||||
'stacks': 0,
|
||||
'min_ticks': float('inf'),
|
||||
'max_ticks': 0,
|
||||
'effects': []
|
||||
})
|
||||
|
||||
for effect in effects:
|
||||
name = effect['effect_name']
|
||||
stacked[name]['icon'] = effect['effect_icon']
|
||||
stacked[name]['total_damage'] += effect.get('damage_per_tick', 0)
|
||||
stacked[name]['stacks'] += 1
|
||||
stacked[name]['min_ticks'] = min(stacked[name]['min_ticks'], effect['ticks_remaining'])
|
||||
stacked[name]['max_ticks'] = max(stacked[name]['max_ticks'], effect['ticks_remaining'])
|
||||
stacked[name]['effects'].append(effect)
|
||||
|
||||
return dict(stacked)
|
||||
|
||||
|
||||
def get_status_summary(effects: list, in_combat: bool = False) -> str:
|
||||
"""
|
||||
Generate compact status summary for display in menus.
|
||||
|
||||
Args:
|
||||
effects: List of status effect dicts
|
||||
in_combat: If True, show "turns" instead of "cycles"
|
||||
|
||||
Returns:
|
||||
String like "Statuses: 🩸 (-4), ☣️ (-3)" or empty string if no effects
|
||||
"""
|
||||
if not effects:
|
||||
return ""
|
||||
|
||||
stacked = stack_status_effects(effects)
|
||||
|
||||
if not stacked:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
for name, data in stacked.items():
|
||||
if data['total_damage'] > 0:
|
||||
parts.append(f"{data['icon']} (-{data['total_damage']})")
|
||||
else:
|
||||
parts.append(f"{data['icon']}")
|
||||
|
||||
return "Statuses: " + ", ".join(parts)
|
||||
|
||||
|
||||
def get_status_details(effects: list, in_combat: bool = False) -> str:
|
||||
"""
|
||||
Generate detailed status display for profile menu.
|
||||
|
||||
Args:
|
||||
effects: List of status effect dicts
|
||||
in_combat: If True, show "turns" instead of "cycles"
|
||||
|
||||
Returns:
|
||||
Multi-line string with detailed effect info
|
||||
"""
|
||||
if not effects:
|
||||
return "No active status effects."
|
||||
|
||||
stacked = stack_status_effects(effects)
|
||||
|
||||
lines = []
|
||||
for name, data in stacked.items():
|
||||
# Build effect line
|
||||
effect_line = f"{data['icon']} {name.replace('_', ' ').title()}"
|
||||
|
||||
# Add damage info
|
||||
if data['total_damage'] > 0:
|
||||
effect_line += f": -{data['total_damage']} HP/{'turn' if in_combat else 'cycle'}"
|
||||
|
||||
# Add tick info
|
||||
if data['stacks'] == 1:
|
||||
tick_unit = 'turn' if in_combat else 'cycle'
|
||||
tick_count = data['min_ticks']
|
||||
effect_line += f" ({tick_count} {tick_unit}{'s' if tick_count != 1 else ''} left)"
|
||||
else:
|
||||
tick_unit = 'turns' if in_combat else 'cycles'
|
||||
if data['min_ticks'] == data['max_ticks']:
|
||||
effect_line += f" (×{data['stacks']}, {data['min_ticks']} {tick_unit} left)"
|
||||
else:
|
||||
effect_line += f" (×{data['stacks']}, {data['min_ticks']}-{data['max_ticks']} {tick_unit} left)"
|
||||
|
||||
lines.append(effect_line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def calculate_status_damage(effects: list) -> int:
|
||||
"""
|
||||
Calculate total damage from all status effects.
|
||||
|
||||
Args:
|
||||
effects: List of status effect dicts
|
||||
|
||||
Returns:
|
||||
Total damage per tick
|
||||
"""
|
||||
return sum(effect.get('damage_per_tick', 0) for effect in effects)
|
||||
128
bot/utils.py
@@ -1,128 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Utility functions and decorators for the bot.
|
||||
"""
|
||||
import os
|
||||
import functools
|
||||
import logging
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_progress_bar(current: int, maximum: int, length: int = 10, filled_char: str = "█", empty_char: str = "░") -> str:
|
||||
"""
|
||||
Create a visual progress bar.
|
||||
|
||||
Args:
|
||||
current: Current value
|
||||
maximum: Maximum value
|
||||
length: Length of the bar in characters (default 10)
|
||||
filled_char: Character for filled portion (default █)
|
||||
empty_char: Character for empty portion (default ░)
|
||||
|
||||
Returns:
|
||||
String representation of progress bar
|
||||
|
||||
Examples:
|
||||
>>> create_progress_bar(75, 100)
|
||||
"███████░░░"
|
||||
>>> create_progress_bar(0, 100)
|
||||
"░░░░░░░░░░"
|
||||
>>> create_progress_bar(100, 100)
|
||||
"██████████"
|
||||
"""
|
||||
if maximum <= 0:
|
||||
return empty_char * length
|
||||
|
||||
percentage = min(1.0, max(0.0, current / maximum))
|
||||
filled_length = int(length * percentage)
|
||||
empty_length = length - filled_length
|
||||
|
||||
return filled_char * filled_length + empty_char * empty_length
|
||||
|
||||
|
||||
def format_stat_bar(label: str, emoji: str, current: int, maximum: int, bar_length: int = 10, label_width: int = 7) -> str:
|
||||
"""
|
||||
Format a stat (HP, Stamina, etc.) with visual progress bar.
|
||||
Uses right-aligned label format to avoid alignment issues with Telegram's proportional font.
|
||||
|
||||
Args:
|
||||
label: Stat label (e.g., "HP", "Stamina", "Your HP")
|
||||
emoji: Emoji to display (e.g., "❤️", "⚡", "🐕")
|
||||
current: Current value
|
||||
maximum: Maximum value
|
||||
bar_length: Length of the progress bar
|
||||
label_width: Not used, kept for backwards compatibility
|
||||
|
||||
Returns:
|
||||
Formatted string with bar on left, label on right
|
||||
|
||||
Examples:
|
||||
>>> format_stat_bar("HP", "❤️", 75, 100)
|
||||
"███████░░░ 75% (75/100) ❤️ HP"
|
||||
>>> format_stat_bar("Stamina", "⚡", 50, 100)
|
||||
"█████░░░░░ 50% (50/100) ⚡ Stamina"
|
||||
"""
|
||||
bar = create_progress_bar(current, maximum, bar_length)
|
||||
percentage = int((current / maximum * 100)) if maximum > 0 else 0
|
||||
|
||||
# Right-aligned format: bar first, then stats, then emoji + label
|
||||
# This way bars are always left-aligned regardless of label length
|
||||
if emoji:
|
||||
return f"{bar} {percentage}% ({current}/{maximum}) {emoji} {label}"
|
||||
else:
|
||||
# If no emoji provided, just use label
|
||||
return f"{bar} {percentage}% ({current}/{maximum}) {label}"
|
||||
|
||||
|
||||
|
||||
def get_admin_ids():
|
||||
"""Get the list of admin user IDs from environment variable."""
|
||||
admin_ids_str = os.getenv("ADMIN_IDS", "")
|
||||
if not admin_ids_str:
|
||||
logger.warning("ADMIN_IDS not set in .env file. No admins configured.")
|
||||
return set()
|
||||
|
||||
try:
|
||||
# Parse comma-separated list of IDs
|
||||
admin_ids = set(int(id.strip()) for id in admin_ids_str.split(",") if id.strip())
|
||||
return admin_ids
|
||||
except ValueError as e:
|
||||
logger.error(f"Error parsing ADMIN_IDS: {e}")
|
||||
return set()
|
||||
|
||||
|
||||
def admin_only(func):
|
||||
"""
|
||||
Decorator that restricts command to admin users only.
|
||||
|
||||
Usage:
|
||||
@admin_only
|
||||
async def my_admin_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
...
|
||||
"""
|
||||
@functools.wraps(func)
|
||||
async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs):
|
||||
user_id = update.effective_user.id
|
||||
admin_ids = get_admin_ids()
|
||||
|
||||
if user_id not in admin_ids:
|
||||
await update.message.reply_html(
|
||||
"🚫 <b>Access Denied</b>\n\n"
|
||||
"This command is restricted to administrators only."
|
||||
)
|
||||
logger.warning(f"User {user_id} attempted to use admin command: {func.__name__}")
|
||||
return
|
||||
|
||||
# User is admin, execute the command
|
||||
return await func(update, context, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Check if a user ID is an admin."""
|
||||
admin_ids = get_admin_ids()
|
||||
return user_id in admin_ids
|
||||
1
build.sh
Executable file
@@ -0,0 +1 @@
|
||||
docker compose build echoes_of_the_ashes_api echoes_of_the_ashes_pwa && docker compose up -d echoes_of_the_ashes_api echoes_of_the_ashes_pwa echoes_of_the_ashes_api
|
||||
88
count_sloc.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
def count_lines():
|
||||
try:
|
||||
# Get list of tracked files
|
||||
result = subprocess.run(['git', 'ls-files'], capture_output=True, text=True, check=True)
|
||||
files = result.stdout.splitlines()
|
||||
except subprocess.CalledProcessError:
|
||||
print("Not a git repository or git error.")
|
||||
return
|
||||
|
||||
stats = {}
|
||||
total_effective = 0
|
||||
total_files = 0
|
||||
|
||||
comments = {
|
||||
'.py': '#',
|
||||
'.js': '//',
|
||||
'.jsx': '//',
|
||||
'.ts': '//',
|
||||
'.tsx': '//',
|
||||
'.css': '/*', # Simple check, not perfect for block comments across lines or inline
|
||||
'.html': '<!--',
|
||||
'.json': None, # JSON doesn't standardized comments, but we count lines
|
||||
'.yml': '#',
|
||||
'.yaml': '#',
|
||||
'.sh': '#',
|
||||
'.md': None
|
||||
}
|
||||
|
||||
ignored_dirs = ['old', 'migrations', 'images', 'claude_sonnet_logs', 'data', 'gamedata/items.json'] # items.json can be huge
|
||||
|
||||
for file_path in files:
|
||||
if any(part in file_path.split('/') for part in ignored_dirs):
|
||||
continue
|
||||
|
||||
# Determine extension
|
||||
_, ext = os.path.splitext(file_path)
|
||||
if ext not in comments and ext not in ['.json', '.md']:
|
||||
# Skip unknown extensions or binary files if not handled
|
||||
# But let's verify if text
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
lines = f.readlines()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
effective_lines = 0
|
||||
file_total = 0
|
||||
|
||||
comment_char = comments.get(ext)
|
||||
|
||||
for line in lines:
|
||||
line_strip = line.strip()
|
||||
if not line_strip:
|
||||
continue
|
||||
|
||||
file_total += 1
|
||||
|
||||
if comment_char:
|
||||
if line_strip.startswith(comment_char):
|
||||
continue
|
||||
# Special handling for CSS/HTML block comments would be needed for perfect accuracy
|
||||
# keeping it simple: if it starts with comment char, ignore.
|
||||
|
||||
effective_lines += 1
|
||||
|
||||
if ext not in stats:
|
||||
stats[ext] = {'files': 0, 'lines': 0}
|
||||
|
||||
stats[ext]['files'] += 1
|
||||
stats[ext]['lines'] += effective_lines
|
||||
total_effective += effective_lines
|
||||
total_files += 1
|
||||
|
||||
print(f"{'Language':<15} {'Files':<10} {'Effective Lines':<15}")
|
||||
print("-" * 40)
|
||||
for ext, data in sorted(stats.items(), key=lambda x: x[1]['lines'], reverse=True):
|
||||
lang = ext if ext else "No Ext"
|
||||
print(f"{lang:<15} {data['files']:<10} {data['lines']:<15}")
|
||||
print("-" * 40)
|
||||
print(f"{'Total':<15} {total_files:<10} {total_effective:<15}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
count_lines()
|
||||
@@ -118,6 +118,8 @@ services:
|
||||
volumes:
|
||||
- ./gamedata:/app/gamedata:ro
|
||||
- ./images:/app/images:ro
|
||||
- ./api:/app/api:rw
|
||||
- ./data:/app/data:rw
|
||||
depends_on:
|
||||
- echoes_of_the_ashes_db
|
||||
- echoes_of_the_ashes_redis
|
||||
|
||||
627
docs/archive/README_old.md
Normal file
@@ -0,0 +1,627 @@
|
||||
# Echoes of the Ash 🌆
|
||||
|
||||
A dark fantasy post-apocalyptic survival RPG featuring exploration, combat, crafting, and scavenging in a ruined world.
|
||||
|
||||
## 🎮 Game Features
|
||||
|
||||
### Core Gameplay
|
||||
|
||||
#### 🗺️ Exploration & Movement
|
||||
- **Grid-based world navigation** with coordinates (x, y)
|
||||
- **Stamina-based movement system** - each move costs stamina based on distance
|
||||
- **Multiple biomes and locations** with varying danger levels (0-4)
|
||||
- **Dynamic location discovery** as you explore
|
||||
- **Compass-based directional movement** (North, South, East, West)
|
||||
|
||||
#### ⚔️ Combat System
|
||||
- **Turn-based combat** with real-time intent preview
|
||||
- **NPC enemy encounters** with weighted spawn tables per location
|
||||
- **Status effects system**: Bleeding, Infected, Radiation
|
||||
- **Weapon effects**: Bleeding, Stun, Armor Break
|
||||
- **Flee mechanics** - escape combat with success/failure chance
|
||||
- **XP and leveling system** - gain XP from defeating enemies
|
||||
- **PvP (Player vs Player) combat** - challenge other players
|
||||
- **Death and respawn mechanics**
|
||||
|
||||
#### 🎒 Inventory & Equipment
|
||||
- **Weight and volume-based inventory** system
|
||||
- **Equipment slots**: Weapon, Backpack, Armor, Head, Tool
|
||||
- **Durability system** - items degrade with use
|
||||
- **Item tiers** (1-3) affecting quality and stats
|
||||
- **Encumbrance system** - affects stamina costs
|
||||
- **Ground item drops** - pick up and drop items
|
||||
|
||||
#### 🔨 Crafting & Repair
|
||||
- **Crafting system** with material requirements
|
||||
- **Tool requirements** for certain recipes
|
||||
- **Repair mechanics** - restore item durability
|
||||
- **Uncrafting/Disassembly** - break down items for materials
|
||||
- **Workbench locations** for advanced crafting
|
||||
- **Craft level requirements** - unlocked through progression
|
||||
|
||||
#### 🔍 Scavenging & Interactables
|
||||
- **Searchable objects** in each location (dumpsters, cars, houses, etc.)
|
||||
- **Action-based interaction** system with stamina costs
|
||||
- **Success/failure mechanics** with critical outcomes
|
||||
- **Loot tables** with item drop chances
|
||||
- **One-time and respawning interactables**
|
||||
- **Status tracking** per player (already looted, depleted, etc.)
|
||||
|
||||
#### 📊 Character Progression
|
||||
- **Level system** (1-50+) with XP requirements
|
||||
- **Stat points** - allocate to Strength, Defense, Stamina
|
||||
- **Character customization** on creation
|
||||
- **Skill progression** tied to crafting levels
|
||||
|
||||
#### 🌍 World Features
|
||||
- **Multi-location world** (Downtown, Gas Station, Residential, Clinic, Plaza, Park, Warehouse, Office Buildings, Subway, etc.)
|
||||
- **Location tags** - workbench, repair_station, safe_zone
|
||||
- **Danger zones** with varying encounter rates
|
||||
- **Location-specific loot** and enemy spawns
|
||||
|
||||
#### 💬 Social & Multiplayer
|
||||
- **Online player tracking** via WebSockets
|
||||
- **Real-time player position updates**
|
||||
- **PvP combat system** with challenge mechanics
|
||||
- **Character browsing** - see other players' stats
|
||||
|
||||
#### 🎨 PWA Features
|
||||
- **Progressive Web App** - installable on mobile/desktop
|
||||
- **Multi-language support** (English, Spanish)
|
||||
- **Responsive UI** with mobile-first design
|
||||
- **Real-time updates** via WebSockets
|
||||
- **Offline capabilities** (service worker)
|
||||
|
||||
---
|
||||
|
||||
## 📁 Gamedata Structure
|
||||
|
||||
The game uses JSON files in the `gamedata/` directory to define all game content. This modular approach makes it easy to add new content without code changes.
|
||||
|
||||
### Directory Layout
|
||||
|
||||
```
|
||||
gamedata/
|
||||
├── npcs.json # Enemy NPCs and combat encounters
|
||||
├── items.json # All items, weapons, consumables, and resources
|
||||
├── locations.json # World map locations and interactables
|
||||
└── interactables.json # Interactable object templates
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 `npcs.json` Structure
|
||||
|
||||
Defines all enemy NPCs, their stats, loot tables, and spawn locations.
|
||||
|
||||
### Top-Level Structure
|
||||
```json
|
||||
{
|
||||
"npcs": { ... }, // NPC definitions
|
||||
"danger_levels": { ... }, // Danger settings per location
|
||||
"spawn_tables": { ... } // Enemy spawn weights per location
|
||||
}
|
||||
```
|
||||
|
||||
### NPC Definition
|
||||
```json
|
||||
"npc_id": {
|
||||
"npc_id": "unique_npc_identifier",
|
||||
"name": {
|
||||
"en": "English Name",
|
||||
"es": "Spanish Name"
|
||||
},
|
||||
"description": {
|
||||
"en": "English description",
|
||||
"es": "Spanish description"
|
||||
},
|
||||
"emoji": "🐕",
|
||||
"hp_min": 15, // Minimum HP when spawned
|
||||
"hp_max": 25, // Maximum HP when spawned
|
||||
"damage_min": 3, // Minimum attack damage
|
||||
"damage_max": 7, // Maximum attack damage
|
||||
"defense": 0, // Damage reduction
|
||||
"xp_reward": 10, // XP given on defeat
|
||||
"loot_table": [ // Items dropped on death (automatic)
|
||||
{
|
||||
"item_id": "raw_meat",
|
||||
"quantity_min": 1,
|
||||
"quantity_max": 2,
|
||||
"drop_chance": 0.6 // 60% chance to drop
|
||||
}
|
||||
],
|
||||
"corpse_loot": [ // Items harvestable from corpse
|
||||
{
|
||||
"item_id": "animal_hide",
|
||||
"quantity_min": 1,
|
||||
"quantity_max": 1,
|
||||
"required_tool": "knife" // Tool needed to harvest (null = no requirement)
|
||||
}
|
||||
],
|
||||
"flee_chance": 0.3, // Chance NPC flees from combat
|
||||
"status_inflict_chance": 0.15, // Chance to inflict status effect on hit
|
||||
"image_path": "images/npcs/feral_dog.webp",
|
||||
"death_message": "The feral dog whimpers and collapses..."
|
||||
}
|
||||
```
|
||||
|
||||
### Danger Levels
|
||||
```json
|
||||
"location_id": {
|
||||
"danger_level": 2, // 0-4 scale
|
||||
"encounter_rate": 0.2, // 20% chance per movement
|
||||
"wandering_chance": 0.35 // 35% chance for random encounter while idle
|
||||
}
|
||||
```
|
||||
|
||||
### Spawn Tables
|
||||
```json
|
||||
"location_id": [
|
||||
{
|
||||
"npc_id": "raider_scout",
|
||||
"weight": 50 // Weighted random spawn (higher = more common)
|
||||
},
|
||||
{
|
||||
"npc_id": "infected_human",
|
||||
"weight": 30
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Available NPCs:**
|
||||
- `feral_dog` - Wild, hungry canine (Tier 1)
|
||||
- `mutant_rat` - Radiation-mutated rodent (Tier 1)
|
||||
- `raider_scout` - Hostile human raider (Tier 2)
|
||||
- `scavenger` - Aggressive survivor (Tier 2)
|
||||
- `infected_human` - Virus-infected zombie-like human (Tier 3)
|
||||
|
||||
---
|
||||
|
||||
## 🎒 `items.json` Structure
|
||||
|
||||
Defines all items, equipment, weapons, consumables, and crafting materials.
|
||||
|
||||
### Item Categories (Types)
|
||||
- `resource` - Raw materials for crafting
|
||||
- `consumable` - Food, medicine, usable items
|
||||
- `weapon` - Melee and ranged weapons
|
||||
- `backpack` - Inventory capacity upgrades
|
||||
- `armor` - Protective equipment
|
||||
- `tool` - Utility items (flashlight, etc.)
|
||||
- `quest` - Story/quest items
|
||||
|
||||
### Basic Item Structure
|
||||
```json
|
||||
"item_id": {
|
||||
"name": {
|
||||
"en": "Item Name",
|
||||
"es": "Spanish Name"
|
||||
},
|
||||
"description": {
|
||||
"en": "Description text",
|
||||
"es": "Spanish description"
|
||||
},
|
||||
"type": "resource",
|
||||
"weight": 0.5, // Kilograms
|
||||
"volume": 0.2, // Liters
|
||||
"emoji": "⚙️",
|
||||
"image_path": "images/items/scrap_metal.webp"
|
||||
}
|
||||
```
|
||||
|
||||
### Consumable Items
|
||||
```json
|
||||
"item_id": {
|
||||
...basic fields...,
|
||||
"type": "consumable",
|
||||
"hp_restore": 20, // Health restored
|
||||
"stamina_restore": 10, // Stamina restored
|
||||
"treats": "Bleeding" // Status effect cured (optional)
|
||||
}
|
||||
```
|
||||
|
||||
### Weapon/Equipment Items
|
||||
```json
|
||||
"item_id": {
|
||||
...basic fields...,
|
||||
"type": "weapon",
|
||||
"equippable": true,
|
||||
"slot": "weapon", // Equipment slot: weapon, backpack, armor, head, tool
|
||||
"durability": 100, // Max durability
|
||||
"tier": 2, // 1-3 quality tier
|
||||
"encumbrance": 2, // Stamina penalty when equipped
|
||||
"stats": {
|
||||
"damage_min": 5,
|
||||
"damage_max": 10,
|
||||
"weight_capacity": 20, // For backpacks
|
||||
"volume_capacity": 20,
|
||||
"defense": 5 // For armor
|
||||
},
|
||||
"weapon_effects": { // Status effects inflicted (optional)
|
||||
"bleeding": {
|
||||
"chance": 0.15, // 15% chance on hit
|
||||
"damage": 2, // Damage per turn
|
||||
"duration": 3 // Turns
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Craftable Items
|
||||
```json
|
||||
"item_id": {
|
||||
...other fields...,
|
||||
"craftable": true,
|
||||
"craft_level": 2, // Required crafting level
|
||||
"craft_materials": [
|
||||
{
|
||||
"item_id": "scrap_metal",
|
||||
"quantity": 3
|
||||
}
|
||||
],
|
||||
"craft_tools": [ // Tools consumed during crafting
|
||||
{
|
||||
"item_id": "hammer",
|
||||
"durability_cost": 3 // Durability consumed
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Repairable Items
|
||||
```json
|
||||
"item_id": {
|
||||
...other fields...,
|
||||
"repairable": true,
|
||||
"repair_materials": [
|
||||
{
|
||||
"item_id": "scrap_metal",
|
||||
"quantity": 2
|
||||
}
|
||||
],
|
||||
"repair_tools": [
|
||||
{
|
||||
"item_id": "hammer",
|
||||
"durability_cost": 2
|
||||
}
|
||||
],
|
||||
"repair_percentage": 30 // % of max durability restored
|
||||
}
|
||||
```
|
||||
|
||||
### Uncraftable Items (Disassembly)
|
||||
```json
|
||||
"item_id": {
|
||||
...other fields...,
|
||||
"uncraftable": true,
|
||||
"uncraft_yield": [ // Materials returned
|
||||
{
|
||||
"item_id": "scrap_metal",
|
||||
"quantity": 2
|
||||
}
|
||||
],
|
||||
"uncraft_loss_chance": 0.25, // 25% chance to lose materials
|
||||
"uncraft_tools": [
|
||||
{
|
||||
"item_id": "hammer",
|
||||
"durability_cost": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Item Examples:**
|
||||
- **Resources:** `scrap_metal`, `cloth_scraps`, `wood_planks`, `bone`, `raw_meat`
|
||||
- **Consumables:** `canned_food`, `water_bottle`, `bandage`, `antibiotics`, `rad_pills`
|
||||
- **Weapons:** `rusty_knife`, `knife`, `tire_iron`, `makeshift_spear`, `reinforced_bat`
|
||||
- **Backpacks:** `tattered_rucksack`, `hiking_backpack`
|
||||
- **Tools:** `flashlight`, `hammer`
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ `locations.json` Structure
|
||||
|
||||
Defines the game world, all locations, coordinates, and interactable objects.
|
||||
|
||||
### Location Definition
|
||||
```json
|
||||
{
|
||||
"id": "location_id",
|
||||
"name": {
|
||||
"en": "🏚️ Location Name",
|
||||
"es": "Spanish Name"
|
||||
},
|
||||
"description": {
|
||||
"en": "Atmospheric description of the location...",
|
||||
"es": "Spanish description"
|
||||
},
|
||||
"image_path": "images/locations/location.webp",
|
||||
"x": 0, // Grid X coordinate
|
||||
"y": 2, // Grid Y coordinate
|
||||
"tags": [ // Optional tags
|
||||
"workbench", // Has crafting bench
|
||||
"repair_station", // Can repair items
|
||||
"safe_zone" // No random encounters
|
||||
],
|
||||
"interactables": { ... } // Interactable objects at this location
|
||||
}
|
||||
```
|
||||
|
||||
### Interactable Object Instance
|
||||
```json
|
||||
"unique_interactable_id": {
|
||||
"template_id": "dumpster", // References interactables.json
|
||||
"outcomes": {
|
||||
"action_id": {
|
||||
"stamina_cost": 2,
|
||||
"success_rate": 0.5, // 50% base success chance
|
||||
"crit_success_chance": 0.1, // 10% chance for critical success
|
||||
"crit_failure_chance": 0.1, // 10% chance for critical failure
|
||||
"rewards": {
|
||||
"damage": 0, // Damage on normal failure
|
||||
"crit_damage": 8, // Damage on critical failure
|
||||
"items": [ // Items on normal success
|
||||
{
|
||||
"item_id": "plastic_bottles",
|
||||
"quantity": 3,
|
||||
"chance": 1.0 // 100% drop rate
|
||||
}
|
||||
],
|
||||
"crit_items": [ // Items on critical success
|
||||
{
|
||||
"item_id": "rare_item",
|
||||
"quantity": 1,
|
||||
"chance": 0.5
|
||||
}
|
||||
]
|
||||
},
|
||||
"text": { // Locale-specific text responses
|
||||
"success": {
|
||||
"en": "You find something useful!",
|
||||
"es": "¡Encuentras algo útil!"
|
||||
},
|
||||
"failure": {
|
||||
"en": "Nothing here.",
|
||||
"es": "Nada aquí."
|
||||
},
|
||||
"crit_success": { ... },
|
||||
"crit_failure": { ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Available Locations:**
|
||||
- `start_point` - Ruined Downtown Core (0, 0) - Starting location
|
||||
- `gas_station` - Abandoned Gas Station (0, 2) - Has workbench
|
||||
- `residential` - Residential Street (3, 0)
|
||||
- `clinic` - Old Clinic (2, 3) - Medical supplies
|
||||
- `plaza` - Shopping Plaza (-2.5, 0)
|
||||
- `park` - Suburban Park (-1, -2)
|
||||
- `overpass` - Highway Overpass (1.0, 4.5)
|
||||
- `warehouse` - Warehouse District
|
||||
- `office_building` - Office Tower
|
||||
- `subway` - Subway Station
|
||||
|
||||
---
|
||||
|
||||
## 🔍 `interactables.json` Structure
|
||||
|
||||
Defines templates for interactable objects that can be placed in locations.
|
||||
|
||||
### Interactable Template
|
||||
```json
|
||||
"template_id": {
|
||||
"id": "template_id",
|
||||
"name": {
|
||||
"en": "🗑️ Object Name",
|
||||
"es": "Spanish Name"
|
||||
},
|
||||
"description": {
|
||||
"en": "Object description",
|
||||
"es": "Spanish description"
|
||||
},
|
||||
"image_path": "images/interactables/object.webp",
|
||||
"actions": { // Available actions for this object
|
||||
"action_id": {
|
||||
"id": "action_id",
|
||||
"label": {
|
||||
"en": "🔎 Action Label",
|
||||
"es": "Spanish Label"
|
||||
},
|
||||
"stamina_cost": 2 // Base stamina cost (can be overridden in locations)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Available Interactable Templates:**
|
||||
- `rubble` - Pile of debris (Action: search)
|
||||
- `dumpster` - Trash container (Action: search_dumpster)
|
||||
- `sedan` - Abandoned car (Actions: search_glovebox, pop_trunk)
|
||||
- `house` - Abandoned house (Action: search_house)
|
||||
- `toolshed` - Tool shed (Action: search_shed)
|
||||
- `medkit` - Medical supply cabinet (Action: search_medkit)
|
||||
- `storage_box` - Storage container (Action: search)
|
||||
- `vending_machine` - Vending machine (Actions: break, search)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Replicating Gamedata
|
||||
|
||||
### Adding a New NPC
|
||||
|
||||
1. **Create NPC definition** in `npcs.json` under `"npcs"`:
|
||||
```json
|
||||
"my_new_npc": {
|
||||
"npc_id": "my_new_npc",
|
||||
"name": { "en": "My NPC", "es": "Mi NPC" },
|
||||
"description": { "en": "Description", "es": "Descripción" },
|
||||
"emoji": "👹",
|
||||
"hp_min": 20, "hp_max": 30,
|
||||
"damage_min": 4, "damage_max": 8,
|
||||
"defense": 1,
|
||||
"xp_reward": 15,
|
||||
"loot_table": [...],
|
||||
"corpse_loot": [...],
|
||||
"flee_chance": 0.2,
|
||||
"status_inflict_chance": 0.1,
|
||||
"image_path": "images/npcs/my_new_npc.webp",
|
||||
"death_message": "The creature falls..."
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add to spawn table** in `npcs.json` under `"spawn_tables"`:
|
||||
```json
|
||||
"location_id": [
|
||||
{ "npc_id": "my_new_npc", "weight": 40 }
|
||||
]
|
||||
```
|
||||
|
||||
3. **Add image** at `images/npcs/my_new_npc.webp`
|
||||
|
||||
### Adding a New Item
|
||||
|
||||
1. **Create item definition** in `items.json`:
|
||||
```json
|
||||
"my_new_item": {
|
||||
"name": { "en": "My Item", "es": "Mi Objeto" },
|
||||
"description": { "en": "Description", "es": "Descripción" },
|
||||
"type": "resource",
|
||||
"weight": 1.0,
|
||||
"volume": 0.5,
|
||||
"emoji": "🔮",
|
||||
"image_path": "images/items/my_new_item.webp"
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add to loot tables** (optional) in locations or NPCs
|
||||
|
||||
3. **Add image** at `images/items/my_new_item.webp`
|
||||
|
||||
### Adding a New Location
|
||||
|
||||
1. **Create location** in `locations.json`:
|
||||
```json
|
||||
{
|
||||
"id": "my_location",
|
||||
"name": { "en": "🏭 My Location", "es": "Mi Ubicación" },
|
||||
"description": { "en": "Description", "es": "Descripción" },
|
||||
"image_path": "images/locations/my_location.webp",
|
||||
"x": 5,
|
||||
"y": 3,
|
||||
"tags": ["workbench"],
|
||||
"interactables": {
|
||||
"my_location_box": {
|
||||
"template_id": "storage_box",
|
||||
"outcomes": {
|
||||
"search": { ...outcome definition... }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add danger level** in `npcs.json`:
|
||||
```json
|
||||
"my_location": {
|
||||
"danger_level": 2,
|
||||
"encounter_rate": 0.15,
|
||||
"wandering_chance": 0.3
|
||||
}
|
||||
```
|
||||
|
||||
3. **Add spawn table** in `npcs.json`:
|
||||
```json
|
||||
"my_location": [
|
||||
{ "npc_id": "raider_scout", "weight": 60 },
|
||||
{ "npc_id": "mutant_rat", "weight": 40 }
|
||||
]
|
||||
```
|
||||
|
||||
4. **Add image** at `images/locations/my_location.webp`
|
||||
|
||||
### Adding a New Interactable Template
|
||||
|
||||
1. **Create template** in `interactables.json`:
|
||||
```json
|
||||
"my_interactable": {
|
||||
"id": "my_interactable",
|
||||
"name": { "en": "🎰 My Object", "es": "Mi Objeto" },
|
||||
"description": { "en": "Description", "es": "Descripción" },
|
||||
"image_path": "images/interactables/my_object.webp",
|
||||
"actions": {
|
||||
"my_action": {
|
||||
"id": "my_action",
|
||||
"label": { "en": "🔨 Do Action", "es": "Hacer Acción" },
|
||||
"stamina_cost": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use in locations** in `locations.json` interactables
|
||||
|
||||
3. **Add image** at `images/interactables/my_object.webp`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Key Game Mechanics
|
||||
|
||||
### Stamina System
|
||||
- Base stamina pool (increases with Stamina stat)
|
||||
- Regenerates passively over time
|
||||
- Consumed by: Movement, Combat Actions, Interactions, Crafting
|
||||
- Encumbrance from equipment increases stamina costs
|
||||
|
||||
### Combat Flow
|
||||
1. Player or NPC initiates combat
|
||||
2. Turn-based with initiative system
|
||||
3. NPCs show **intent preview** (next planned action)
|
||||
4. Player chooses: Attack, Defend, Use Item, Flee
|
||||
5. Status effects tick each turn
|
||||
6. Combat ends on death or successful flee
|
||||
|
||||
### Loot System
|
||||
- **Immediate drops** from loot_table (on death)
|
||||
- **Corpse harvesting** from corpse_loot (requires tools)
|
||||
- **Interactable loot** with success/failure mechanics
|
||||
- **Respawn timers** for interactables
|
||||
|
||||
### Crafting Requirements
|
||||
- Sufficient materials in inventory
|
||||
- Required tools with durability
|
||||
- Crafting level unlocked
|
||||
- Optional: Workbench location tag
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Documentation
|
||||
|
||||
- **[CLAUDE.md](./CLAUDE.md)** - Project structure and development commands
|
||||
- **[QUICK_REFERENCE.md](./QUICK_REFERENCE.md)** - API endpoints and architecture
|
||||
- **[docker-compose.yml](./docker-compose.yml)** - Infrastructure setup
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
# Start the game
|
||||
docker compose up -d
|
||||
|
||||
# View API logs
|
||||
docker compose logs -f echoes_of_the_ashes_api
|
||||
|
||||
# Rebuild after changes
|
||||
docker compose build && docker compose up -d
|
||||
```
|
||||
|
||||
Game runs at: `http://localhost` (PWA) and `http://localhost/api` (API)
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
|
||||
All rights reserved. Post-apocalyptic survival simulation for educational purposes.
|
||||
@@ -2,114 +2,192 @@
|
||||
"interactables": {
|
||||
"rubble": {
|
||||
"id": "rubble",
|
||||
"name": "🧱 Pile of Rubble",
|
||||
"description": "A scattered pile of debris and broken concrete.",
|
||||
"name": {
|
||||
"en": "🧱 Pile of Rubble",
|
||||
"es": "🧱 Pila de escombros"
|
||||
},
|
||||
"description": {
|
||||
"en": "A scattered pile of debris and broken concrete.",
|
||||
"es": "Una pila de escombros y cemento roto."
|
||||
},
|
||||
"image_path": "images/interactables/rubble.webp",
|
||||
"actions": {
|
||||
"search": {
|
||||
"id": "search",
|
||||
"label": "\ud83d\udd0e Search Rubble",
|
||||
"label": {
|
||||
"en": "🔎 Search Rubble",
|
||||
"es": "🔎 Buscar en los escombros"
|
||||
},
|
||||
"stamina_cost": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"dumpster": {
|
||||
"id": "dumpster",
|
||||
"name": "\ud83d\uddd1\ufe0f Dumpster",
|
||||
"description": "A rusted metal dumpster, possibly containing scavenged goods.",
|
||||
"name": {
|
||||
"en": "🗑️ Dumpster",
|
||||
"es": "🗑️ Contenedor de basura"
|
||||
},
|
||||
"description": {
|
||||
"en": "A rusted metal dumpster, possibly containing scavenged goods.",
|
||||
"es": "Un contenedor de basura de metal oxidado, posiblemente conteniendo bienes robados."
|
||||
},
|
||||
"image_path": "images/interactables/dumpster.webp",
|
||||
"actions": {
|
||||
"search_dumpster": {
|
||||
"id": "search_dumpster",
|
||||
"label": "\ud83d\udd0e Dig Through Trash",
|
||||
"label": {
|
||||
"en": "🔎 Dig Through Trash",
|
||||
"es": "🔎 Buscar en la basura"
|
||||
},
|
||||
"stamina_cost": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"sedan": {
|
||||
"id": "sedan",
|
||||
"name": "\ud83d\ude97 Rusty Sedan",
|
||||
"description": "An abandoned sedan with rusted doors.",
|
||||
"name": {
|
||||
"en": "🚗 Rusty Sedan",
|
||||
"es": "🚗 Sedán oxidado"
|
||||
},
|
||||
"description": {
|
||||
"en": "An abandoned sedan with rusted doors.",
|
||||
"es": "Un sedán abandonado con puertas oxidadas."
|
||||
},
|
||||
"image_path": "images/interactables/sedan.webp",
|
||||
"actions": {
|
||||
"search_glovebox": {
|
||||
"id": "search_glovebox",
|
||||
"label": "\ud83d\udd0e Search Glovebox",
|
||||
"label": {
|
||||
"en": "🔎 Search Glovebox",
|
||||
"es": "🔎 Buscar en la guantera"
|
||||
},
|
||||
"stamina_cost": 1
|
||||
},
|
||||
"pop_trunk": {
|
||||
"id": "pop_trunk",
|
||||
"label": "\ud83d\udd27 Pop the Trunk",
|
||||
"label": {
|
||||
"en": "🔧 Pop the Trunk",
|
||||
"es": "🔧 Forzar el maletero"
|
||||
},
|
||||
"stamina_cost": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
"house": {
|
||||
"id": "house",
|
||||
"name": "\ud83c\udfda\ufe0f Abandoned House",
|
||||
"description": "A dilapidated house with boarded windows.",
|
||||
"name": {
|
||||
"en": "🏚️ Abandoned House",
|
||||
"es": "🏚️ Casa abandonada"
|
||||
},
|
||||
"description": {
|
||||
"en": "A dilapidated house with boarded windows.",
|
||||
"es": "Una casa abandonada con ventanas tapadas."
|
||||
},
|
||||
"image_path": "images/interactables/house.webp",
|
||||
"actions": {
|
||||
"search_house": {
|
||||
"id": "search_house",
|
||||
"label": "\ud83d\udd0e Search House",
|
||||
"label": {
|
||||
"en": "🔎 Search House",
|
||||
"es": "🔎 Buscar en la casa"
|
||||
},
|
||||
"stamina_cost": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
"toolshed": {
|
||||
"id": "toolshed",
|
||||
"name": "\ud83d\udd28 Tool Shed",
|
||||
"description": "A small wooden shed, door slightly ajar.",
|
||||
"name": {
|
||||
"en": "🔨 Tool Shed",
|
||||
"es": "🔨 Almacén de herramientas"
|
||||
},
|
||||
"description": {
|
||||
"en": "A small wooden shed, door slightly ajar.",
|
||||
"es": "Un pequeño almacén de madera, la puerta está ligeramente abierta."
|
||||
},
|
||||
"image_path": "images/interactables/toolshed.webp",
|
||||
"actions": {
|
||||
"search_shed": {
|
||||
"id": "search_shed",
|
||||
"label": "\ud83d\udd0e Search Shed",
|
||||
"label": {
|
||||
"en": "🔎 Search Shed",
|
||||
"es": "🔎 Buscar en el almacén"
|
||||
},
|
||||
"stamina_cost": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"medkit": {
|
||||
"id": "medkit",
|
||||
"name": "\ud83c\udfe5 Medical Supply Cabinet",
|
||||
"description": "A white metal cabinet with a red cross symbol.",
|
||||
"name": {
|
||||
"en": "🏥 Medical Supply Cabinet",
|
||||
"es": "🏥 Armario de suministros médicos"
|
||||
},
|
||||
"description": {
|
||||
"en": "A white metal cabinet with a red cross symbol.",
|
||||
"es": "Un armario de metal blanco con un símbolo de cruz roja."
|
||||
},
|
||||
"image_path": "images/interactables/medkit.webp",
|
||||
"actions": {
|
||||
"search_medkit": {
|
||||
"id": "search_medkit",
|
||||
"label": "\ud83d\udd0e Search Cabinet",
|
||||
"label": {
|
||||
"en": "🔎 Search Cabinet",
|
||||
"es": "🔎 Buscar en el armario"
|
||||
},
|
||||
"stamina_cost": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage_box": {
|
||||
"id": "storage_box",
|
||||
"name": "📦 Storage Box",
|
||||
"description": "A weathered storage container.",
|
||||
"name": {
|
||||
"en": "📦 Storage Box",
|
||||
"es": "📦 Caja de almacenamiento"
|
||||
},
|
||||
"description": {
|
||||
"en": "A weathered storage container.",
|
||||
"es": "Un contenedor de almacenamiento desgastado."
|
||||
},
|
||||
"image_path": "images/interactables/storage_box.webp",
|
||||
"actions": {
|
||||
"search": {
|
||||
"id": "search",
|
||||
"label": "\ud83d\udd0e Search Box",
|
||||
"label": {
|
||||
"en": "🔎 Search Box",
|
||||
"es": "🔎 Buscar en la caja"
|
||||
},
|
||||
"stamina_cost": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"vending_machine": {
|
||||
"id": "vending_machine",
|
||||
"name": "\ud83e\uddc3 Vending Machine",
|
||||
"description": "A broken vending machine, glass shattered.",
|
||||
"image_path": "images/interactables/vending.webp",
|
||||
"name": {
|
||||
"en": "🧃 Vending Machine",
|
||||
"es": "🧃 Máquina expendedora"
|
||||
},
|
||||
"description": {
|
||||
"en": "A broken vending machine, glass shattered.",
|
||||
"es": "Una máquina expendedora rota, el vidrio está roto."
|
||||
},
|
||||
"image_path": "images/interactables/vending_machine.webp",
|
||||
"actions": {
|
||||
"break": {
|
||||
"id": "break",
|
||||
"label": "\ud83d\udd28 Break Open",
|
||||
"label": {
|
||||
"en": "🔨 Break Open",
|
||||
"es": "🔨 Forzar la máquina"
|
||||
},
|
||||
"stamina_cost": 5
|
||||
},
|
||||
"search": {
|
||||
"id": "search",
|
||||
"label": "\ud83d\udd0e Search Machine",
|
||||
"label": {
|
||||
"en": "🔎 Search Machine",
|
||||
"es": "🔎 Buscar en la máquina"
|
||||
},
|
||||
"stamina_cost": 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
"locations": [
|
||||
{
|
||||
"id": "start_point",
|
||||
"name": "\ud83c\udf06 Ruined Downtown Core",
|
||||
"description": "The wind howls through skeletal skyscrapers. Debris litters the cracked asphalt. You sense danger, but also opportunity.",
|
||||
"name": {
|
||||
"en": "🌆 Ruined Downtown Core",
|
||||
"es": "🌆 Centro de la ciudad destruido"
|
||||
},
|
||||
"description": {
|
||||
"en": "The wind howls through skeletal skyscrapers. Debris litters the cracked asphalt. You sense danger, but also opportunity.",
|
||||
"es": "El viento ruge a través de los esqueléticos rascacielos. El desastre llena el asfalto roto. Sientes el peligro, pero también la oportunidad."
|
||||
},
|
||||
"image_path": "images/locations/downtown.webp",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
@@ -33,10 +39,19 @@
|
||||
"stamina_cost": 2,
|
||||
"success_rate": 0.5,
|
||||
"text": {
|
||||
"crit_failure": "You disturb a nest of rats! They bite you! (-8 HP)",
|
||||
"crit_failure": {
|
||||
"en": "You disturb a nest of rats! They bite you!",
|
||||
"es": "Te topas con una colmena de ratones. Te muerden!"
|
||||
},
|
||||
"crit_success": "",
|
||||
"failure": "Just rotting garbage. Nothing useful.",
|
||||
"success": "Despite the smell, you find some [Plastic Bottles] and [Cloth Scraps]."
|
||||
"failure": {
|
||||
"en": "Just rotting garbage. Nothing useful.",
|
||||
"es": "Solo escombros rotos. Nada útil."
|
||||
},
|
||||
"success": {
|
||||
"en": "Despite the smell, you find some [Plastic Bottles] and [Cloth Scraps].",
|
||||
"es": "A pesar del olor, encuentras algunos [Botellas de plástico] y [Ramas de tela]."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -64,8 +79,14 @@
|
||||
"text": {
|
||||
"crit_failure": "",
|
||||
"crit_success": "",
|
||||
"failure": "The trunk is rusted shut. You can't get it open.",
|
||||
"success": "With a great heave, you pry the trunk open and find a [Tire Iron]!"
|
||||
"failure": {
|
||||
"en": "The trunk is rusted shut. You can't get it open.",
|
||||
"es": "El maletero está oxidado. No puedes abrirlo."
|
||||
},
|
||||
"success": {
|
||||
"en": "With a great heave, you pry the trunk open and find a [Tire Iron]!",
|
||||
"es": "Con un gran esfuerzo, pruebas el maletero y encuentras una [Herramienta de neumáticos]!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search_glovebox": {
|
||||
@@ -88,8 +109,14 @@
|
||||
"text": {
|
||||
"crit_failure": "",
|
||||
"crit_success": "",
|
||||
"failure": "The glovebox is empty except for dust and old receipts.",
|
||||
"success": "You find a half-eaten [Stale Chocolate Bar]."
|
||||
"failure": {
|
||||
"en": "The glovebox is empty except for dust and old receipts.",
|
||||
"es": ""
|
||||
},
|
||||
"success": {
|
||||
"en": "You find a half-eaten [Stale Chocolate Bar].",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -99,8 +126,14 @@
|
||||
},
|
||||
{
|
||||
"id": "gas_station",
|
||||
"name": "\u26fd\ufe0f Abandoned Gas Station",
|
||||
"description": "The smell of stale gasoline hangs in the air. A rusty sedan sits by the pumps, its door ajar. Behind the station, you spot a small tool shed with a workbench.",
|
||||
"name": {
|
||||
"en": "⛽️ Abandoned Gas Station",
|
||||
"es": "⛽️ Gasolinera abandonada"
|
||||
},
|
||||
"description": {
|
||||
"en": "The smell of stale gasoline hangs in the air. A rusty sedan sits by the pumps, its door ajar. Behind the station, you spot a small tool shed with a workbench.",
|
||||
"es": "El olor a gasolina se suspende en el aire. Un sedán oxidado está en los surtidores, su puerta está abierta. Por detrás de la gasolinera, ves un pequeño almacén de herramientas con una mesa de trabajo."
|
||||
},
|
||||
"image_path": "images/locations/gas_station.webp",
|
||||
"x": 0,
|
||||
"y": 2,
|
||||
@@ -141,10 +174,22 @@
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"success": "You find some cloth scraps and plastic in the glovebox.",
|
||||
"failure": "The glovebox is empty except for old papers.",
|
||||
"crit_success": "You find scrap metal from the dashboard!",
|
||||
"crit_failure": "The glovebox is jammed shut."
|
||||
"success": {
|
||||
"en": "You find some cloth scraps and plastic in the glovebox.",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "The glovebox is empty except for old papers.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": {
|
||||
"en": "You find scrap metal from the dashboard!",
|
||||
"es": ""
|
||||
},
|
||||
"crit_failure": {
|
||||
"en": "The glovebox is jammed shut.",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"pop_trunk": {
|
||||
@@ -176,10 +221,22 @@
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"success": "You force the trunk open and find scrap metal and plastic.",
|
||||
"failure": "The trunk is rusted shut.",
|
||||
"crit_success": "The trunk contains tools!",
|
||||
"crit_failure": "You cut your hand on rusty metal! (-5 HP)"
|
||||
"success": {
|
||||
"en": "You force the trunk open and find scrap metal and plastic.",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "The trunk is rusted shut.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": {
|
||||
"en": "The trunk contains tools!",
|
||||
"es": ""
|
||||
},
|
||||
"crit_failure": {
|
||||
"en": "You cut your hand on rusty metal! (-5 HP)",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,10 +273,22 @@
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"success": "You find scrap metal and cloth in the storage box.",
|
||||
"failure": "The storage box is mostly empty.",
|
||||
"crit_success": "You discover tools inside!",
|
||||
"crit_failure": "Just oil stains and rust."
|
||||
"success": {
|
||||
"en": "You find scrap metal and cloth in the storage box.",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "The storage box is mostly empty.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": {
|
||||
"en": "You discover tools inside!",
|
||||
"es": ""
|
||||
},
|
||||
"crit_failure": {
|
||||
"en": "Just oil stains and rust.",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,8 +297,14 @@
|
||||
},
|
||||
{
|
||||
"id": "residential",
|
||||
"name": "\ud83c\udfd8\ufe0f Residential Street",
|
||||
"description": "A quiet suburban street lined with abandoned homes. Most are boarded up, but a few doors hang open, creaking in the wind.",
|
||||
"name": {
|
||||
"en": "🏘️ Residential Street",
|
||||
"es": "🏘️ Calle residencial"
|
||||
},
|
||||
"description": {
|
||||
"en": "A quiet suburban street lined with abandoned homes. Most are boarded up, but a few doors hang open, creaking in the wind.",
|
||||
"es": "Una tranquila calle suburbana llena de casas abandonadas. La mayoría están tapiadas, pero algunas puertas están abiertas, movidas por el viento."
|
||||
},
|
||||
"image_path": "images/locations/residential.webp",
|
||||
"x": 3,
|
||||
"y": 0,
|
||||
@@ -264,10 +339,19 @@
|
||||
"stamina_cost": 3,
|
||||
"success_rate": 0.5,
|
||||
"text": {
|
||||
"crit_failure": "The floor collapses beneath you! (-10 HP)",
|
||||
"crit_failure": {
|
||||
"en": "The floor collapses beneath you! (-10 HP)",
|
||||
"es": "¡El suelo se derrumba bajo ti! (-10 HP)"
|
||||
},
|
||||
"crit_success": "",
|
||||
"failure": "The house has already been thoroughly looted. Nothing remains.",
|
||||
"success": "You find some useful supplies: [Canned Beans], [Bottled Water], and [Cloth Scraps]!"
|
||||
"failure": {
|
||||
"en": "The house has already been thoroughly looted. Nothing remains.",
|
||||
"es": "La casa ya ha sido despojada de todo. No queda nada."
|
||||
},
|
||||
"success": {
|
||||
"en": "You find some useful supplies!",
|
||||
"es": "¡Encuentras algunos suministros útiles!"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -277,8 +361,14 @@
|
||||
},
|
||||
{
|
||||
"id": "clinic",
|
||||
"name": "\ud83c\udfe5 Old Clinic",
|
||||
"description": "A small medical clinic, its windows shattered. The waiting room is a mess of overturned chairs and scattered papers. The examination rooms might still have supplies.",
|
||||
"name": {
|
||||
"en": "🏥 Old Clinic",
|
||||
"es": "🏥 Clínica abandonada"
|
||||
},
|
||||
"description": {
|
||||
"en": "A small medical clinic, its windows shattered. The waiting room is a mess of overturned chairs and scattered papers. The examination rooms might still have supplies.",
|
||||
"es": "Una pequeña clínica médica, sus ventanas están rotas. El salón de espera es un desastre de sillas invertidas y papeles dispersos. Las habitaciones de examen pueden todavía tener suministros."
|
||||
},
|
||||
"image_path": "images/locations/clinic.webp",
|
||||
"x": 2,
|
||||
"y": 3,
|
||||
@@ -310,8 +400,14 @@
|
||||
"text": {
|
||||
"crit_failure": "",
|
||||
"crit_success": "",
|
||||
"failure": "The cabinet is empty. Someone got here first.",
|
||||
"success": "Jackpot! You find a [First Aid Kit] and some [Bandages]!"
|
||||
"failure": {
|
||||
"en": "The cabinet is empty. Someone got here first.",
|
||||
"es": ""
|
||||
},
|
||||
"success": {
|
||||
"en": "Jackpot! You find a [First Aid Kit] and some [Bandages]!",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -321,8 +417,14 @@
|
||||
},
|
||||
{
|
||||
"id": "plaza",
|
||||
"name": "\ud83c\udfec Shopping Plaza",
|
||||
"description": "A strip mall with broken storefronts. Most shops have been thoroughly ransacked, but you might find something if you search carefully.",
|
||||
"name": {
|
||||
"en": "🏬 Shopping Plaza",
|
||||
"es": "🏬 Plaza de comercio"
|
||||
},
|
||||
"description": {
|
||||
"en": "A strip mall with broken storefronts. Most shops have been thoroughly ransacked, but you might find something if you search carefully.",
|
||||
"es": "Una plaza de comercio con vitrinas rotas. La mayoría de las tiendas han sido despojadas, pero puedes encontrar algo si buscas con cuidado."
|
||||
},
|
||||
"image_path": "images/locations/plaza.webp",
|
||||
"x": -2.5,
|
||||
"y": 0,
|
||||
@@ -359,10 +461,22 @@
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"success": "You smash the vending machine and grab bottles and scrap.",
|
||||
"failure": "The machine is too sturdy to break.",
|
||||
"crit_success": "Packaged food falls out!",
|
||||
"crit_failure": "Glass cuts your arm! (-10 HP)"
|
||||
"success": {
|
||||
"en": "You smash the vending machine and grab bottles and scrap.",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "The machine is too sturdy to break.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": {
|
||||
"en": "Packaged food falls out!",
|
||||
"es": ""
|
||||
},
|
||||
"crit_failure": {
|
||||
"en": "Glass cuts your arm! (-10 HP)",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
@@ -389,10 +503,22 @@
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"success": "You find a plastic bottle at the bottom.",
|
||||
"failure": "Nothing left to scavenge.",
|
||||
"crit_success": "A snack is wedged in the dispenser!",
|
||||
"crit_failure": "Already picked clean."
|
||||
"success": {
|
||||
"en": "You find a plastic bottle at the bottom.",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "Nothing left to scavenge.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": {
|
||||
"en": "A snack is wedged in the dispenser!",
|
||||
"es": ""
|
||||
},
|
||||
"crit_failure": {
|
||||
"en": "Already picked clean.",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -429,10 +555,22 @@
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"success": "You dig through rubble and find scrap metal and cloth.",
|
||||
"failure": "Just broken concrete and dust.",
|
||||
"crit_success": "A tool was buried in the debris!",
|
||||
"crit_failure": "Sharp debris cuts you! (-5 HP)"
|
||||
"success": {
|
||||
"en": "You dig through rubble and find scrap metal and cloth.",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "Just broken concrete and dust.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": {
|
||||
"en": "A tool was buried in the debris!",
|
||||
"es": ""
|
||||
},
|
||||
"crit_failure": {
|
||||
"en": "Sharp debris cuts you! (-5 HP)",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -441,8 +579,14 @@
|
||||
},
|
||||
{
|
||||
"id": "park",
|
||||
"name": "\ud83c\udf33 Suburban Park",
|
||||
"description": "An overgrown park with rusted playground equipment. Nature is slowly reclaiming this space. A maintenance shed sits at the far end.",
|
||||
"name": {
|
||||
"en": "🌳 Suburban Park",
|
||||
"es": "🌳 Parque suburbano"
|
||||
},
|
||||
"description": {
|
||||
"en": "An overgrown park with rusted playground equipment. Nature is slowly reclaiming this space. A maintenance shed sits at the far end.",
|
||||
"es": "Un parque suburbano deshabitado con equipos de juegos oxidados. La naturaleza está reclamando este espacio. Un almacén de mantenimiento se encuentra al final."
|
||||
},
|
||||
"image_path": "images/locations/park.webp",
|
||||
"x": -1,
|
||||
"y": -2,
|
||||
@@ -484,8 +628,14 @@
|
||||
"text": {
|
||||
"crit_failure": "",
|
||||
"crit_success": "",
|
||||
"failure": "The shed has been picked clean. Only empty shelves remain.",
|
||||
"success": "You find some tools: [Rusty Nails], [Wood Planks], and a [Flashlight]!"
|
||||
"failure": {
|
||||
"en": "The shed has been picked clean. Only empty shelves remain.",
|
||||
"es": ""
|
||||
},
|
||||
"success": {
|
||||
"en": "You find some tools!",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -495,8 +645,14 @@
|
||||
},
|
||||
{
|
||||
"id": "overpass",
|
||||
"name": "\ud83d\udee3\ufe0f Highway Overpass",
|
||||
"description": "A concrete overpass spanning the cracked highway below. Abandoned vehicles litter the road. This is a good vantage point to survey the area.",
|
||||
"name": {
|
||||
"en": "🛣️ Highway Overpass",
|
||||
"es": "🛣️ Puesto de carretera"
|
||||
},
|
||||
"description": {
|
||||
"en": "A concrete overpass spanning the cracked highway below. Abandoned vehicles litter the road. This is a good vantage point to survey the area.",
|
||||
"es": "Un puesto de carretera de cemento que atraviesa la carretera rota por debajo. Vehículos abandonados se desvanecen por la carretera. Este es un buen punto de vista para examinar el área."
|
||||
},
|
||||
"x": 1.0,
|
||||
"y": 4.5,
|
||||
"image_path": "images/locations/overpass.webp",
|
||||
@@ -510,8 +666,14 @@
|
||||
"crit_success_chance": 0.1,
|
||||
"crit_failure_chance": 0.1,
|
||||
"text": {
|
||||
"success": "You find a half-eaten [Stale Chocolate Bar].",
|
||||
"failure": "The glovebox is empty except for dust and old receipts.",
|
||||
"success": {
|
||||
"en": "You find a half-eaten [Stale Chocolate Bar].",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "The glovebox is empty except for dust and old receipts.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": "",
|
||||
"crit_failure": ""
|
||||
},
|
||||
@@ -534,8 +696,14 @@
|
||||
"crit_success_chance": 0.1,
|
||||
"crit_failure_chance": 0.1,
|
||||
"text": {
|
||||
"success": "With a great heave, you pry the trunk open and find a [Tire Iron]!",
|
||||
"failure": "The trunk is rusted shut. You can't get it open.",
|
||||
"success": {
|
||||
"en": "With a great heave, you pry the trunk open and find a [Tire Iron]!",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "The trunk is rusted shut. You can't get it open.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": "",
|
||||
"crit_failure": ""
|
||||
},
|
||||
@@ -563,8 +731,14 @@
|
||||
"crit_success_chance": 0.1,
|
||||
"crit_failure_chance": 0.1,
|
||||
"text": {
|
||||
"success": "You find a half-eaten [Stale Chocolate Bar].",
|
||||
"failure": "The glovebox is empty except for dust and old receipts.",
|
||||
"success": {
|
||||
"en": "You find a half-eaten [Stale Chocolate Bar].",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "The glovebox is empty except for dust and old receipts.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": "",
|
||||
"crit_failure": ""
|
||||
},
|
||||
@@ -587,8 +761,14 @@
|
||||
"crit_success_chance": 0.1,
|
||||
"crit_failure_chance": 0.1,
|
||||
"text": {
|
||||
"success": "With a great heave, you pry the trunk open and find a [Tire Iron]!",
|
||||
"failure": "The trunk is rusted shut. You can't get it open.",
|
||||
"success": {
|
||||
"en": "With a great heave, you pry the trunk open and find a [Tire Iron]!",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "The trunk is rusted shut. You can't get it open.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": "",
|
||||
"crit_failure": ""
|
||||
},
|
||||
@@ -611,8 +791,14 @@
|
||||
},
|
||||
{
|
||||
"id": "warehouse",
|
||||
"name": "\ud83c\udfed Warehouse District",
|
||||
"description": "Rows of industrial warehouses stretch before you. Metal doors creak in the wind. The loading docks are littered with debris and abandoned cargo.",
|
||||
"name": {
|
||||
"en": "🏭 Warehouse District",
|
||||
"es": "🏭 Distrito de Almacenes"
|
||||
},
|
||||
"description": {
|
||||
"en": "Rows of industrial warehouses stretch before you. Metal doors creak in the wind. The loading docks are littered with debris and abandoned cargo.",
|
||||
"es": "Filas de almacenes industriales se extienden ante ti. Las puertas metálicas crujen en el viento. Los muelles de carga están cubiertos de basura y carga abandonada."
|
||||
},
|
||||
"image_path": "images/locations/warehouse.webp",
|
||||
"x": 4,
|
||||
"y": -1.5,
|
||||
@@ -642,10 +828,19 @@
|
||||
"stamina_cost": 2,
|
||||
"success_rate": 0.5,
|
||||
"text": {
|
||||
"crit_failure": "You disturb a nest of rats! They bite you! (-8 HP)",
|
||||
"crit_failure": {
|
||||
"en": "You disturb a nest of rats! They bite you! (-8 HP)",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": "",
|
||||
"failure": "Just rotting garbage. Nothing useful.",
|
||||
"success": "Despite the smell, you find some [Plastic Bottles] and [Cloth Scraps]."
|
||||
"failure": {
|
||||
"en": "Just rotting garbage. Nothing useful.",
|
||||
"es": ""
|
||||
},
|
||||
"success": {
|
||||
"en": "Despite the smell, you find some [Plastic Bottles] and [Cloth Scraps].",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -683,8 +878,14 @@
|
||||
"text": {
|
||||
"crit_failure": "",
|
||||
"crit_success": "",
|
||||
"failure": "The shed has been picked clean. Only empty shelves remain.",
|
||||
"success": "You find some tools: [Rusty Nails], [Wood Planks], and a [Flashlight]!"
|
||||
"failure": {
|
||||
"en": "The shed has been picked clean. Only empty shelves remain.",
|
||||
"es": ""
|
||||
},
|
||||
"success": {
|
||||
"en": "You find some tools: [Rusty Nails], [Wood Planks], and a [Flashlight]!",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -694,8 +895,14 @@
|
||||
},
|
||||
{
|
||||
"id": "warehouse_interior",
|
||||
"name": "\ud83d\udce6 Warehouse Interior",
|
||||
"description": "Inside the warehouse, towering shelves cast long shadows. Scattered crates and pallets suggest this was once a distribution center. The back office door hangs open.",
|
||||
"name": {
|
||||
"en": "📦 Warehouse Interior",
|
||||
"es": "📦 Interior del almacén"
|
||||
},
|
||||
"description": {
|
||||
"en": "Inside the warehouse, towering shelves cast long shadows. Scattered crates and pallets suggest this was once a distribution center. The back office door hangs open.",
|
||||
"es": "Dentro del almacén, las estanterías altas proyectan sombras largas. Los cajones y los palets dispersos sugieren que esto alguna vez fue un centro de distribución. La puerta del despacho de la oficina de atrás se coloca abierta."
|
||||
},
|
||||
"image_path": "images/locations/warehouse_interior.webp",
|
||||
"x": 4.5,
|
||||
"y": -2,
|
||||
@@ -709,8 +916,14 @@
|
||||
"crit_success_chance": 0,
|
||||
"crit_failure_chance": 0,
|
||||
"text": {
|
||||
"success": "You successfully \ud83d\udd0e search box.",
|
||||
"failure": "You failed to \ud83d\udd0e search box.",
|
||||
"success": {
|
||||
"en": "You successfully 🔎 search box.",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "You failed to 🔎 search box.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": "",
|
||||
"crit_failure": ""
|
||||
},
|
||||
@@ -738,8 +951,14 @@
|
||||
},
|
||||
{
|
||||
"id": "subway",
|
||||
"name": "\ud83d\ude87 Subway Station Entrance",
|
||||
"description": "Stairs descend into darkness. The entrance to an abandoned subway station yawns before you. Emergency lighting flickers somewhere below.",
|
||||
"name": {
|
||||
"en": "🚇 Subway Station Entrance",
|
||||
"es": "🚇 Entrada de la Estación de Metro"
|
||||
},
|
||||
"description": {
|
||||
"en": "Stairs descend into darkness. The entrance to an abandoned subway station yawns before you. Emergency lighting flickers somewhere below.",
|
||||
"es": "Los escalones descienden en la oscuridad. La entrada a una estación de metro abandonada se abre ante ti. La luz de emergencia titila por debajo de algún lugar."
|
||||
},
|
||||
"image_path": "images/locations/subway.webp",
|
||||
"x": -4,
|
||||
"y": -0.5,
|
||||
@@ -775,10 +994,22 @@
|
||||
"stamina_cost": 2,
|
||||
"success_rate": 0.55,
|
||||
"text": {
|
||||
"crit_failure": "Debris shifts and hits your leg! (-4 HP)",
|
||||
"crit_success": "You uncover a tool buried deep!",
|
||||
"failure": "Just concrete chunks.",
|
||||
"success": "You sift through rubble and find scrap metal."
|
||||
"crit_failure": {
|
||||
"en": "Debris shifts and hits your leg! (-4 HP)",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": {
|
||||
"en": "You uncover a tool buried deep!",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "Just concrete chunks.",
|
||||
"es": ""
|
||||
},
|
||||
"success": {
|
||||
"en": "You sift through rubble and find scrap metal.",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -810,10 +1041,22 @@
|
||||
"stamina_cost": 5,
|
||||
"success_rate": 0.6,
|
||||
"text": {
|
||||
"crit_failure": "The machine topples on you! (-12 HP)",
|
||||
"crit_success": "Food packages tumble out!",
|
||||
"failure": "The machine won't budge.",
|
||||
"success": "You bash open the vending machine and grab bottles."
|
||||
"crit_failure": {
|
||||
"en": "The machine topples on you! (-12 HP)",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": {
|
||||
"en": "Food packages tumble out!",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "The machine won't budge.",
|
||||
"es": ""
|
||||
},
|
||||
"success": {
|
||||
"en": "You bash open the vending machine and grab bottles.",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
@@ -834,10 +1077,22 @@
|
||||
"stamina_cost": 2,
|
||||
"success_rate": 0.4,
|
||||
"text": {
|
||||
"crit_failure": "Nothing here.",
|
||||
"crit_success": "A bottle still rolls out!",
|
||||
"failure": "Completely empty.",
|
||||
"success": "You find a bottle in the machine's slot."
|
||||
"crit_failure": {
|
||||
"en": "Nothing here.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": {
|
||||
"en": "A bottle still rolls out!",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "Completely empty.",
|
||||
"es": ""
|
||||
},
|
||||
"success": {
|
||||
"en": "You find a bottle in the machine's slot.",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -847,8 +1102,14 @@
|
||||
},
|
||||
{
|
||||
"id": "subway_tunnels",
|
||||
"name": "\ud83d\ude8a Subway Tunnels",
|
||||
"description": "Dark subway tunnels stretch into blackness. Flickering emergency lights cast eerie shadows. The third rail is dead, but you should still watch your step.",
|
||||
"name": {
|
||||
"en": "🚊 Subway Tunnels",
|
||||
"es": "🚊 Túneles de Metro"
|
||||
},
|
||||
"description": {
|
||||
"en": "Dark subway tunnels stretch into blackness. Flickering emergency lights cast eerie shadows. The third rail is dead, but you should still watch your step.",
|
||||
"es": "Los túneles de metro oscuros se extienden en la oscuridad. Las luces de emergencia titilan castellando sombras. El tercer rail está muerto, pero aún debes prestar atención a tus pies."
|
||||
},
|
||||
"image_path": "images/locations/subway_tunnels.webp",
|
||||
"x": -4.5,
|
||||
"y": -1,
|
||||
@@ -880,10 +1141,22 @@
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"success": "You find scrap metal in the tunnel debris.",
|
||||
"failure": "Just rocks and dirt.",
|
||||
"crit_success": "A maintenance tool was left behind!",
|
||||
"crit_failure": "You stumble and hit the wall! (-6 HP)"
|
||||
"success": {
|
||||
"en": "You find scrap metal in the tunnel debris.",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "Just rocks and dirt.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": {
|
||||
"en": "A maintenance tool was left behind!",
|
||||
"es": ""
|
||||
},
|
||||
"crit_failure": {
|
||||
"en": "You stumble and hit the wall! (-6 HP)",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -892,8 +1165,14 @@
|
||||
},
|
||||
{
|
||||
"id": "office_building",
|
||||
"name": "\ud83c\udfe2 Office Building",
|
||||
"description": "A five-story office building with shattered windows. The lobby is trashed, but the stairs appear intact. You can hear the wind whistling through the upper floors.",
|
||||
"name": {
|
||||
"en": "🏢 Office Building",
|
||||
"es": "🏢 Edificio de Oficinas"
|
||||
},
|
||||
"description": {
|
||||
"en": "A five-story office building with shattered windows. The lobby is trashed, but the stairs appear intact. You can hear the wind whistling through the upper floors.",
|
||||
"es": "Un edificio de oficinas de cinco pisos con ventanas rotas. El lobby está despeinado, pero las escaleras parecen intactas. Puedes escuchar el viento susurrando por las plantas superiores."
|
||||
},
|
||||
"image_path": "images/locations/office_building.webp",
|
||||
"x": 3.5,
|
||||
"y": 4,
|
||||
@@ -924,10 +1203,22 @@
|
||||
"crit_items": []
|
||||
},
|
||||
"text": {
|
||||
"success": "You find scrap metal and cloth in the lobby debris.",
|
||||
"failure": "Just broken furniture and papers.",
|
||||
"crit_success": "You discover useful materials!",
|
||||
"crit_failure": "Glass cuts your hand! (-5 HP)"
|
||||
"success": {
|
||||
"en": "You find scrap metal and cloth in the lobby debris.",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "Just broken furniture and papers.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": {
|
||||
"en": "You discover useful materials!",
|
||||
"es": ""
|
||||
},
|
||||
"crit_failure": {
|
||||
"en": "Glass cuts your hand! (-5 HP)",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -936,8 +1227,14 @@
|
||||
},
|
||||
{
|
||||
"id": "office_interior",
|
||||
"name": "\ud83d\udcbc Office Floors",
|
||||
"description": "Cubicles stretch across the floor. Papers scatter in the breeze from broken windows. Filing cabinets stand open, already ransacked. A corner office looks promising.",
|
||||
"name": {
|
||||
"en": "💼 Office Floors",
|
||||
"es": "💼 Pisos de Oficinas"
|
||||
},
|
||||
"description": {
|
||||
"en": "Cubicles stretch across the floor. Papers scatter in the breeze from broken windows. Filing cabinets stand open, already ransacked. A corner office looks promising.",
|
||||
"es": "Los cubículos se extienden por el suelo. Los papeles se dispersan en el viento de las ventanas rotas. Los cajones de archivo se colocan abiertos, ya despojados. Un despacho de esquina parece prometedor."
|
||||
},
|
||||
"image_path": "images/locations/office_interior.webp",
|
||||
"x": 4,
|
||||
"y": 4.5,
|
||||
@@ -974,10 +1271,22 @@
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"success": "You find cloth and bottles in desk drawers.",
|
||||
"failure": "Everything's been picked through already.",
|
||||
"crit_success": "Someone left food in their desk!",
|
||||
"crit_failure": "Just old paperwork."
|
||||
"success": {
|
||||
"en": "You find cloth and bottles in desk drawers.",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "Everything's been picked through already.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": {
|
||||
"en": "Someone left food in their desk!",
|
||||
"es": ""
|
||||
},
|
||||
"crit_failure": {
|
||||
"en": "Just old paperwork.",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -986,8 +1295,14 @@
|
||||
},
|
||||
{
|
||||
"id": "location_1760791397492",
|
||||
"name": "Subway Section A",
|
||||
"description": "A shady dimly lit subway section. All you can see are abandoned train tracks and some garbage lying around. ",
|
||||
"name": {
|
||||
"en": "Subway Section A",
|
||||
"es": "Sección A del metro"
|
||||
},
|
||||
"description": {
|
||||
"en": "A shady dimly lit subway section. All you can see are abandoned train tracks and some garbage lying around. ",
|
||||
"es": "Una sección oscura y despeinada del metro. Todo lo que puedes ver son rutas de tren abandonadas y algunos desechos de basura por el suelo."
|
||||
},
|
||||
"image_path": "images/locations/subway_section_a.jpg",
|
||||
"x": -5,
|
||||
"y": -2,
|
||||
@@ -1019,10 +1334,22 @@
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"success": "You dig through the garbage and find scrap metal.",
|
||||
"failure": "Just rotting trash.",
|
||||
"crit_success": "A tool was discarded here!",
|
||||
"crit_failure": "You step on sharp debris! (-5 HP)"
|
||||
"success": {
|
||||
"en": "You dig through the garbage and find scrap metal.",
|
||||
"es": ""
|
||||
},
|
||||
"failure": {
|
||||
"en": "Just rotting trash.",
|
||||
"es": ""
|
||||
},
|
||||
"crit_success": {
|
||||
"en": "A tool was discarded here!",
|
||||
"es": ""
|
||||
},
|
||||
"crit_failure": {
|
||||
"en": "You step on sharp debris! (-5 HP)",
|
||||
"es": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
"npcs": {
|
||||
"feral_dog": {
|
||||
"npc_id": "feral_dog",
|
||||
"name": "Feral Dog",
|
||||
"description": "A wild, mangy dog with desperate hunger in its eyes. Its ribs are visible beneath matted fur.",
|
||||
"name": {
|
||||
"en": "Feral Dog",
|
||||
"es": "Perro feroz"
|
||||
},
|
||||
"description": {
|
||||
"en": "A wild, mangy dog with desperate hunger in its eyes. Its ribs are visible beneath matted fur.",
|
||||
"es": "Un perro salvaje, desgarrado, con hambre desesperada en sus ojos. Sus huesos están visibles bajo el pelo despeinado."
|
||||
},
|
||||
"emoji": "🐕",
|
||||
"hp_min": 15,
|
||||
"hp_max": 25,
|
||||
@@ -42,12 +48,21 @@
|
||||
"flee_chance": 0.3,
|
||||
"status_inflict_chance": 0.15,
|
||||
"image_path": "images/npcs/feral_dog.webp",
|
||||
"death_message": "The feral dog whimpers and collapses. Perhaps it was just hungry..."
|
||||
"death_message": {
|
||||
"en": "The feral dog whimpers and collapses. Perhaps it was just hungry...",
|
||||
"es": "El perro salvaje gemía y se derrumbó. Quizás solo estaba hambriento..."
|
||||
}
|
||||
},
|
||||
"raider_scout": {
|
||||
"npc_id": "raider_scout",
|
||||
"name": "Raider Scout",
|
||||
"description": "A lone raider wearing makeshift armor. They eye you with hostile intent.",
|
||||
"name": {
|
||||
"en": "Raider Scout",
|
||||
"es": "Explorador"
|
||||
},
|
||||
"description": {
|
||||
"en": "A lone raider wearing makeshift armor. They eye you with hostile intent.",
|
||||
"es": "Un explorador solitario con ropa improvisada. Te mira con intención hostil."
|
||||
},
|
||||
"emoji": "🏴☠️",
|
||||
"hp_min": 30,
|
||||
"hp_max": 45,
|
||||
@@ -98,12 +113,21 @@
|
||||
"flee_chance": 0.2,
|
||||
"status_inflict_chance": 0.1,
|
||||
"image_path": "images/npcs/raider_scout.webp",
|
||||
"death_message": "The raider scout falls with a final gasp. Their supplies are yours."
|
||||
"death_message": {
|
||||
"en": "The raider scout falls with a final gasp. Their supplies are yours.",
|
||||
"es": "El explorador cae con un último gemido. Sus suministros son tuyos."
|
||||
}
|
||||
},
|
||||
"mutant_rat": {
|
||||
"npc_id": "mutant_rat",
|
||||
"name": "Mutant Rat",
|
||||
"description": "A grotesquely large rat, its fur patchy and eyes glowing with unnatural light.",
|
||||
"name": {
|
||||
"en": "Mutant Rat",
|
||||
"es": "Rata mutante"
|
||||
},
|
||||
"description": {
|
||||
"en": "A grotesquely large rat, its fur patchy and eyes glowing with unnatural light.",
|
||||
"es": "Una rata grotescamente grande, su pelaje es desgarrado y sus ojos brillan con luz unnatural."
|
||||
},
|
||||
"emoji": "🐀",
|
||||
"hp_min": 10,
|
||||
"hp_max": 18,
|
||||
@@ -136,12 +160,21 @@
|
||||
"flee_chance": 0.5,
|
||||
"status_inflict_chance": 0.25,
|
||||
"image_path": "images/npcs/mutant_rat.webp",
|
||||
"death_message": "The mutant rat squeals its last and goes still."
|
||||
"death_message": {
|
||||
"en": "The mutant rat squeals its last and goes still.",
|
||||
"es": "La rata mutante gemía por última vez y se detuvo."
|
||||
}
|
||||
},
|
||||
"infected_human": {
|
||||
"npc_id": "infected_human",
|
||||
"name": "Infected Human",
|
||||
"description": "Once human, now something else. Their movements are jerky and their skin shows signs of advanced infection.",
|
||||
"name": {
|
||||
"en": "Infected Human",
|
||||
"es": "Humano infectado"
|
||||
},
|
||||
"description": {
|
||||
"en": "Once human, now something else. Their movements are jerky and their skin shows signs of advanced infection.",
|
||||
"es": "Una vez humano, ahora algo más. Sus movimientos son torpes y su piel muestra signos de infección avanzada."
|
||||
},
|
||||
"emoji": "🧟",
|
||||
"hp_min": 35,
|
||||
"hp_max": 50,
|
||||
@@ -180,12 +213,21 @@
|
||||
"flee_chance": 0.1,
|
||||
"status_inflict_chance": 0.3,
|
||||
"image_path": "images/npcs/infected_human.webp",
|
||||
"death_message": "The infected human finally finds peace in death."
|
||||
"death_message": {
|
||||
"en": "The infected human finally finds peace in death.",
|
||||
"es": "El humano infectado finalmente encuentra paz en la muerte."
|
||||
}
|
||||
},
|
||||
"scavenger": {
|
||||
"npc_id": "scavenger",
|
||||
"name": "Hostile Scavenger",
|
||||
"description": "Another survivor, but this one sees you as competition. They won't share territory.",
|
||||
"name": {
|
||||
"en": "Hostile Scavenger",
|
||||
"es": "Superviviente hostil"
|
||||
},
|
||||
"description": {
|
||||
"en": "Another survivor, but this one sees you as competition. They won't share territory.",
|
||||
"es": "Otro superviviente, eres su competencia. No compartirá el territorio."
|
||||
},
|
||||
"emoji": "💀",
|
||||
"hp_min": 25,
|
||||
"hp_max": 40,
|
||||
@@ -248,7 +290,10 @@
|
||||
"flee_chance": 0.25,
|
||||
"status_inflict_chance": 0.05,
|
||||
"image_path": "images/npcs/scavenger.webp",
|
||||
"death_message": "The scavenger's struggle ends. Survival has no mercy."
|
||||
"death_message": {
|
||||
"en": "The scavenger's struggle ends. Survival has no mercy.",
|
||||
"es": "El deseo de supervivencia del escavador se agota. La supervivencia no tiene misericordia."
|
||||
}
|
||||
}
|
||||
},
|
||||
"danger_levels": {
|
||||
@@ -264,23 +309,23 @@
|
||||
},
|
||||
"residential": {
|
||||
"danger_level": 1,
|
||||
"encounter_rate": 0.10,
|
||||
"wandering_chance": 0.20
|
||||
"encounter_rate": 0.1,
|
||||
"wandering_chance": 0.2
|
||||
},
|
||||
"park": {
|
||||
"danger_level": 1,
|
||||
"encounter_rate": 0.10,
|
||||
"wandering_chance": 0.20
|
||||
"encounter_rate": 0.1,
|
||||
"wandering_chance": 0.2
|
||||
},
|
||||
"clinic": {
|
||||
"danger_level": 2,
|
||||
"encounter_rate": 0.20,
|
||||
"encounter_rate": 0.2,
|
||||
"wandering_chance": 0.35
|
||||
},
|
||||
"plaza": {
|
||||
"danger_level": 2,
|
||||
"encounter_rate": 0.15,
|
||||
"wandering_chance": 0.30
|
||||
"wandering_chance": 0.3
|
||||
},
|
||||
"warehouse": {
|
||||
"danger_level": 2,
|
||||
@@ -290,27 +335,27 @@
|
||||
"warehouse_interior": {
|
||||
"danger_level": 2,
|
||||
"encounter_rate": 0.22,
|
||||
"wandering_chance": 0.40
|
||||
"wandering_chance": 0.4
|
||||
},
|
||||
"overpass": {
|
||||
"danger_level": 3,
|
||||
"encounter_rate": 0.30,
|
||||
"encounter_rate": 0.3,
|
||||
"wandering_chance": 0.45
|
||||
},
|
||||
"office_building": {
|
||||
"danger_level": 3,
|
||||
"encounter_rate": 0.25,
|
||||
"wandering_chance": 0.40
|
||||
"wandering_chance": 0.4
|
||||
},
|
||||
"office_interior": {
|
||||
"danger_level": 3,
|
||||
"encounter_rate": 0.35,
|
||||
"wandering_chance": 0.50
|
||||
"wandering_chance": 0.5
|
||||
},
|
||||
"subway": {
|
||||
"danger_level": 4,
|
||||
"encounter_rate": 0.35,
|
||||
"wandering_chance": 0.50
|
||||
"wandering_chance": 0.5
|
||||
},
|
||||
"subway_tunnels": {
|
||||
"danger_level": 4,
|
||||
@@ -468,4 +513,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
68
gamedata/quests.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"quests": {
|
||||
"quest_collect_wood": {
|
||||
"quest_id": "quest_collect_wood",
|
||||
"title": {
|
||||
"en": "Rebuilding the Bridge",
|
||||
"es": "Reconstruyendo el Puente"
|
||||
},
|
||||
"description": {
|
||||
"en": "We need wood to repair the bridge to the north. Bring what you can.",
|
||||
"es": "Necesitamos madera para reparar el puente del norte. Trae lo que puedas."
|
||||
},
|
||||
"giver_id": "mechanic_mike",
|
||||
"type": "global",
|
||||
"repeatable": true,
|
||||
"cooldown_hours": 0,
|
||||
"objectives": [
|
||||
{
|
||||
"type": "item_delivery",
|
||||
"target": "wood_planks",
|
||||
"count": 1000
|
||||
}
|
||||
],
|
||||
"rewards": {
|
||||
"xp": 10,
|
||||
"items": {
|
||||
"credits": 5
|
||||
}
|
||||
},
|
||||
"completion_text": {
|
||||
"en": "Thanks, every plank helps.",
|
||||
"es": "Gracias, cada tabla ayuda."
|
||||
}
|
||||
},
|
||||
"quest_rat_problem": {
|
||||
"quest_id": "quest_rat_problem",
|
||||
"title": {
|
||||
"en": "Rat Problem",
|
||||
"es": "Problema de Ratas"
|
||||
},
|
||||
"description": {
|
||||
"en": "Mutant rats are infesting the basement. Kill 3 of them.",
|
||||
"es": "Ratas mutantes infestan el sótano. Mata a 3 de ellas."
|
||||
},
|
||||
"giver_id": "trader_joe",
|
||||
"type": "individual",
|
||||
"repeatable": true,
|
||||
"cooldown_hours": 24,
|
||||
"objectives": [
|
||||
{
|
||||
"type": "kill_count",
|
||||
"target": "mutant_rat",
|
||||
"count": 3
|
||||
}
|
||||
],
|
||||
"rewards": {
|
||||
"xp": 50,
|
||||
"items": {
|
||||
"canned_food": 1
|
||||
}
|
||||
},
|
||||
"completion_text": {
|
||||
"en": "Thanks for clearing them out. Here's some food.",
|
||||
"es": "Gracias por limpiarlos. Aquí tienes algo de comida."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
93
gamedata/static_npcs.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"static_npcs": {
|
||||
"trader_joe": {
|
||||
"npc_id": "trader_joe",
|
||||
"name": {
|
||||
"en": "Trader Joe",
|
||||
"es": "Comerciante José"
|
||||
},
|
||||
"location_id": "residential",
|
||||
"image": "images/static_npcs/trader_joe.webp",
|
||||
"dialog": {
|
||||
"greeting": {
|
||||
"en": "Got some rare goods for sale, stranger.",
|
||||
"es": "Tengo mercancía rara a la venta, forastero."
|
||||
},
|
||||
"topics": [
|
||||
{
|
||||
"id": "lore_markets",
|
||||
"title": {
|
||||
"en": "About the markets",
|
||||
"es": "Sobre los mercados"
|
||||
},
|
||||
"text": {
|
||||
"en": "Before the fall, this place was bustling. Now, we scrape by with what we can found.",
|
||||
"es": "Antes de la caída, este lugar estaba lleno de vida. Ahora, sobrevivimos con lo que podemos encontrar."
|
||||
}
|
||||
}
|
||||
],
|
||||
"quest_offer": {
|
||||
"en": "I could use a hand with something.",
|
||||
"es": "Podría necesitar una mano con algo."
|
||||
}
|
||||
},
|
||||
"trade": {
|
||||
"enabled": true,
|
||||
"currency": "value",
|
||||
"unlimited_currency": true,
|
||||
"keep_sold_items": true,
|
||||
"buy_markup": 1.5,
|
||||
"sell_markdown": 0.5,
|
||||
"stock": [
|
||||
{
|
||||
"item_id": "water_bottle",
|
||||
"max_stock": 10,
|
||||
"restock_rate": 2,
|
||||
"infinite": false
|
||||
},
|
||||
{
|
||||
"item_id": "canned_food",
|
||||
"max_stock": 50,
|
||||
"infinite": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"mechanic_mike": {
|
||||
"npc_id": "mechanic_mike",
|
||||
"name": {
|
||||
"en": "Mechanic Mike",
|
||||
"es": "Mecánico Mike"
|
||||
},
|
||||
"location_id": "gas_station",
|
||||
"image": "images/static_npcs/mechanic_mike.webp",
|
||||
"dialog": {
|
||||
"greeting": {
|
||||
"en": "If it's broken, I might be able to fix it. Might.",
|
||||
"es": "Si está roto, tal vez pueda arreglarlo. Tal vez."
|
||||
},
|
||||
"topics": [],
|
||||
"quest_offer": {
|
||||
"en": "Need parts. Always need parts.",
|
||||
"es": "Necesito piezas. Siempre necesito piezas."
|
||||
}
|
||||
},
|
||||
"trade": {
|
||||
"enabled": true,
|
||||
"currency": "value",
|
||||
"unlimited_currency": true,
|
||||
"keep_sold_items": false,
|
||||
"buy_markup": 1.2,
|
||||
"sell_markdown": 0.6,
|
||||
"stock": [
|
||||
{
|
||||
"item_id": "scrap_metal",
|
||||
"max_stock": 20,
|
||||
"refresh_rate": 5,
|
||||
"infinite": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
extends Control
|
||||
|
||||
@onready var token_input = $VBoxContainer/HBoxContainer/TokenInput
|
||||
@onready var status_label = $VBoxContainer/ConnectionStatusLabel
|
||||
@onready var location_name_label = $VBoxContainer/LocationNameLabel
|
||||
@onready var location_image = $VBoxContainer/LocationImage
|
||||
@onready var location_desc_label = $VBoxContainer/LocationDescriptionLabel
|
||||
@onready var log_label = $VBoxContainer/LogLabel
|
||||
|
||||
var socket = WebSocketPeer.new()
|
||||
var http_request : HTTPRequest
|
||||
var is_connected_to_host = false
|
||||
|
||||
func _ready():
|
||||
log_message("Godot PoC Started")
|
||||
http_request = HTTPRequest.new()
|
||||
add_child(http_request)
|
||||
http_request.request_completed.connect(_on_image_request_completed)
|
||||
|
||||
func _process(delta):
|
||||
socket.poll()
|
||||
var state = socket.get_ready_state()
|
||||
|
||||
if state == WebSocketPeer.STATE_OPEN:
|
||||
if not is_connected_to_host:
|
||||
is_connected_to_host = true
|
||||
status_label.text = "Status: Connected"
|
||||
log_message("WebSocket Connected!")
|
||||
|
||||
while socket.get_available_packet_count():
|
||||
var packet = socket.get_packet()
|
||||
var data = packet.get_string_from_utf8()
|
||||
var json = JSON.new()
|
||||
var error = json.parse(data)
|
||||
if error == OK:
|
||||
handle_message(json.get_data())
|
||||
else:
|
||||
log_message("Error parsing JSON: " + data)
|
||||
|
||||
elif state == WebSocketPeer.STATE_CLOSED:
|
||||
if is_connected_to_host:
|
||||
is_connected_to_host = false
|
||||
status_label.text = "Status: Disconnected"
|
||||
log_message("WebSocket Disconnected")
|
||||
|
||||
func _on_connect_button_pressed():
|
||||
var token = token_input.text.strip_edges()
|
||||
if token == "":
|
||||
log_message("Please enter a token.")
|
||||
return
|
||||
|
||||
var url = "wss://api-staging.echoesoftheash.com/ws/game/" + token
|
||||
log_message("Connecting to: " + url)
|
||||
var err = socket.connect_to_url(url)
|
||||
if err != OK:
|
||||
log_message("Error connecting to URL: " + str(err))
|
||||
else:
|
||||
status_label.text = "Status: Connecting..."
|
||||
|
||||
func handle_message(msg):
|
||||
# log_message("Received: " + str(msg.get("type")))
|
||||
|
||||
if msg.get("type") == "location_update":
|
||||
var data = msg.get("data", {})
|
||||
var location = data.get("location", {})
|
||||
|
||||
if location:
|
||||
update_location_ui(location)
|
||||
|
||||
func update_location_ui(location):
|
||||
location_name_label.text = location.get("name", "Unknown Location")
|
||||
location_desc_label.text = location.get("description", "")
|
||||
|
||||
var image_url = location.get("image_url", "")
|
||||
if image_url != "":
|
||||
fetch_image(image_url)
|
||||
|
||||
func fetch_image(url):
|
||||
if url.begins_with("/"):
|
||||
url = "https://api-staging.echoesoftheash.com" + url
|
||||
|
||||
log_message("Fetching image: " + url)
|
||||
http_request.cancel_request()
|
||||
http_request.request(url)
|
||||
|
||||
func _on_image_request_completed(result, response_code, headers, body):
|
||||
if result == HTTPRequest.RESULT_SUCCESS:
|
||||
var image = Image.new()
|
||||
var error = image.load_png_from_buffer(body)
|
||||
if error != OK:
|
||||
error = image.load_jpg_from_buffer(body)
|
||||
if error != OK:
|
||||
error = image.load_webp_from_buffer(body)
|
||||
|
||||
if error == OK:
|
||||
var texture = ImageTexture.create_from_image(image)
|
||||
location_image.texture = texture
|
||||
else:
|
||||
log_message("Failed to load image texture")
|
||||
else:
|
||||
log_message("Failed to fetch image. Code: " + str(response_code))
|
||||
|
||||
func log_message(text):
|
||||
print(text)
|
||||
log_label.text += text + "\n"
|
||||
@@ -1,69 +0,0 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://c8q7y6x5z4w3"]
|
||||
|
||||
[ext_resource type="Script" path="res://Main.gd" id="1_m4i3n"]
|
||||
|
||||
[node name="Main" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_m4i3n")
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="HeaderLabel" type="Label" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Echoes of the Ashes - Godot PoC"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="TokenInput" type="LineEdit" parent="VBoxContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Enter Auth Token"
|
||||
|
||||
[node name="ConnectButton" type="Button" parent="VBoxContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Connect via WebSocket"
|
||||
|
||||
[node name="ConnectionStatusLabel" type="Label" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Status: Disconnected"
|
||||
|
||||
[node name="HSeparator" type="HSeparator" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="LocationNameLabel" type="Label" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="LocationImage" type="TextureRect" parent="VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 300)
|
||||
layout_mode = 2
|
||||
expand_mode = 1
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="LocationDescriptionLabel" type="RichTextLabel" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
fit_content = true
|
||||
|
||||
[node name="HSeparator2" type="HSeparator" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="LogLabel" type="RichTextLabel" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
text = "Logs will appear here...
|
||||
"
|
||||
|
||||
[connection signal="pressed" from="VBoxContainer/HBoxContainer/ConnectButton" to="." method="_on_connect_button_pressed"]
|
||||
@@ -1 +0,0 @@
|
||||
<svg height="128" width="128" xmlns="http://www.w3.org/2000/svg"><rect fill="#364966" height="128" width="128" rx="20" ry="20"/><path d="M64 16 L16 112 L112 112 Z" fill="#ffffff"/></svg>
|
||||
|
Before Width: | Height: | Size: 187 B |
@@ -1,13 +0,0 @@
|
||||
config_version=5
|
||||
|
||||
[application]
|
||||
|
||||
config/name="Echoes of the Ashes PoC"
|
||||
config/features=PackedStringArray("4.5", "Forward Plus")
|
||||
run/main_scene="res://Main.tscn"
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1280
|
||||
window/size/viewport_height=720
|
||||
BIN
images-source/characters/default.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 678 KiB After Width: | Height: | Size: 678 KiB |
|
Before Width: | Height: | Size: 881 KiB After Width: | Height: | Size: 881 KiB |
|
Before Width: | Height: | Size: 602 KiB After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 367 KiB After Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 528 KiB After Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 597 KiB After Width: | Height: | Size: 597 KiB |
|
Before Width: | Height: | Size: 859 KiB After Width: | Height: | Size: 859 KiB |
|
Before Width: | Height: | Size: 661 KiB After Width: | Height: | Size: 661 KiB |
|
Before Width: | Height: | Size: 597 KiB After Width: | Height: | Size: 597 KiB |
|
Before Width: | Height: | Size: 758 KiB After Width: | Height: | Size: 758 KiB |
|
Before Width: | Height: | Size: 552 KiB After Width: | Height: | Size: 552 KiB |
|
Before Width: | Height: | Size: 677 KiB After Width: | Height: | Size: 677 KiB |
|
Before Width: | Height: | Size: 804 KiB After Width: | Height: | Size: 804 KiB |
|
Before Width: | Height: | Size: 507 KiB After Width: | Height: | Size: 507 KiB |
|
Before Width: | Height: | Size: 538 KiB After Width: | Height: | Size: 538 KiB |
|
Before Width: | Height: | Size: 947 KiB After Width: | Height: | Size: 947 KiB |
|
Before Width: | Height: | Size: 698 KiB After Width: | Height: | Size: 698 KiB |
|
Before Width: | Height: | Size: 822 KiB After Width: | Height: | Size: 822 KiB |
|
Before Width: | Height: | Size: 535 KiB After Width: | Height: | Size: 535 KiB |
|
Before Width: | Height: | Size: 961 KiB After Width: | Height: | Size: 961 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 905 KiB After Width: | Height: | Size: 905 KiB |
|
Before Width: | Height: | Size: 980 KiB After Width: | Height: | Size: 980 KiB |
|
Before Width: | Height: | Size: 803 KiB After Width: | Height: | Size: 803 KiB |
|
Before Width: | Height: | Size: 864 KiB After Width: | Height: | Size: 864 KiB |