diff --git a/api/database.py b/api/database.py index ca00efa..98c53d4 100644 --- a/api/database.py +++ b/api/database.py @@ -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 diff --git a/api/game_logic.py b/api/game_logic.py index ff95739..709e5ff 100644 --- a/api/game_logic.py +++ b/api/game_logic.py @@ -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 diff --git a/api/routers/auth.py b/api/routers/auth.py index 3f4d69f..eb46a5d 100644 --- a/api/routers/auth.py +++ b/api/routers/auth.py @@ -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") diff --git a/api/routers/characters.py b/api/routers/characters.py index b973f21..6510531 100644 --- a/api/routers/characters.py +++ b/api/routers/characters.py @@ -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']) } diff --git a/api/routers/combat.py b/api/routers/combat.py index a996b35..01d5de0 100644 --- a/api/routers/combat.py +++ b/api/routers/combat.py @@ -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 } \ No newline at end of file diff --git a/api/routers/equipment.py b/api/routers/equipment.py index ecb00ed..fab96f6 100644 --- a/api/routers/equipment.py +++ b/api/routers/equipment.py @@ -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, diff --git a/api/routers/game_routes.py b/api/routers/game_routes.py index 6ec6922..b896284 100644 --- a/api/routers/game_routes.py +++ b/api/routers/game_routes.py @@ -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) } \ No newline at end of file diff --git a/api/routers/loot.py b/api/routers/loot.py index b1db641..8960ae7 100644 --- a/api/routers/loot.py +++ b/api/routers/loot.py @@ -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, diff --git a/api/services/helpers.py b/api/services/helpers.py index 8395a40..ff72d5d 100644 --- a/api/services/helpers.py +++ b/api/services/helpers.py @@ -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: """ diff --git a/count_sloc.py b/count_sloc.py new file mode 100644 index 0000000..7b3dbc5 --- /dev/null +++ b/count_sloc.py @@ -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': '