WIP: Current state before PVP combat investigation

This commit is contained in:
Joan
2026-02-03 12:19:28 +01:00
parent 7f42fd6b7f
commit 0b0a23f500
36 changed files with 2423 additions and 1472 deletions

View File

@@ -2190,3 +2190,128 @@ 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
async def create_pvp_combat(
attacker_id: int,
defender_id: int,
location_id: str,
turn_timeout: int = 300
) -> Dict[str, Any]:
"""Create a new PVP combat encounter."""
import time
async with DatabaseSession() as session:
current_time = time.time()
# Get names for denormalization
attacker_res = await session.execute(select(characters.c.name).where(characters.c.id == attacker_id))
defender_res = await session.execute(select(characters.c.name).where(characters.c.id == defender_id))
attacker_name = attacker_res.scalar() or "Unknown"
defender_name = defender_res.scalar() or "Unknown"
stmt = insert(pvp_combats).values(
attacker_character_id=attacker_id,
defender_character_id=defender_id,
attacker_name=attacker_name,
defender_name=defender_name,
location_id=location_id,
started_at=current_time,
updated_at=current_time,
turn='defender', # Defender goes first usually, or random? 'initiator pays price?'
# Requirement says: "You have initiated combat... They get the first turn."
turn_started_at=current_time,
turn_timeout_seconds=turn_timeout,
attacker_acknowledged=False,
defender_acknowledged=False
).returning(pvp_combats)
result = await session.execute(stmt)
await session.commit()
return dict(result.fetchone()._mapping)
async def update_pvp_combat(combat_id: int, updates: Dict[str, Any]) -> bool:
"""Update PVP combat state."""
import time
updates['updated_at'] = time.time()
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,7 +6,7 @@ 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
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, locale: str = 'en') -> Tuple[bool, str, Optional[str], int, int]:
@@ -67,7 +67,7 @@ async def move_player(player_id: int, direction: str, locations: Dict, locale: s
# 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_character(
@@ -81,7 +81,7 @@ async def move_player(player_id: int, direction: str, locations: Dict, locale: s
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.
@@ -92,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:
@@ -112,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']}")
@@ -130,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.
@@ -148,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
@@ -158,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
@@ -173,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
@@ -199,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
@@ -219,7 +220,7 @@ async def interact_with_object(
if not item:
continue
item_name = get_locale_string(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)
@@ -240,7 +241,7 @@ async def interact_with_object(
max_durability=item.durability,
tier=getattr(item, 'tier', None)
)
items_found.append(f"{emoji} {get_locale_string(item_name)}")
items_found.append(f"{emoji} {item_name}")
current_weight += item.weight
current_volume += item.volume
else:
@@ -255,7 +256,7 @@ async def interact_with_object(
unique_stats=base_stats
)
await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id)
items_dropped.append(f"{emoji} {get_locale_string(item_name)}")
items_dropped.append(f"{emoji} {item_name}")
else:
# Stackable items - process as before
item_weight = item.weight * quantity
@@ -265,13 +266,13 @@ async def interact_with_object(
current_volume + item_volume <= max_volume):
# Add to inventory
await db.add_item_to_inventory(player_id, item_id, quantity)
items_found.append(f"{emoji} {get_locale_string(item_name)} x{quantity}")
items_found.append(f"{emoji} {item_name} x{quantity}")
current_weight += item_weight
current_volume += item_volume
else:
# Drop to ground
await db.drop_item_to_world(item_id, quantity, player['location_id'])
items_dropped.append(f"{emoji} {get_locale_string(item_name)} x{quantity}")
items_dropped.append(f"{emoji} {item_name} x{quantity}")
# Apply damage
if damage_taken > 0:
@@ -286,9 +287,9 @@ async def interact_with_object(
await db.set_interactable_cooldown(interactable_id, action_id, 60)
# Build message
final_message = get_locale_string(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,
@@ -302,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}
@@ -320,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)
@@ -328,7 +329,7 @@ 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 = {}
@@ -366,7 +367,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)})"
@@ -377,7 +378,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.
@@ -389,7 +390,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
@@ -402,7 +403,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
@@ -423,13 +424,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
@@ -449,7 +450,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}"
}
@@ -538,19 +539,16 @@ 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
# 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:
@@ -558,17 +556,22 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
intent_type = current_intent_str
message = ""
messages = []
actual_damage = 0
# EXECUTE INTENT
if intent_type == 'defend':
# NPC defends - maybe heals or takes less damage next turn?
# For simplicity: Heals 5% HP
# 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})
message = f"{get_locale_string(npc_def.name)} defends and recovers {heal_amount} HP!"
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)
@@ -577,54 +580,78 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
actual_damage = max(1, npc_damage - armor_absorbed)
new_player_hp = max(0, player['hp'] - actual_damage)
message = f"{get_locale_string(npc_def.name)} uses a SPECIAL ATTACK for {npc_damage} damage!"
if armor_absorbed > 0:
message += f" (Armor absorbed {armor_absorbed})"
messages.append(create_combat_message(
"enemy_special",
origin="enemy",
npc_name=npc_def.name,
damage=npc_damage,
armor_absorbed=armor_absorbed
))
if broken_armor:
for armor in broken_armor:
message += f"\n💔 Your {armor['emoji']} {armor['name']} broke!"
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)
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:
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3
if is_enraged:
npc_damage = int(npc_damage * 1.5)
message = f"{get_locale_string(npc_def.name)} is ENRAGED! "
else:
message = ""
messages.append(create_combat_message(
"enemy_enraged",
origin="enemy",
npc_name=npc_def.name
))
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"{get_locale_string(npc_def.name)} attacks for {npc_damage} damage!"
if armor_absorbed > 0:
message += f" (Armor absorbed {armor_absorbed})"
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:
message += f"\n💔 Your {armor['emoji']} {armor['name']} broke!"
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:
@@ -648,4 +675,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

@@ -2,9 +2,11 @@
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
@@ -205,10 +207,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 +254,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 +265,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 +311,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")

View File

@@ -2,9 +2,11 @@
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 ..services.helpers import get_game_message
from ..core.security import decode_token, create_access_token, security
from ..services.models import CharacterCreate, CharacterSelect
from .. import database as db
@@ -51,10 +53,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 +124,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 +207,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 +240,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'])
}

View File

@@ -2,7 +2,7 @@
Combat 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, create_combat_message
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, create_combat_message, get_game_message
from .. import database as db
from ..items import ItemsManager
from .. import game_logic
@@ -80,6 +80,7 @@ async def get_combat_status(current_user: dict = Depends(get_current_user)):
@router.post("/api/game/combat/initiate")
async def initiate_combat(
req: InitiateCombatRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Start combat with a wandering enemy"""
@@ -88,6 +89,9 @@ async def initiate_combat(
sys.path.insert(0, '/app')
from data.npcs import NPCS
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
# Check if already in combat
existing_combat = await db.get_active_combat(current_user['id'])
if existing_combat:
@@ -147,7 +151,7 @@ async def initiate_combat(
await manager.send_personal_message(current_user['id'], {
"type": "combat_started",
"data": {
"message": create_combat_message("combat_start", origin="neutral", npc_name=npc_def.name),
"messages": [create_combat_message("combat_start", origin="neutral", npc_name=npc_def.name)],
"combat": {
"npc_id": enemy.npc_id,
"npc_name": npc_def.name,
@@ -167,7 +171,7 @@ async def initiate_combat(
message={
"type": "location_update",
"data": {
"message": f"{player['name']} entered combat with {get_locale_string(npc_def.name)}",
"message": get_game_message('player_entered_combat', locale, player_name=player['name'], npc_name=get_locale_string(npc_def.name, locale)),
"action": "combat_started",
"player_id": player['id']
},
@@ -178,7 +182,7 @@ async def initiate_combat(
return {
"success": True,
"message": create_combat_message("combat_start", origin="neutral", npc_name=npc_def.name),
"messages": [create_combat_message("combat_start", origin="neutral", npc_name=npc_def.name)],
"combat": {
"npc_id": enemy.npc_id,
"npc_name": npc_def.name,
@@ -194,6 +198,7 @@ async def initiate_combat(
@router.post("/api/game/combat/action")
async def combat_action(
req: CombatActionRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Perform a combat action"""
@@ -202,6 +207,9 @@ async def combat_action(
sys.path.insert(0, '/app')
from data.npcs import NPCS
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
# Get active combat
combat = await db.get_active_combat(current_user['id'])
if not combat:
@@ -238,7 +246,7 @@ async def combat_action(
player = current_user # current_user is already the character dict
npc_def = NPCS.get(combat['npc_id'])
result_message = ""
messages = []
combat_over = False
player_won = False
@@ -278,12 +286,20 @@ async def combat_action(
damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
if attack_failed:
result_message = f"Your attack misses due to heavy encumbrance! "
messages.append(create_combat_message(
"player_miss",
origin="player",
reason="encumbrance"
))
new_npc_hp = combat['npc_hp']
else:
# Apply damage to NPC
new_npc_hp = max(0, combat['npc_hp'] - damage)
result_message = f"You attack for {damage} damage! "
messages.append(create_combat_message(
"player_attack",
origin="player",
damage=damage
))
# Apply weapon effects
if weapon_effects and 'bleeding' in weapon_effects:
@@ -292,26 +308,42 @@ async def combat_action(
# Apply bleeding effect (would need combat effects table, for now just bonus damage)
bleed_damage = bleeding.get('damage', 0)
new_npc_hp = max(0, new_npc_hp - bleed_damage)
result_message += f"💉 Bleeding effect! +{bleed_damage} damage! "
messages.append(create_combat_message(
"effect_bleeding",
origin="player",
damage=bleed_damage
))
# Decrease weapon durability (from unique_item)
if weapon_inv_id and inv_item.get('unique_item_id'):
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
if new_durability is None:
# Weapon broke (unique_item was deleted, cascades to inventory)
result_message += "\n⚠️ Your weapon broke! "
messages.append(create_combat_message(
"weapon_broke",
origin="player",
item_name=weapon_def.name
))
await db.unequip_item(player['id'], 'weapon')
if new_npc_hp <= 0:
# NPC defeated
result_message += f"Victory! Defeated {get_locale_string(npc_def.name)}"
messages.append(create_combat_message(
"victory",
origin="neutral",
npc_name=npc_def.name
))
combat_over = True
player_won = True
# Award XP
xp_gained = npc_def.xp_reward
new_xp = player['xp'] + xp_gained
result_message += f"\n+{xp_gained} XP"
messages.append(create_combat_message(
"xp_gain",
origin="player",
amount=xp_gained
))
await db.update_player(player['id'], xp=new_xp)
@@ -321,8 +353,12 @@ async def combat_action(
# Check for level up
level_up_result = await game_logic.check_and_apply_level_up(player['id'])
if level_up_result['leveled_up']:
result_message += f"\n🎉 Level Up! You are now level {level_up_result['new_level']}!"
result_message += f"\n+{level_up_result['levels_gained']} stat point(s) to spend!"
messages.append(create_combat_message(
"level_up",
origin="player",
level=level_up_result['new_level'],
stat_points=level_up_result['levels_gained']
))
# Create corpse with loot
import json
@@ -361,7 +397,7 @@ async def combat_action(
message={
"type": "location_update",
"data": {
"message": f"{player['name']} defeated {npc_def.name}",
"message": get_game_message('player_defeated_enemy_broadcast', locale, player_name=player['name'], npc_name=get_locale_string(npc_def.name, locale)),
"action": "combat_ended",
"player_id": player['id'],
"corpse_created": True
@@ -373,13 +409,13 @@ async def combat_action(
else:
# NPC's turn - use shared logic
npc_attack_message, player_defeated = await game_logic.npc_attack(
npc_attack_messages, player_defeated = await game_logic.npc_attack(
player['id'],
{'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp']},
npc_def,
reduce_armor_durability
)
result_message += f"\n{npc_attack_message}"
messages.extend(npc_attack_messages)
if player_defeated:
combat_over = True
@@ -392,7 +428,10 @@ async def combat_action(
elif req.action == 'flee':
# 50% chance to flee
if random.random() < 0.5:
result_message = "You successfully fled from combat!"
messages.append(create_combat_message(
"flee_success",
origin="player"
))
combat_over = True
player_won = False # Fled, not won
@@ -423,7 +462,7 @@ async def combat_action(
message={
"type": "location_update",
"data": {
"message": f"{player['name']} fled from combat",
"message": get_game_message('player_fled_broadcast', locale, player_name=player['name']),
"action": "combat_fled",
"player_id": player['id']
},
@@ -435,10 +474,20 @@ async def combat_action(
# Failed to flee, NPC attacks
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
new_player_hp = max(0, player['hp'] - npc_damage)
result_message = f"Failed to flee! {get_locale_string(npc_def.name)} attacks for {npc_damage} damage!"
messages.append(create_combat_message(
"flee_fail",
origin="enemy",
npc_name=npc_def.name,
damage=npc_damage
))
if new_player_hp <= 0:
result_message += "\nYou have been defeated!"
messages.append(create_combat_message(
"player_defeated",
origin="neutral",
npc_name=npc_def.name
))
combat_over = True
await db.update_player(player['id'], hp=0, is_dead=True)
await db.update_player_statistics(player['id'], deaths=1, failed_flees=1, damage_taken=npc_damage, increment=True)
@@ -509,7 +558,7 @@ async def combat_action(
message={
"type": "location_update",
"data": {
"message": f"{player['name']} was defeated in combat",
"message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']),
"action": "player_died",
"player_id": player['id'],
"corpse": corpse_data
@@ -558,7 +607,7 @@ async def combat_action(
return {
"success": True,
"message": result_message,
"messages": messages,
"combat_over": combat_over,
"player_won": player_won if combat_over else None,
"combat": updated_combat if updated_combat else None,
@@ -574,6 +623,7 @@ async def combat_action(
@router.post("/api/game/pvp/initiate")
async def initiate_pvp_combat(
req: PvPCombatInitiateRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Initiate PvP combat with another player"""
@@ -582,6 +632,9 @@ async def initiate_pvp_combat(
if not attacker:
raise HTTPException(status_code=404, detail="Player not found")
# Extract locale
locale = request.headers.get('Accept-Language', 'en')
# Check if attacker is already in combat
existing_combat = await db.get_active_combat(attacker['id'])
if existing_combat:
@@ -637,7 +690,7 @@ async def initiate_pvp_combat(
await manager.send_personal_message(attacker['id'], {
"type": "combat_started",
"data": {
"message": f"You have initiated combat with {defender['name']}! They get the first turn.",
"message": get_game_message('pvp_initiated_attacker', locale, defender=defender['name']),
"pvp_combat": pvp_combat
},
"timestamp": datetime.utcnow().isoformat()
@@ -646,7 +699,7 @@ async def initiate_pvp_combat(
await manager.send_personal_message(defender['id'], {
"type": "combat_started",
"data": {
"message": f"{attacker['name']} has challenged you to PvP combat! It's your turn.",
"message": get_game_message('pvp_challenged_defender', locale, attacker=attacker['name']),
"pvp_combat": pvp_combat
},
"timestamp": datetime.utcnow().isoformat()
@@ -654,7 +707,7 @@ async def initiate_pvp_combat(
return {
"success": True,
"message": f"You have initiated combat with {defender['name']}! They get the first turn.",
"message": get_game_message('pvp_initiated_attacker', locale, defender=defender['name']),
"pvp_combat": pvp_combat
}
@@ -737,11 +790,15 @@ class PvPAcknowledgeRequest(BaseModel):
@router.post("/api/game/pvp/acknowledge")
async def acknowledge_pvp_combat(
req: PvPAcknowledgeRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Acknowledge PvP combat end"""
await db.acknowledge_pvp_combat(req.combat_id, current_user['id'])
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
# Broadcast to location that player has returned
player = current_user # current_user is already the character dict
if player:
@@ -752,7 +809,7 @@ async def acknowledge_pvp_combat(
"data": {
"player_id": player['id'],
"username": player['name'],
"message": f"{player['name']} has returned from PvP combat."
"message": get_game_message('player_returned_pvp', locale, player_name=player['name'])
},
"timestamp": datetime.utcnow().isoformat()
},
@@ -770,12 +827,16 @@ class PvPCombatActionRequest(BaseModel):
@router.post("/api/game/pvp/action")
async def pvp_combat_action(
req: PvPCombatActionRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Perform a PvP combat action"""
import random
import time
# Extract locale
locale = request.headers.get('Accept-Language', 'en')
# Get PvP combat
pvp_combat = await db.get_pvp_combat_by_player(current_user['id'])
if not pvp_combat:
@@ -795,10 +856,13 @@ async def pvp_combat_action(
current_player = attacker if is_attacker else defender
opponent = defender if is_attacker else attacker
result_message = ""
messages = []
combat_over = False
winner_id = None
# Track the last action string for DB history
last_action_text = ""
if req.action == 'attack':
# Calculate damage (similar to PvE)
base_damage = 5
@@ -822,7 +886,11 @@ async def pvp_combat_action(
if inv_item.get('unique_item_id'):
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
if new_durability is None:
result_message += "⚠️ Your weapon broke! "
messages.append(create_combat_message(
"weapon_broke",
origin="player",
item_name=weapon_def.name
))
await db.unequip_item(current_player['id'], 'weapon')
variance = random.randint(-2, 2)
@@ -832,24 +900,42 @@ async def pvp_combat_action(
armor_absorbed, broken_armor = await reduce_armor_durability(opponent['id'], damage)
actual_damage = max(1, damage - armor_absorbed)
# Structure the attack message
messages.append(create_combat_message(
"player_attack",
origin="player",
damage=damage,
armor_absorbed=armor_absorbed
))
# Update opponent HP (use actual player HP, not pvp_combat fields)
new_opponent_hp = max(0, opponent['hp'] - actual_damage)
# Update opponent's HP in database
await db.update_player(opponent['id'], hp=new_opponent_hp)
# Store message with attacker's username so both players can see it correctly
stored_message = f"{current_player['name']} attacks {opponent['name']} for {damage} damage!"
# Construct summary string for DB history/passive player
last_action_text = f"{current_player['name']} attacks {opponent['name']} for {damage} damage!"
if armor_absorbed > 0:
stored_message += f" (Armor absorbed {armor_absorbed})"
last_action_text += f" (Armor absorbed {armor_absorbed})"
for broken in broken_armor:
stored_message += f"\n💔 {opponent['name']}'s {broken['emoji']} {broken['name']} broke!"
messages.append(create_combat_message(
"item_broken",
origin="enemy", # Belongs to opponent
item_name=broken['name'],
emoji=broken['emoji']
))
last_action_text += f"\n💔 {opponent['name']}'s {broken['emoji']} {broken['name']} broke!"
# Check if opponent defeated
if new_opponent_hp <= 0:
stored_message += f"\n🏆 {current_player['name']} has defeated {opponent['name']}!"
result_message = "Combat victory!" # Simple message, details in stored_message
last_action_text += f"\n🏆 {current_player['name']} has defeated {opponent['name']}!"
messages.append(create_combat_message(
"victory",
origin="neutral",
npc_name=opponent['name']
))
combat_over = True
winner_id = current_player['id']
@@ -921,7 +1007,7 @@ async def pvp_combat_action(
message={
"type": "location_update",
"data": {
"message": f"{opponent['name']} was defeated by {current_player['name']} in PvP combat",
"message": get_game_message('pvp_defeat_broadcast', locale, opponent=opponent['name'], winner=current_player['name']),
"action": "player_died",
"player_id": opponent['id'],
"corpse": corpse_data
@@ -933,8 +1019,7 @@ async def pvp_combat_action(
# End PvP combat
await db.end_pvp_combat(pvp_combat['id'])
else:
# Combat continues - don't return detailed message, it's in stored_message
result_message = "" # Empty message, frontend will show stored_message from polling
# Combat continues
# Update PvP statistics for attack
await db.update_player_statistics(current_player['id'],
@@ -953,7 +1038,7 @@ async def pvp_combat_action(
updates = {
'turn': 'defender' if is_attacker else 'attacker',
'turn_started_at': time.time(),
'last_action': f"{stored_message}|{time.time()}" # Add timestamp for uniqueness
'last_action': f"{last_action_text}|{time.time()}" # Add timestamp for uniqueness
}
# No need to update HP in pvp_combat - we use player HP directly
@@ -963,14 +1048,19 @@ async def pvp_combat_action(
elif req.action == 'flee':
# 50% chance to flee from PvP
if random.random() < 0.5:
result_message = f"You successfully fled from {opponent['name']}!"
last_action_text = f"{current_player['name']} fled from combat!"
messages.append(create_combat_message(
"flee_success",
origin="player"
))
combat_over = True
# Mark as fled, store last action with timestamp, and end combat
flee_field = 'attacker_fled' if is_attacker else 'defender_fled'
await db.update_pvp_combat(pvp_combat['id'], {
flee_field: True,
'last_action': f"{current_player['name']} fled from combat!|{time.time()}"
'last_action': f"{last_action_text}|{time.time()}"
})
await db.end_pvp_combat(pvp_combat['id'])
await db.update_player_statistics(current_player['id'],
@@ -979,11 +1069,17 @@ async def pvp_combat_action(
)
else:
# Failed to flee, skip turn
result_message = f"Failed to flee from {opponent['name']}!"
last_action_text = f"{current_player['name']} tried to flee but failed!"
messages.append(create_combat_message(
"flee_fail",
origin="player",
reason="chance"
))
await db.update_pvp_combat(pvp_combat['id'], {
'turn': 'defender' if is_attacker else 'attacker',
'turn_started_at': time.time(),
'last_action': f"{current_player['name']} tried to flee but failed!|{time.time()}"
'last_action': f"{last_action_text}|{time.time()}"
})
await db.update_player_statistics(current_player['id'],
pvp_failed_flees=1,
@@ -1041,20 +1137,22 @@ async def pvp_combat_action(
await manager.send_personal_message(player_id, {
"type": "combat_update",
"data": {
"message": result_message if player_id == current_user['id'] else "",
"log_entry": result_message if player_id == current_user['id'] else "", # Append to combat log
"message": last_action_text if player_id == current_user['id'] else "",
"log_entry": last_action_text if player_id == current_user['id'] else "", # Append to combat log
"pvp_combat": enriched_pvp,
"combat_over": combat_over,
"winner_id": winner_id,
"attacker_hp": fresh_attacker['hp'],
"defender_hp": fresh_defender['hp']
"defender_hp": fresh_defender['hp'],
"messages": messages if player_id == current_user['id'] else []
},
"timestamp": datetime.utcnow().isoformat()
})
return {
"success": True,
"message": result_message,
"messages": messages,
"combat_over": combat_over,
"winner_id": winner_id
"winner_id": winner_id,
"pvp_combat": updated_pvp
}

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,

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, 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
@@ -757,7 +757,7 @@ 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
@@ -870,7 +870,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
}
@@ -881,7 +881,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']
@@ -897,7 +897,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'],
@@ -930,8 +930,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)
@@ -947,7 +950,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 {
@@ -971,7 +975,7 @@ async def interact(
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']
@@ -988,7 +992,8 @@ async def interact(
interact_req.interactable_id,
interact_req.action_id,
location,
ITEMS_MANAGER
ITEMS_MANAGER,
locale
)
if not result['success']:
@@ -1052,6 +1057,7 @@ async def interact(
@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"""
@@ -1064,10 +1070,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']:
@@ -1087,10 +1097,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
@@ -1149,7 +1159,7 @@ async def use_item(
message={
"type": "location_update",
"data": {
"message": f"{player['name']} was defeated in combat",
"message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']),
"action": "player_died",
"player_id": player['id'],
"corpse": corpse_data # Send full corpse data
@@ -1194,7 +1204,8 @@ async def pickup(
pickup_req.item_id,
current_user['location_id'],
pickup_req.quantity,
ITEMS_MANAGER
ITEMS_MANAGER,
locale
)
if not result['success']:
@@ -1214,7 +1225,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()
@@ -1336,6 +1347,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"""
@@ -1343,6 +1355,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:
@@ -1400,7 +1415,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()
@@ -1410,5 +1425,5 @@ async def drop_item(
return {
"success": True,
"message": f"Dropped {item_def.emoji} {get_locale_string(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, 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
@@ -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)
@@ -99,7 +103,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)
}
@@ -137,7 +141,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 +153,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 +163,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 +318,26 @@ async def loot_corpse(
message_parts = []
for item in looted_items:
item_def = ITEMS_MANAGER.get_item(item['item_id'])
item_name = get_locale_string(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 = get_locale_string(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 +347,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 +446,24 @@ async def loot_corpse(
message_parts = []
for item in looted_items:
item_def = ITEMS_MANAGER.get_item(item['item_id'])
item_name = get_locale_string(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 = get_locale_string(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 +473,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 +488,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,

View File

@@ -15,7 +15,97 @@ 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.'},
'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."},
# 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}."},
'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"},
# 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"}
}
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'},
@@ -39,8 +129,8 @@ def translate_travel_message(direction: str, location_name: str, lang: str = 'en
import json
def create_combat_message(message_type: str, origin: str = "neutral", **data) -> str:
"""Create a structured combat message with type, origin, and data.
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.)
@@ -48,13 +138,13 @@ def create_combat_message(message_type: str, origin: str = "neutral", **data) ->
**data: Dynamic data for the message (damage, npc_name, etc.)
Returns:
JSON string with 'type', 'origin', and 'data' fields
Dictionary with 'type', 'origin', and 'data' fields
"""
return json.dumps({
return {
"type": message_type,
"origin": origin,
"data": data
})
}
def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float:
"""