32 Commits

Author SHA1 Message Date
Joan
d38d4cc288 Update 2026-02-23 15:42:21 +01:00
Joan
a725ae5836 1080p layout fixes: responsive location sizing, dynamic entity limits, compass cleanup 2026-02-10 17:43:09 +01:00
Joan
bba5d1d9dd chore: save progress before layout changes 2026-02-10 10:48:53 +01:00
Joan
70dc35b4b2 Added trading and quests, checkpoint push 2026-02-08 20:18:42 +01:00
Joan
8820cd897e fix(ui): disable scrolling when dropdown is open 2026-02-07 23:30:33 +01:00
Joan
596a3ce010 fix(ui): force 1:1 ratio and padding for enemy images 2026-02-07 23:17:40 +01:00
Joan
05136e9708 fix(ui): ensure dropdown position stays sticky on interaction 2026-02-07 23:08:17 +01:00
Joan
b82f3c4855 fix(ui): adjust dropdown offset when flipped up 2026-02-07 23:03:58 +01:00
Joan
2e9a833a1a refactor(ui): auto-detect dropdown position from last click 2026-02-07 23:01:34 +01:00
Joan
6f1e8c56f2 fix(ui): improved dropdown positioning, added rich data to corpse loot 2026-02-07 22:55:12 +01:00
Joan
0e0ac10b20 fix(ui): improve location view styling (centered enemy images, correct dropdown pos) 2026-02-07 22:48:11 +01:00
Joan
c9d180379a fix(ui): stable sort for ground items, improved loot modal (images/desc, outside click) 2026-02-07 22:44:51 +01:00
Joan
ff9472048d fix(ui): add x10 drop option and prevent inventory dropdown closing 2026-02-07 22:39:02 +01:00
Joan
dae6e6df2d fix(css): refine location grid layout, remove border-radius, expand corpse loot view 2026-02-07 22:25:03 +01:00
Joan
d791fcec7e fix(css): remove min-width from grid cards to fix layout 2026-02-07 22:17:22 +01:00
Joan
e6e1d3f312 fix(build): remove unused useRef from LocationView 2026-02-07 22:08:22 +01:00
Joan
eb75ee5b33 feat: Implement Inventory Grid View and GameButton 2026-02-07 22:00:14 +01:00
Joan
dcfc91b82b Refactor global UI to Tech-HUD style (clip-path) 2026-02-06 11:51:58 +01:00
Joan
fb92f28a69 Unify search bar styles across components 2026-02-06 11:45:25 +01:00
Joan
539377e63d Fix GameTooltip blocking issue and translate Found string 2026-02-06 11:23:32 +01:00
Joan
173d6c9117 UI Refinements Round 2: CSS fixes, Grid Layout, Sidebar Images 2026-02-05 17:53:00 +01:00
Joan
ccf9ba3e28 Push 2026-02-05 16:09:34 +01:00
Joan
1b7ffd614d Backup before cleanup 2026-02-05 15:00:49 +01:00
Joan
e6747b1d05 Pre-combat-improvements: Combat animations, flee fixes, corpse logic updates 2026-02-03 19:48:37 +01:00
Joan
0b0a23f500 WIP: Current state before PVP combat investigation 2026-02-03 12:19:28 +01:00
Joan
7f42fd6b7f Fix critical bug in Combat.tsx: message split was using escaped backslash instead of newline 2026-01-09 11:39:08 +01:00
Joan
f986fa18a0 Combat frontend rewrite: Clean architecture with structured messages, animations, and i18n 2026-01-09 11:14:40 +01:00
Joan
2875e72b20 Pre-combat-rewrite: Backup current state before comprehensive combat frontend rewrite 2026-01-09 11:07:37 +01:00
Joan
dc438ae4c1 WIP: i18n implementation - fix items.json syntax, add useTranslation hooks to components 2026-01-07 15:12:01 +01:00
Joan
ea594f80c6 Release v0.2.13: Update package-lock.json and CI config 2025-12-30 19:16:56 +01:00
Joan
ee55c5f887 Release v0.2.12: Update package-lock.json and CI config 2025-12-30 19:08:56 +01:00
Joan
2766b4035f Release v0.2.11: Update package-lock.json and CI config 2025-12-30 18:58:07 +01:00
271 changed files with 18838 additions and 32190 deletions

56
CLAUDE.md Normal file
View 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.

View File

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

View File

@@ -20,6 +20,7 @@ COPY data/ ./data/
COPY gamedata/ ./gamedata/
# Copy migration scripts
COPY migrations/ ./migrations/
COPY migrate_*.py ./
# Copy startup script

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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'])
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ import logging
from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import *
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, 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
}

View File

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

View File

@@ -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, get_locale_string
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'])
@@ -234,8 +281,42 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
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']:
@@ -274,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
})
@@ -313,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
}
@@ -325,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
@@ -391,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)
@@ -411,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
@@ -429,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
})
@@ -465,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(
@@ -516,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,
@@ -526,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:
@@ -682,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:
@@ -719,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"""
@@ -753,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:
@@ -854,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
@@ -862,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
}
@@ -873,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']
@@ -889,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'],
@@ -922,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)
@@ -939,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 {
@@ -951,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']
@@ -976,7 +1097,8 @@ async def interact(
interact_req.interactable_id,
interact_req.action_id,
location,
ITEMS_MANAGER
ITEMS_MANAGER,
locale
)
if not result['success']:
@@ -1026,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()
}
@@ -1035,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"""
@@ -1050,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']:
@@ -1073,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
@@ -1100,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']
@@ -1159,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"
@@ -1176,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']:
@@ -1196,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()
@@ -1318,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"""
@@ -1325,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:
@@ -1333,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")
@@ -1382,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()
@@ -1392,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)
}

View File

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

View File

@@ -0,0 +1,6 @@
"""
Global game constants
"""
# PvP Combat
PVP_TURN_TIMEOUT = 60

View File

@@ -15,6 +15,147 @@ def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> st
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.
@@ -250,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),
}

View File

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

View File

@@ -35,6 +35,8 @@ class Interactable:
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)
@@ -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
build.sh Executable file
View 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
View 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()

View File

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

View File

@@ -172,7 +172,7 @@
"en": "A broken vending machine, glass shattered.",
"es": "Una máquina expendedora rota, el vidrio está roto."
},
"image_path": "images/interactables/vending.webp",
"image_path": "images/interactables/vending_machine.webp",
"actions": {
"break": {
"id": "break",

View File

@@ -13,7 +13,8 @@
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"value": 10
},
"rusty_nails": {
"name": {
@@ -28,12 +29,13 @@
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"value": 10
},
"wood_planks": {
"name": {
"en": "Wood Planks",
"es": "Tablillas de madera"
"es": "Tablas de madera"
},
"weight": 3.0,
"volume": 2.0,
@@ -43,7 +45,8 @@
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"value": 10
},
"cloth_scraps": {
"name": {
@@ -58,7 +61,8 @@
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"value": 10
},
"cloth": {
"name": {
@@ -86,7 +90,8 @@
"item_id": "knife",
"durability_cost": 1
}
]
],
"value": 10
},
"plastic_bottles": {
"name": {
@@ -101,7 +106,8 @@
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"value": 10
},
"bone": {
"name": {
@@ -116,7 +122,8 @@
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"value": 10
},
"raw_meat": {
"name": {
@@ -131,7 +138,8 @@
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"value": 10
},
"animal_hide": {
"name": {
@@ -146,7 +154,8 @@
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"value": 10
},
"mutant_tissue": {
"name": {
@@ -161,7 +170,8 @@
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"value": 10
},
"infected_tissue": {
"name": {
@@ -176,7 +186,8 @@
"description": {
"en": "A raw material used for crafting and upgrades.",
"es": "Un material bruto utilizado para la fabricación y las mejoras."
}
},
"value": 10
},
"stale_chocolate_bar": {
"name": {
@@ -192,7 +203,8 @@
"description": {
"en": "Can be consumed to restore health or stamina.",
"es": "Se puede consumir para restaurar salud o stamina."
}
},
"value": 10
},
"canned_beans": {
"name": {
@@ -209,7 +221,8 @@
"description": {
"en": "Can be consumed to restore health or stamina.",
"es": "Se puede consumir para restaurar salud o stamina."
}
},
"value": 10
},
"canned_food": {
"name": {
@@ -226,7 +239,8 @@
"description": {
"en": "Can be consumed to restore health or stamina.",
"es": "Se puede consumir para restaurar salud o stamina."
}
},
"value": 10
},
"bottled_water": {
"name": {
@@ -242,7 +256,8 @@
"description": {
"en": "Can be consumed to restore health or stamina.",
"es": "Se puede consumir para restaurar salud o stamina."
}
},
"value": 10
},
"water_bottle": {
"name": {
@@ -258,7 +273,8 @@
"description": {
"en": "Can be consumed to restore health or stamina.",
"es": "Se puede consumir para restaurar salud o stamina."
}
},
"value": 10
},
"energy_bar": {
"name": {
@@ -274,7 +290,8 @@
"description": {
"en": "Can be consumed to restore health or stamina.",
"es": "Se puede consumir para restaurar salud o stamina."
}
},
"value": 10
},
"mystery_pills": {
"name": {
@@ -290,7 +307,8 @@
"description": {
"en": "Can be consumed to restore health or stamina.",
"es": "Se puede consumir para restaurar salud o stamina."
}
},
"value": 10
},
"first_aid_kit": {
"name": {
@@ -306,7 +324,8 @@
"type": "consumable",
"hp_restore": 50,
"emoji": "🩹",
"image_path": "images/items/first_aid_kit.webp"
"image_path": "images/items/first_aid_kit.webp",
"value": 10
},
"bandage": {
"name": {
@@ -314,16 +333,28 @@
"es": "Vendaje"
},
"description": {
"en": "Clean cloth bandages for treating minor wounds. Can stop bleeding.",
"es": "Vendajes limpios de tela para tratar heridas menores. Pueden detener la sangrado."
"en": "Clean cloth bandages for treating minor wounds. Applies regeneration and stops bleeding.",
"es": "Vendajes limpios de tela para tratar heridas menores. Aplica regeneración y detiene el sangrado."
},
"weight": 0.1,
"volume": 0.1,
"type": "consumable",
"hp_restore": 15,
"treats": "Bleeding",
"effects": {
"status_effect": {
"name": "regeneration",
"icon": "❤️",
"type": "buff",
"damage_per_tick": -5,
"ticks": 3,
"value": 15
},
"cures": [
"bleeding"
]
},
"emoji": "🩹",
"image_path": "images/items/bandage.webp"
"image_path": "images/items/bandage.webp",
"value": 10
},
"medical_supplies": {
"name": {
@@ -339,7 +370,8 @@
"type": "consumable",
"hp_restore": 40,
"emoji": "⚕️",
"image_path": "images/items/medical_supplies.webp"
"image_path": "images/items/medical_supplies.webp",
"value": 10
},
"antibiotics": {
"name": {
@@ -356,7 +388,8 @@
"hp_restore": 20,
"treats": "Infected",
"emoji": "💊",
"image_path": "images/items/antibiotics.webp"
"image_path": "images/items/antibiotics.webp",
"value": 10
},
"rad_pills": {
"name": {
@@ -373,7 +406,8 @@
"hp_restore": 5,
"treats": "Radiation",
"emoji": "☢️",
"image_path": "images/items/rad_pills.webp"
"image_path": "images/items/rad_pills.webp",
"value": 10
},
"tire_iron": {
"name": {
@@ -397,7 +431,8 @@
"damage_max": 5
},
"emoji": "🔧",
"image_path": "images/items/tire_iron.webp"
"image_path": "images/items/tire_iron.webp",
"value": 10
},
"baseball_bat": {
"name": {
@@ -417,7 +452,8 @@
"stats": {
"damage_min": 5,
"damage_max": 8
}
},
"value": 10
},
"rusty_knife": {
"name": {
@@ -453,16 +489,17 @@
"damage_max": 5
},
"emoji": "🔪",
"image_path": "images/items/rusty_knife.webp"
"image_path": "images/items/rusty_knife.webp",
"value": 10
},
"knife": {
"name": {
"en": "Knife",
"es": ""
"es": "Cuchillo"
},
"description": {
"en": "A sharp survival knife in decent condition.",
"es": ""
"es": "Un cuchillo de supervivencia afilado en buen estado."
},
"weight": 0.3,
"volume": 0.2,
@@ -542,16 +579,17 @@
}
},
"emoji": "🔪",
"image_path": "images/items/knife.webp"
"image_path": "images/items/knife.webp",
"value": 10
},
"rusty_pipe": {
"name": {
"en": "Rusty Pipe",
"es": ""
"es": "Tubería oxidada"
},
"description": {
"en": "Heavy metal pipe. Crude but effective.",
"es": ""
"es": "Tubería de metal oxidada. Bruta pero efectiva."
},
"weight": 1.5,
"volume": 0.8,
@@ -562,16 +600,17 @@
"stats": {
"damage_min": 5,
"damage_max": 8
}
},
"value": 10
},
"tattered_rucksack": {
"name": {
"en": "Tattered Rucksack",
"es": ""
"es": "Mochila rústica"
},
"description": {
"en": "An old backpack with torn straps. Still functional.",
"es": ""
"es": "Una mochila vieja con tirantes rotos. Todavía funcional."
},
"weight": 1.0,
"volume": 0.5,
@@ -609,16 +648,17 @@
"volume_capacity": 10
},
"emoji": "🎒",
"image_path": "images/items/tattered_rucksack.webp"
"image_path": "images/items/tattered_rucksack.webp",
"value": 10
},
"hiking_backpack": {
"name": {
"en": "Hiking Backpack",
"es": ""
"es": "Mochila de senderismo"
},
"description": {
"en": "A quality backpack with multiple compartments.",
"es": ""
"es": "Una mochila de calidad con múltiples compartimentos."
},
"weight": 1.5,
"volume": 0.7,
@@ -645,16 +685,17 @@
"volume_capacity": 20
},
"emoji": "🎒",
"image_path": "images/items/hiking_backpack.webp"
"image_path": "images/items/hiking_backpack.webp",
"value": 10
},
"flashlight": {
"name": {
"en": "Flashlight",
"es": ""
"es": "Linterna"
},
"description": {
"en": "A battery-powered flashlight. Batteries low but working.",
"es": ""
"es": "Una linterna alimentada por pilas. Las pilas están casi agotadas pero funcionan."
},
"weight": 0.3,
"volume": 0.2,
@@ -665,12 +706,13 @@
"stats": {
"damage_min": 5,
"damage_max": 8
}
},
"value": 10
},
"old_photograph": {
"name": {
"en": "Old Photograph",
"es": ""
"es": "Fotografía vieja"
},
"weight": 0.01,
"volume": 0.01,
@@ -679,13 +721,14 @@
"image_path": "images/items/old_photograph.webp",
"description": {
"en": "A useful old photograph.",
"es": ""
}
"es": "Una fotografía vieja útil."
},
"value": 10
},
"key_ring": {
"name": {
"en": "Key Ring",
"es": ""
"es": "Anillo de llaves"
},
"weight": 0.1,
"volume": 0.05,
@@ -694,17 +737,18 @@
"image_path": "images/items/key_ring.webp",
"description": {
"en": "A useful key ring.",
"es": ""
}
"es": "Un anillo de llaves útil."
},
"value": 10
},
"makeshift_spear": {
"name": {
"en": "Makeshift Spear",
"es": ""
"es": "Pica improvisado"
},
"description": {
"en": "A crude spear made from a sharpened stick and scrap metal.",
"es": ""
"es": "Una pica improvisada hecha de un palo afilado y metal desechado."
},
"weight": 1.2,
"volume": 2.0,
@@ -746,16 +790,17 @@
"damage_max": 7
},
"emoji": "⚔️",
"image_path": "images/items/makeshift_spear.webp"
"image_path": "images/items/makeshift_spear.webp",
"value": 10
},
"reinforced_bat": {
"name": {
"en": "Reinforced Bat",
"es": ""
"es": "Bate de béisbol reforzado"
},
"description": {
"en": "A wooden bat wrapped with scrap metal and nails. Brutal.",
"es": ""
"es": "Un bate de béisbol envuelto con metal desechado y clavos. Brutal."
},
"weight": 1.8,
"volume": 1.5,
@@ -803,16 +848,17 @@
}
},
"emoji": "🏸",
"image_path": "images/items/reinforced_bat.webp"
"image_path": "images/items/reinforced_bat.webp",
"value": 10
},
"leather_vest": {
"name": {
"en": "Leather Vest",
"es": ""
"es": "Chaleco de cuero"
},
"description": {
"en": "A makeshift vest crafted from leather scraps. Provides basic protection.",
"es": ""
"es": "Un chaleco improvisado hecho de cuero desechado. Proporciona protección básica."
},
"weight": 1.5,
"volume": 1.0,
@@ -854,16 +900,17 @@
"hp_bonus": 10
},
"emoji": "🦺",
"image_path": "images/items/leather_vest.webp"
"image_path": "images/items/leather_vest.webp",
"value": 10
},
"cloth_bandana": {
"name": {
"en": "Cloth Bandana",
"es": ""
"es": "Banda de tela"
},
"description": {
"en": "A simple cloth head covering. Keeps the sun and dust out.",
"es": ""
"es": "Una cobertura simple para la cabeza. Mantiene el sol y la arena fuera."
},
"weight": 0.1,
"volume": 0.1,
@@ -892,16 +939,17 @@
"armor": 1
},
"emoji": "🧣",
"image_path": "images/items/cloth_bandana.webp"
"image_path": "images/items/cloth_bandana.webp",
"value": 10
},
"sturdy_boots": {
"name": {
"en": "Sturdy Boots",
"es": ""
"es": "Botas fuertes"
},
"description": {
"en": "Reinforced boots for traversing the wasteland.",
"es": ""
"es": "Botas reforzadas para cruzar el desierto."
},
"weight": 1.0,
"volume": 0.8,
@@ -943,16 +991,17 @@
"stamina_bonus": 5
},
"emoji": "🥾",
"image_path": "images/items/sturdy_boots.webp"
"image_path": "images/items/sturdy_boots.webp",
"value": 10
},
"padded_pants": {
"name": {
"en": "Padded Pants",
"es": ""
"es": "Pantalones reforzados"
},
"description": {
"en": "Pants reinforced with extra padding for protection.",
"es": ""
"es": "Pantalones reforzados con un relleno extra para protección."
},
"weight": 0.8,
"volume": 0.6,
@@ -990,16 +1039,17 @@
"hp_bonus": 5
},
"emoji": "👖",
"image_path": "images/items/padded_pants.webp"
"image_path": "images/items/padded_pants.webp",
"value": 10
},
"reinforced_pack": {
"name": {
"en": "Reinforced Pack",
"es": ""
"es": "Mochila reforzada"
},
"description": {
"en": "A custom-built backpack with metal frame and extra pockets.",
"es": ""
"es": "Una mochila personalizada con un marco de metal y bolsillos extra."
},
"weight": 2.0,
"volume": 0.9,
@@ -1080,16 +1130,17 @@
"volume_capacity": 30
},
"emoji": "🎒",
"image_path": "images/items/reinforced_pack.webp"
"image_path": "images/items/reinforced_pack.webp",
"value": 10
},
"hammer": {
"name": {
"en": "Hammer",
"es": ""
"es": "Martillo"
},
"description": {
"en": "A basic tool for crafting and repairs. Essential for any survivor.",
"es": ""
"es": "Una herramienta básica para la fabricación y reparaciones. Esencial para cualquier superviviente."
},
"weight": 0.8,
"volume": 0.4,
@@ -1119,16 +1170,17 @@
],
"repair_percentage": 30,
"emoji": "🔨",
"image_path": "images/items/hammer.webp"
"image_path": "images/items/hammer.webp",
"value": 10
},
"screwdriver": {
"name": {
"en": "Screwdriver",
"es": ""
"es": "Destornillador"
},
"description": {
"en": "A flathead screwdriver. Useful for repairs and scavenging.",
"es": ""
"es": "Un destornillador de cabeza plana. Útil para reparaciones y recogida de material."
},
"weight": 0.2,
"volume": 0.2,
@@ -1162,7 +1214,137 @@
"stats": {
"damage_min": 5,
"damage_max": 8
}
},
"value": 10
},
"pipe_bomb": {
"name": {
"en": "Pipe Bomb",
"es": "Bomba improvisada"
},
"type": "throwable",
"weight": 0.5,
"volume": 0.3,
"emoji": "💣",
"image_path": "images/items/pipe_bomb.webp",
"description": {
"en": "An improvised explosive. Deals heavy damage when thrown.",
"es": "Un explosivo improvisado. Causa gran daño cuando se lanza."
},
"stackable": true,
"combat_usable": true,
"combat_effects": {
"damage_min": 15,
"damage_max": 25
},
"value": 10
},
"molotov_cocktail": {
"name": {
"en": "Molotov Cocktail",
"es": "Cóctel Molotov"
},
"type": "throwable",
"weight": 0.4,
"volume": 0.3,
"emoji": "🔥",
"image_path": "images/items/molotov.webp",
"description": {
"en": "A bottle filled with flammable liquid. Sets the target on fire.",
"es": "Una botella llena de líquido inflamable. Prende fuego al objetivo."
},
"stackable": true,
"combat_usable": true,
"combat_effects": {
"damage_min": 10,
"damage_max": 15,
"status": {
"name": "burning",
"icon": "🔥",
"damage_per_tick": 3,
"ticks": 3,
"persist_after_combat": true
}
},
"value": 10
},
"smoke_bomb": {
"name": {
"en": "Smoke Bomb",
"es": "Bomba de humo"
},
"type": "throwable",
"weight": 0.3,
"volume": 0.2,
"emoji": "💨",
"image_path": "images/items/smoke_bomb.webp",
"description": {
"en": "Creates a smoke screen. Greatly increases flee chance for 1 turn.",
"es": "Crea una cortina de humo. Aumenta la probabilidad de huir por 1 turno."
},
"stackable": true,
"combat_usable": true,
"combat_only": true,
"combat_effects": {
"status": {
"name": "smoke_cover",
"icon": "💨",
"value": 50,
"ticks": 1,
"persist_after_combat": false
}
},
"value": 10
},
"stim_pack": {
"name": {
"en": "Stim Pack",
"es": "Estimulante"
},
"type": "consumable",
"weight": 0.2,
"volume": 0.1,
"emoji": "💉",
"image_path": "images/items/stim_pack.webp",
"description": {
"en": "A combat stimulant that instantly restores health. Only usable in combat.",
"es": "Un estimulante de combate que restaura salud instantáneamente. Solo usable en combate."
},
"stackable": true,
"consumable": true,
"combat_usable": true,
"combat_only": true,
"hp_restore": 20,
"value": 10
},
"adrenaline_shot": {
"name": {
"en": "Adrenaline Shot",
"es": "Inyección de adrenalina"
},
"type": "consumable",
"weight": 0.1,
"volume": 0.1,
"emoji": "⚡",
"image_path": "images/items/adrenaline.webp",
"description": {
"en": "Increases damage output for 2 turns. Only usable in combat.",
"es": "Aumenta el daño durante 2 turnos. Solo usable en combate."
},
"stackable": true,
"consumable": true,
"combat_usable": true,
"combat_only": true,
"combat_effects": {
"status": {
"name": "empowered",
"icon": "⚡",
"value": 25,
"ticks": 2,
"persist_after_combat": false
}
},
"value": 10
}
}
}

View File

@@ -341,16 +341,16 @@
"text": {
"crit_failure": {
"en": "The floor collapses beneath you! (-10 HP)",
"es": ""
"es": "¡El suelo se derrumba bajo ti! (-10 HP)"
},
"crit_success": "",
"failure": {
"en": "The house has already been thoroughly looted. Nothing remains.",
"es": ""
"es": "La casa ya ha sido despojada de todo. No queda nada."
},
"success": {
"en": "You find some useful supplies: [Canned Beans], [Bottled Water], and [Cloth Scraps]!",
"es": ""
"en": "You find some useful supplies!",
"es": "¡Encuentras algunos suministros útiles!"
}
}
}
@@ -633,7 +633,7 @@
"es": ""
},
"success": {
"en": "You find some tools: [Rusty Nails], [Wood Planks], and a [Flashlight]!",
"en": "You find some tools!",
"es": ""
}
}
@@ -793,11 +793,11 @@
"id": "warehouse",
"name": {
"en": "🏭 Warehouse District",
"es": ""
"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": ""
"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,
@@ -897,11 +897,11 @@
"id": "warehouse_interior",
"name": {
"en": "📦 Warehouse Interior",
"es": ""
"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": ""
"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,
@@ -953,11 +953,11 @@
"id": "subway",
"name": {
"en": "🚇 Subway Station Entrance",
"es": ""
"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": ""
"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,
@@ -1104,11 +1104,11 @@
"id": "subway_tunnels",
"name": {
"en": "🚊 Subway Tunnels",
"es": ""
"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": ""
"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,
@@ -1167,11 +1167,11 @@
"id": "office_building",
"name": {
"en": "🏢 Office Building",
"es": ""
"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": ""
"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,
@@ -1229,11 +1229,11 @@
"id": "office_interior",
"name": {
"en": "💼 Office Floors",
"es": ""
"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": ""
"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,
@@ -1297,11 +1297,11 @@
"id": "location_1760791397492",
"name": {
"en": "Subway Section A",
"es": ""
"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": ""
"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,

View File

@@ -48,17 +48,20 @@
"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": {
"en": "Raider Scout",
"es": ""
"es": "Explorador"
},
"description": {
"en": "A lone raider wearing makeshift armor. They eye you with hostile intent.",
"es": ""
"es": "Un explorador solitario con ropa improvisada. Te mira con intención hostil."
},
"emoji": "🏴‍☠️",
"hp_min": 30,
@@ -110,17 +113,20 @@
"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": {
"en": "Mutant Rat",
"es": ""
"es": "Rata mutante"
},
"description": {
"en": "A grotesquely large rat, its fur patchy and eyes glowing with unnatural light.",
"es": ""
"es": "Una rata grotescamente grande, su pelaje es desgarrado y sus ojos brillan con luz unnatural."
},
"emoji": "🐀",
"hp_min": 10,
@@ -154,17 +160,20 @@
"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": {
"en": "Infected Human",
"es": ""
"es": "Humano infectado"
},
"description": {
"en": "Once human, now something else. Their movements are jerky and their skin shows signs of advanced infection.",
"es": ""
"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,
@@ -204,17 +213,20 @@
"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": {
"en": "Hostile Scavenger",
"es": ""
"es": "Superviviente hostil"
},
"description": {
"en": "Another survivor, but this one sees you as competition. They won't share territory.",
"es": ""
"es": "Otro superviviente, eres su competencia. No compartirá el territorio."
},
"emoji": "💀",
"hp_min": 25,
@@ -278,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": {

68
gamedata/quests.json Normal file
View 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
View 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
}
]
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -10,13 +10,14 @@ set -e
SOURCE_DIR="."
OUTPUT_DIR="../images"
ITEM_SIZE="256x256"
PORTRAIT_SIZE="512x512"
echo "🔄 Starting image conversion..."
echo " Source: $SOURCE_DIR"
echo " Output: $OUTPUT_DIR"
echo ""
for category in items locations npcs interactables; do
for category in items locations npcs interactables characters placeholder static_npcs; do
src="$SOURCE_DIR/$category"
out="$OUTPUT_DIR/$category"
@@ -38,17 +39,30 @@ for category in items locations npcs interactables; do
continue
fi
if [[ "$category" == "items" ]]; then
if [[ "$category" == "items" || "$category" == "placeholder" || "$category" == "static_npcs" || "$category" == "npcs" ]]; then
# Special processing for items: remove white background and resize
echo " ➜ Converting item: $filename"
tmp="/tmp/${base}_clean.png"
convert "$img" -fuzz 10% -transparent white -resize "$ITEM_SIZE" "$tmp"
if [[ "$category" == "static_npcs" ]]; then
convert "$img" -fuzz 10% -transparent white -resize "$PORTRAIT_SIZE" "$tmp"
elif [[ "$category" == "items" || "$category" == "placeholder" ]]; then
convert "$img" -fuzz 10% -transparent white -resize "$ITEM_SIZE" "$tmp"
else
convert "$img" -fuzz 10% -transparent white "$tmp"
fi
cwebp "$tmp" -q 85 -o "$out_file" >/dev/null
rm "$tmp"
else
# Standard conversion for other categories
# If locations or interactables, crop to 16:9
tmp="/tmp/${base}_clean.png"
if [[ "$category" == "locations" || "$category" == "interactables" ]]; then
convert "$img" -resize "16:9" "$tmp"
else
convert "$img" "$tmp"
fi
echo " ➜ Converting: $filename"
cwebp "$img" -q 85 -o "$out_file" >/dev/null
cwebp "$tmp" -q 85 -o "$out_file" >/dev/null
fi
done
done

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 735 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 686 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

BIN
images/items/stimpack.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 180 KiB

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