diff --git a/Dockerfile.api b/Dockerfile.api index 9d48934..67d5a58 100644 --- a/Dockerfile.api +++ b/Dockerfile.api @@ -20,6 +20,7 @@ COPY data/ ./data/ COPY gamedata/ ./gamedata/ # Copy migration scripts +COPY migrations/ ./migrations/ COPY migrate_*.py ./ # Copy startup script diff --git a/api/background_tasks.py b/api/background_tasks.py index ef10717..8baf0a0 100644 --- a/api/background_tasks.py +++ b/api/background_tasks.py @@ -6,6 +6,7 @@ import asyncio import logging import random import time +from api.services.helpers import get_game_message from .services.constants import PVP_TURN_TIMEOUT import os import fcntl @@ -401,7 +402,7 @@ async def check_pvp_combat_timers(manager=None): await db.update_pvp_combat(combat['id'], { 'turn': new_turn, 'turn_started_at': time.time(), - 'last_action': f"Turn timeout - {current_turn}'s turn skipped|{time.time()}" + 'last_action': f"turn_timeout:{current_turn}|{time.time()}" }) processed += 1 @@ -423,10 +424,16 @@ async def check_pvp_combat_timers(manager=None): "turn": new_turn, "time_remaining": time_remaining, "turn_timeout": "skipped", - "last_action": f"Turn timeout - {current_turn}'s turn skipped" + "last_action": f"turn_timeout:{current_turn}" }, "is_pvp": True, - "message": f"⏱️ Turn skipped due to timeout!" + "messages": [ + { + "type": "combat_timeout", + "origin": "system", + "timestamp": time.time() + } + ] }, "timestamp": time.time() } @@ -519,6 +526,8 @@ async def cleanup_interactable_cooldowns(manager=None, world_locations=None): "data": { "instance_id": cooldown_info['instance_id'], "action_id": cooldown_info['action_id'], + "name": cooldown_info['name'], + "action_name": cooldown_info['action_name'], "message": f"{cooldown_info['action_name']} is ready on {cooldown_info['name']}" }, "timestamp": datetime.utcnow().isoformat() @@ -638,7 +647,7 @@ async def process_status_effects(manager=None): while True: try: - await asyncio.sleep(300) # Wait 5 minutes + await asyncio.sleep(60) # Wait 1 minute (requested by user) start_time = time.time() logger.info("Running status effects processor...") @@ -658,28 +667,39 @@ async def process_status_effects(manager=None): for player_id in affected_players: try: - # Get current status effects (after decrement) - effects = await db.get_player_status_effects(player_id) + # Get current status effects (after decrement), INCLUDING expired (0 ticks) + effects = await db.get_player_status_effects(player_id, min_ticks=0) if not effects: continue - # Calculate total damage - from api.game_logic import calculate_status_damage - total_damage = calculate_status_damage(effects) + # Prepare detailed effects data for frontend + effects_data = [ + { + "name": e['effect_name'], + "ticks_remaining": e['ticks_remaining'], + "effect_icon": e.get('effect_icon') + } + for e in effects + ] - if total_damage > 0: - damage_dealt += total_damage + # Calculate total impact (positive = damage, negative = healing) + from api.game_logic import calculate_status_impact + total_impact = calculate_status_impact(effects) + + if total_impact > 0: + # DAMAGE LOGIC + damage_dealt += total_impact player = await db.get_player_by_id(player_id) if not player or player['is_dead']: continue - new_hp = max(0, player['hp'] - total_damage) + new_hp = max(0, player['hp'] - total_impact) # Check if player died from status effects if new_hp <= 0: - await db.update_player(player_id, {'hp': 0, 'is_dead': True}) + await db.update_player(player_id, hp=0, is_dead=True) deaths += 1 # Only create corpse if player has items @@ -701,6 +721,7 @@ async def process_status_effects(manager=None): # Notify player of death if manager: from datetime import datetime + locale = player.get('locale', 'en') await manager.send_personal_message( player_id, { @@ -708,7 +729,7 @@ async def process_status_effects(manager=None): "data": { "hp": 0, "is_dead": True, - "message": "You died from status effects" + "message": get_game_message('diedFromStatus', locale) }, "timestamp": datetime.utcnow().isoformat() } @@ -717,10 +738,11 @@ async def process_status_effects(manager=None): logger.info(f"Player {player['name']} (ID: {player_id}) died from status effects") else: # Apply damage and notify player - await db.update_player(player_id, {'hp': new_hp}) + await db.update_player(player_id, hp=new_hp) if manager: from datetime import datetime + locale = player.get('locale', 'en') await manager.send_personal_message( player_id, { @@ -728,8 +750,44 @@ async def process_status_effects(manager=None): "data": { "hp": new_hp, "max_hp": player['max_hp'], - "damage": total_damage, - "message": f"You took {total_damage} damage from status effects" + "damage": total_impact, + "message": get_game_message('statusDamage', locale, damage=total_impact), + "effects": effects_data + }, + "timestamp": datetime.utcnow().isoformat() + } + ) + elif total_impact < 0: + # HEALING LOGIC + heal_amount = abs(total_impact) + player = await db.get_player_by_id(player_id) + + if not player or player['is_dead']: + continue + + # Don't heal if already full + if player['hp'] >= player['max_hp']: + continue + + new_hp = min(player['max_hp'], player['hp'] + heal_amount) + real_heal = new_hp - player['hp'] + + if real_heal > 0: + await db.update_player(player_id, hp=new_hp) + + if manager: + from datetime import datetime + locale = player.get('locale', 'en') + await manager.send_personal_message( + player_id, + { + "type": "status_effect_heal", + "data": { + "hp": new_hp, + "max_hp": player['max_hp'], + "heal": real_heal, + "message": get_game_message('statusHeal', locale, heal=real_heal), + "effects": effects_data }, "timestamp": datetime.utcnow().isoformat() } @@ -738,10 +796,13 @@ async def process_status_effects(manager=None): except Exception as e: logger.error(f"Error processing status effects for player {player_id}: {e}") + # CLEANUP: Remove expired effects now that we've notified the user + await db.clean_expired_status_effects() + elapsed = time.time() - start_time logger.info( f"Processed status effects for {len(affected_players)} players " - f"({damage_dealt} total damage, {deaths} deaths) in {elapsed:.3f}s" + f"({damage_dealt} damage, {deaths} deaths) in {elapsed:.3f}s" ) # Warn if taking too long (potential scaling issue) diff --git a/api/database.py b/api/database.py index 86b5544..9223d89 100644 --- a/api/database.py +++ b/api/database.py @@ -262,8 +262,12 @@ player_status_effects = Table( Column("character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False), Column("effect_name", String(50), nullable=False), Column("effect_icon", String(10), nullable=False), + Column("effect_type", String(20), default="damage"), # 'damage', 'buff', 'debuff' Column("damage_per_tick", Integer, nullable=False, default=0), + Column("value", Integer, default=0), # Generic value (buff %, damage, etc.) Column("ticks_remaining", Integer, nullable=False), + Column("persist_after_combat", Boolean, default=False), # Keep after combat ends + Column("source", String(50), nullable=True), # 'item:molotov', 'action:defend' Column("applied_at", Float, nullable=False), ) @@ -2030,18 +2034,99 @@ async def remove_empty_npc_corpses() -> int: # STATUS EFFECTS FUNCTIONS # ============================================================================ -async def get_player_status_effects(player_id: int): +async def add_effect( + player_id: int, + effect_name: str, + effect_icon: str, + ticks_remaining: int, + effect_type: str = "damage", + damage_per_tick: int = 0, + value: int = 0, + persist_after_combat: bool = False, + source: str = None +) -> int: + """ + Add a status effect to a player. + If the effect already exists, it refreshes the duration (ticks_remaining). + Returns the effect ID. + """ + async with DatabaseSession() as session: + # Check if effect already exists + result = await session.execute( + select(player_status_effects).where( + and_( + player_status_effects.c.character_id == player_id, + player_status_effects.c.effect_name == effect_name + ) + ) + ) + existing_effect = result.first() + + if existing_effect: + # Refresh duration + await session.execute( + update(player_status_effects).where( + player_status_effects.c.id == existing_effect.id + ).values( + ticks_remaining=ticks_remaining, + applied_at=time.time() + ) + ) + await session.commit() + return existing_effect.id + else: + # Insert new effect + stmt = insert(player_status_effects).values( + character_id=player_id, + effect_name=effect_name, + effect_icon=effect_icon, + effect_type=effect_type, + damage_per_tick=damage_per_tick, + value=value, + ticks_remaining=ticks_remaining, + persist_after_combat=persist_after_combat, + source=source, + applied_at=time.time() + ).returning(player_status_effects.c.id) + result = await session.execute(stmt) + row = result.first() + await session.commit() + return row[0] if row else None + + +async def get_player_effects(player_id: int, min_ticks: int = 1) -> List[Dict[str, Any]]: """Get all active status effects for a player.""" async with DatabaseSession() as session: result = await session.execute( select(player_status_effects).where( and_( player_status_effects.c.character_id == player_id, - player_status_effects.c.ticks_remaining > 0 + player_status_effects.c.ticks_remaining >= min_ticks ) ) ) - return [row._asdict() for row in result.fetchall()] + return [dict(row._mapping) for row in result.fetchall()] + + +# Alias for backward compatibility +async def get_player_status_effects(player_id: int, min_ticks: int = 1): + """Alias for get_player_effects for backward compatibility.""" + return await get_player_effects(player_id, min_ticks) + + +async def remove_effect(player_id: int, effect_name: str) -> bool: + """Remove a specific effect from a player by name.""" + async with DatabaseSession() as session: + await session.execute( + delete(player_status_effects).where( + and_( + player_status_effects.c.character_id == player_id, + player_status_effects.c.effect_name == effect_name + ) + ) + ) + await session.commit() + return True async def remove_all_status_effects(player_id: int): @@ -2052,36 +2137,141 @@ async def remove_all_status_effects(player_id: int): ) await session.commit() - -async def decrement_all_status_effect_ticks(): - """ - Decrement ticks for all active status effects and return affected player IDs. - Used by background processor. - """ +async def clean_expired_status_effects(): + """Remove all status effects with <= 0 ticks.""" async with DatabaseSession() as session: - # Get player IDs with effects before updating - from sqlalchemy import distinct - result = await session.execute( - select(distinct(player_status_effects.c.character_id)).where( - player_status_effects.c.ticks_remaining > 0 + await session.execute( + delete(player_status_effects).where(player_status_effects.c.ticks_remaining <= 0) + ) + await session.commit() + + +async def remove_non_persistent_effects(player_id: int): + """Remove effects where persist_after_combat is False. Called when combat ends.""" + async with DatabaseSession() as session: + await session.execute( + delete(player_status_effects).where( + and_( + player_status_effects.c.character_id == player_id, + player_status_effects.c.persist_after_combat == False + ) ) ) - affected_players = [row[0] for row in result.fetchall()] + await session.commit() + + +async def tick_player_effects(player_id: int) -> List[Dict[str, Any]]: + """ + Decrement ticks and return effects that were applied this tick. + Used during combat when player receives a turn. + Returns list of effects with their current state (before tick was applied). + """ + async with DatabaseSession() as session: + # Get effects before decrementing + result = await session.execute( + select(player_status_effects).where( + and_( + player_status_effects.c.character_id == player_id, + player_status_effects.c.ticks_remaining > 0 + ) + ) + ) + effects = [dict(row._mapping) for row in result.fetchall()] + + if not effects: + return [] # Decrement ticks await session.execute( update(player_status_effects).where( - player_status_effects.c.ticks_remaining > 0 + and_( + player_status_effects.c.character_id == player_id, + player_status_effects.c.ticks_remaining > 0 + ) ).values(ticks_remaining=player_status_effects.c.ticks_remaining - 1) ) # Remove expired effects await session.execute( - delete(player_status_effects).where(player_status_effects.c.ticks_remaining <= 0) + delete(player_status_effects).where( + and_( + player_status_effects.c.character_id == player_id, + player_status_effects.c.ticks_remaining <= 0 + ) + ) ) await session.commit() - return affected_players + return effects + + +async def decrement_all_status_effect_ticks(): + """ + Decrement ticks for all active status effects and return affected player IDs. + Used by background processor. Only processes players NOT in combat. + """ + async with DatabaseSession() as session: + from sqlalchemy import distinct + + # Get all players with active effects + result = await session.execute( + select(distinct(player_status_effects.c.character_id)).where( + player_status_effects.c.ticks_remaining > 0 + ) + ) + all_players = [row[0] for row in result.fetchall()] + + # Filter out players in combat - they process effects on turn + players_to_process = [] + for pid in all_players: + if not await is_player_in_combat(pid): + players_to_process.append(pid) + + if not players_to_process: + return [] + + # Decrement ticks only for players not in combat + for pid in players_to_process: + await session.execute( + update(player_status_effects).where( + and_( + player_status_effects.c.character_id == pid, + player_status_effects.c.ticks_remaining > 0 + ) + ).values(ticks_remaining=player_status_effects.c.ticks_remaining - 1) + ) + + # NOTE: We do NOT remove expired effects here anymore. + # They will be processed by the background task (to apply final tick) + # and then cleaned up via clean_expired_status_effects() + + await session.commit() + return players_to_process + + +async def is_player_in_combat(player_id: int) -> bool: + """Check if player is in any active combat (PvE or PvP).""" + async with DatabaseSession() as session: + # Check PvE combat + pve = await session.execute( + select(active_combats.c.id).where(active_combats.c.character_id == player_id) + ) + if pve.first(): + return True + + # Check PvP combat + pvp = await session.execute( + select(pvp_combats.c.id).where( + or_( + pvp_combats.c.attacker_character_id == player_id, + pvp_combats.c.defender_character_id == player_id + ) + ) + ) + if pvp.first(): + return True + + return False # ============================================================================ diff --git a/api/game_logic.py b/api/game_logic.py index 709e5ff..7a5f3e2 100644 --- a/api/game_logic.py +++ b/api/game_logic.py @@ -335,6 +335,55 @@ async def use_item(player_id: int, item_id: str, items_manager, locale: str = 'e effects = {} effects_msg = [] + # 1. Apply Status Effects (e.g. Regeneration from Bandage) + if 'status_effect' in item.effects: + status_data = item.effects['status_effect'] + + # Check if effect already exists + current_effects = await db.get_player_effects(player_id) + effect_name = status_data['name'] + + # Handle potential dict/string difference in validation (db stores as string usually) + # But we need to compare with what's in the DB. + # DB get_player_effects returns list of dicts with 'effect_name' key. + + is_active = False + for effect in current_effects: + # Simple string comparison should suffice as both should be localized keys or raw strings + if effect['effect_name'] == effect_name: + is_active = True + break + + if is_active: + return {"success": False, "message": get_game_message('effect_already_active', locale)} + + await db.add_effect( + player_id=player['id'], + effect_name=status_data['name'], + effect_icon=status_data.get('icon', '✨'), + effect_type=status_data.get('type', 'buff'), + damage_per_tick=status_data.get('damage_per_tick', 0), + value=status_data.get('value', 0), + ticks_remaining=status_data.get('ticks', 3), + persist_after_combat=True, # Consumable effects usually persist + source=f"item:{item.id}" + ) + effects['status_applied'] = status_data['name'] + effects_msg.append(f"Applied {get_locale_string(status_data['name'], locale) if isinstance(status_data['name'], dict) else status_data['name']}") + + # 2. Cure Status Effects + if 'cures' in item.effects: + cures = item.effects['cures'] + cured_list = [] + for cure_effect in cures: + if await db.remove_effect(player['id'], cure_effect): + cured_list.append(cure_effect) + + if cured_list: + effects['cured'] = cured_list + effects_msg.append(f"{get_game_message('cured', locale)}: {', '.join(cured_list)}") + + # 3. Direct Healing (Legacy/Instant) if 'hp_restore' in item.effects: hp_restore = item.effects['hp_restore'] old_hp = player['hp'] @@ -496,15 +545,17 @@ async def check_and_apply_level_up(player_id: int) -> Dict[str, Any]: # STATUS EFFECTS UTILITIES # ============================================================================ -def calculate_status_damage(effects: list) -> int: +def calculate_status_impact(effects: list) -> int: """ - Calculate total damage from all status effects. + Calculate total impact from all status effects. + Positive value = Damage + Negative value = Healing Args: effects: List of status effect dicts Returns: - Total damage per tick + Total impact per tick """ return sum(effect.get('damage_per_tick', 0) for effect in effects) @@ -513,8 +564,6 @@ def calculate_status_damage(effects: list) -> int: # COMBAT UTILITIES # ============================================================================ - return message, player_defeated - def generate_npc_intent(npc_def, combat_state: dict) -> dict: """ @@ -548,6 +597,96 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) - if not player: return [], True + messages = [] + + # 1. PROCESS NPC STATUS EFFECTS + npc_hp = combat['npc_hp'] + npc_max_hp = combat['npc_max_hp'] + npc_status_str = combat.get('npc_status_effects', '') + + if npc_status_str: + # Parse status: "bleeding:5:3" (name:dmg:ticks) or multiple "bleeding:5:3|burning:2:2" + # Handling multiple effects separated by | + effects_list = npc_status_str.split('|') + active_effects = [] + npc_damage_taken = 0 + npc_healing_received = 0 + + for effect_str in effects_list: + if not effect_str: continue + try: + parts = effect_str.split(':') + if len(parts) >= 3: + name = parts[0] + dmg = int(parts[1]) + ticks = int(parts[2]) + + # Apply effect + if ticks > 0: + if dmg > 0: + npc_damage_taken += dmg + messages.append(create_combat_message( + "effect_damage", + origin="enemy", + damage=dmg, + effect_name=name, + npc_name=npc_def.name + )) + elif dmg < 0: + heal = abs(dmg) + npc_healing_received += heal + messages.append(create_combat_message( + "effect_heal", # Check if this message type exists or fallback + origin="enemy", + heal=heal, + effect_name=name, + npc_name=npc_def.name + )) + + # Decrement tick + ticks -= 1 + if ticks > 0: + active_effects.append(f"{name}:{dmg}:{ticks}") + except Exception as e: + print(f"Error parsing NPC status: {e}") + + # Update NPC active effects + new_status_str = "|".join(active_effects) + if new_status_str != npc_status_str: + await db.update_combat(player_id, {'npc_status_effects': new_status_str}) + + # Apply Total Damage/Healing + if npc_damage_taken > 0: + npc_hp = max(0, npc_hp - npc_damage_taken) + + if npc_healing_received > 0: + npc_hp = min(npc_max_hp, npc_hp + npc_healing_received) + + # Update NPC HP in DB + await db.update_combat(player_id, {'npc_hp': npc_hp}) + + # Check if NPC died from effects + if npc_hp <= 0: + messages.append(create_combat_message( + "victory", + origin="neutral", + npc_name=npc_def.name + )) + # Award XP/Loot logic handled in combat route mostly, but we need to signal it. + # Returning true for player_defeated is definitely WRONG here if NPC died. + # The router usually handles "victory" check after action. + # But here this is triggered during NPC turn (which happens after Player turn). + # If NPC dies on its OWN turn, we need to handle it. + # However, typically NPC dies on Player turn. + # If NPC dies from bleeding on its turn, the player wins. + # We need to signal this back to router. + # But the current return signature is (messages, player_defeated). + # We might need to handle the win logic here or update signature. + # For now, let's update HP and let the flow continue. + # Wait, if NPC is dead, it shouldn't attack! + # returning here prevents NPC from attacking if it died from status effects + return messages, False + # Parse current intent (stored in DB as string or JSON, assuming simple string for now or we parse it) current_intent_str = combat.get('npc_intent', 'attack') # Handle legacy/null @@ -556,84 +695,98 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) - intent_type = current_intent_str - messages = [] actual_damage = 0 # EXECUTE INTENT - if intent_type == 'defend': - # NPC defends - heals 5% HP - heal_amount = int(combat['npc_max_hp'] * 0.05) - new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount) - await db.update_combat(player_id, {'npc_hp': new_npc_hp}) - - messages.append(create_combat_message( - "enemy_defend", - origin="enemy", - npc_name=npc_def.name, - heal=heal_amount - )) - - elif intent_type == 'special': - # Strong attack (1.5x damage) - npc_damage = int(random.randint(npc_def.damage_min, npc_def.damage_max) * 1.5) - armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage) - actual_damage = max(1, npc_damage - armor_absorbed) - new_player_hp = max(0, player['hp'] - actual_damage) - - messages.append(create_combat_message( - "enemy_special", - origin="enemy", - npc_name=npc_def.name, - damage=npc_damage, - armor_absorbed=armor_absorbed - )) + if npc_hp > 0: # Only attack if alive + if intent_type == 'defend': + # NPC defends - heals 5% HP + heal_amount = int(combat['npc_max_hp'] * 0.05) + new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount) + await db.update_combat(player_id, {'npc_hp': new_npc_hp}) - if broken_armor: - for armor in broken_armor: - 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 - is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3 - if is_enraged: - npc_damage = int(npc_damage * 1.5) messages.append(create_combat_message( - "enemy_enraged", - origin="enemy", - npc_name=npc_def.name + "enemy_defend", + origin="enemy", + npc_name=npc_def.name, + heal=heal_amount )) - armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage) - actual_damage = max(1, npc_damage - armor_absorbed) - new_player_hp = max(0, player['hp'] - actual_damage) - - messages.append(create_combat_message( - "enemy_attack", - origin="enemy", - npc_name=npc_def.name, - damage=npc_damage, - armor_absorbed=armor_absorbed - )) + elif intent_type == 'special': + # Strong attack (1.5x damage) + npc_damage = int(random.randint(npc_def.damage_min, npc_def.damage_max) * 1.5) + armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage) + actual_damage = max(1, npc_damage - armor_absorbed) + new_player_hp = max(0, player['hp'] - actual_damage) - if broken_armor: - for armor in broken_armor: - messages.append(create_combat_message( - "item_broken", - origin="player", - item_name=armor['name'], - emoji=armor['emoji'] - )) + messages.append(create_combat_message( + "enemy_special", + origin="enemy", + npc_name=npc_def.name, + damage=npc_damage, + armor_absorbed=armor_absorbed + )) - await db.update_player(player_id, hp=new_player_hp) + if broken_armor: + for armor in broken_armor: + messages.append(create_combat_message( + "item_broken", + origin="player", + item_name=armor['name'], + emoji=armor['emoji'] + )) + + await db.update_player(player_id, hp=new_player_hp) + + else: # Default 'attack' + npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max) + + # Enrage bonus if NPC is below 30% HP + is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3 + if is_enraged: + npc_damage = int(npc_damage * 1.5) + messages.append(create_combat_message( + "enemy_enraged", + origin="enemy", + npc_name=npc_def.name + )) + + # Check if player is defending (reduces damage by value%) + player_effects = await db.get_player_effects(player_id) + defending_effect = next((e for e in player_effects if e['effect_name'] == 'defending'), None) + if defending_effect: + reduction = defending_effect.get('value', 50) / 100 # Default 50% reduction + npc_damage = int(npc_damage * (1 - reduction)) + messages.append(create_combat_message( + "damage_reduced", + origin="player", + reduction=int(reduction * 100) + )) + # Remove defending effect after use + await db.remove_effect(player_id, 'defending') + + armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage) + actual_damage = max(1, npc_damage - armor_absorbed) + new_player_hp = max(0, player['hp'] - actual_damage) + + messages.append(create_combat_message( + "enemy_attack", + origin="enemy", + npc_name=npc_def.name, + damage=npc_damage, + armor_absorbed=armor_absorbed + )) + + if broken_armor: + for armor in broken_armor: + messages.append(create_combat_message( + "item_broken", + origin="player", + item_name=armor['name'], + emoji=armor['emoji'] + )) + + await db.update_player(player_id, hp=new_player_hp) # GENERATE NEXT INTENT diff --git a/api/items.py b/api/items.py index d40da64..6975bf6 100644 --- a/api/items.py +++ b/api/items.py @@ -45,6 +45,10 @@ class Item: uncraft_yield: list = None # Materials yielded from uncrafting (before loss chance) uncraft_loss_chance: float = 0.3 # Chance to lose materials when uncrafting (0.3 = 30%) uncraft_tools: list = None # Tools required for uncrafting + # Combat system + combat_usable: bool = False # Can be used during combat + combat_only: bool = False # Can ONLY be used during combat + combat_effects: Dict[str, Any] = None # Effects applied in combat (damage, status) def __post_init__(self): if self.stats is None: @@ -65,7 +69,8 @@ class Item: self.uncraft_yield = [] if self.uncraft_tools is None: self.uncraft_tools = [] - self.craft_materials = [] + if self.combat_effects is None: + self.combat_effects = {} class ItemsManager: @@ -129,7 +134,10 @@ class ItemsManager: uncraftable=item_data.get('uncraftable', False), uncraft_yield=item_data.get('uncraft_yield', []), uncraft_loss_chance=item_data.get('uncraft_loss_chance', 0.3), - uncraft_tools=item_data.get('uncraft_tools', []) + uncraft_tools=item_data.get('uncraft_tools', []), + combat_usable=item_data.get('combat_usable', is_consumable), # Default: consumables are combat usable + combat_only=item_data.get('combat_only', False), + combat_effects=item_data.get('combat_effects', {}) ) self.items[item_id] = item diff --git a/api/routers/combat.py b/api/routers/combat.py index f5417aa..7d744ef 100644 --- a/api/routers/combat.py +++ b/api/routers/combat.py @@ -249,7 +249,59 @@ async def combat_action( messages = [] combat_over = False - player_won = False + + # Process status effects (bleeding, etc.) before action + active_effects = await db.tick_player_effects(player['id']) + + # Process status effects before action + if active_effects: + from ..game_logic import calculate_status_impact + total_impact = calculate_status_impact(active_effects) + + if total_impact > 0: + # DAMAGE + damage = total_impact + new_hp = max(0, player['hp'] - damage) + await db.update_player_hp(player['id'], new_hp) + player['hp'] = new_hp # Update local reference + + messages.append(create_combat_message( + "effect_damage", + origin="player", + damage=damage, + effect_name="status effects" + )) + + if new_hp <= 0: + # Player died from effects + await db.remove_non_persistent_effects(player['id']) + await db.end_combat(player['id']) + + return { + "player": player, + "combat": None, + "messages": messages + [create_combat_message("died", origin="player", message="You died from status effects!")], + "active_effects": [], + "round": combat['round'] + } + elif total_impact < 0: + # HEALING + heal = abs(total_impact) + new_hp = min(player['max_hp'], player['hp'] + heal) + actual_heal = new_hp - player['hp'] + + if actual_heal > 0: + await db.update_player_hp(player['id'], new_hp) + player['hp'] = new_hp + + messages.append(create_combat_message( + "effect_heal", + origin="player", + heal=actual_heal, + effect_name="status effects" + )) + + if req.action == 'attack': # Calculate player damage @@ -382,6 +434,9 @@ async def combat_action( loot_remaining=json.dumps(corpse_loot_dicts) ) + + + await db.remove_non_persistent_effects(player['id']) await db.end_combat(player['id']) # Update Redis: Delete combat state cache @@ -456,6 +511,7 @@ async def combat_action( await session.execute(stmt) await session.commit() + await db.remove_non_persistent_effects(player['id']) await db.end_combat(player['id']) # Broadcast to location that player fled from combat @@ -557,6 +613,7 @@ async def combat_action( await session.execute(stmt) await session.commit() + await db.remove_non_persistent_effects(player['id']) await db.end_combat(player['id']) # Broadcast to location that player died (and corpse if created) @@ -584,6 +641,249 @@ async def combat_action( await db.update_player_statistics(player['id'], failed_flees=1, damage_taken=npc_damage, increment=True) await db.update_combat(player['id'], {'turn': 'player', 'turn_started_at': time.time()}) + elif req.action == 'defend': + # Apply "defending" status effect - reduces incoming damage by 50% for 1 turn + await db.add_effect( + player_id=player['id'], + effect_name='defending', + effect_icon='🛡️', + effect_type='buff', + value=50, # 50% damage reduction + ticks_remaining=1, + persist_after_combat=False, + source='action:defend' + ) + + messages.append(create_combat_message( + "defend", + origin="player", + message=get_game_message('defend_text', locale, name=player['name']) + )) + + # NPC's turn after defend + npc_attack_messages, player_defeated = await game_logic.npc_attack( + player['id'], + {'npc_hp': combat['npc_hp'], 'npc_max_hp': combat['npc_max_hp']}, + npc_def, + reduce_armor_durability + ) + messages.extend(npc_attack_messages) + if player_defeated: + await db.remove_non_persistent_effects(player['id']) + combat_over = True + + elif req.action == 'use_item': + combat_over = False + # Validate item_id provided + if not req.item_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="item_id required for use_item action" + ) + + # Get the item from inventory + player_inventory = await db.get_inventory(player['id']) + inv_item = None + for item in player_inventory: + if item['item_id'] == req.item_id: + inv_item = item + break + + if not inv_item: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Item not found in inventory" + ) + + # Get item definition + item_def = ITEMS_MANAGER.get_item(req.item_id) + if not item_def: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unknown item" + ) + + # Check if item is combat usable + if not item_def.combat_usable: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This item cannot be used in combat" + ) + + # Apply item effects + item_name = get_locale_string(item_def.name, locale) + effects_applied = [] + + # 1. Apply Status Effects (e.g. Regeneration from Bandage) + if item_def.effects.get('status_effect'): + status_data = item_def.effects['status_effect'] + await db.add_effect( + player_id=player['id'], + effect_name=status_data['name'], + effect_icon=status_data.get('icon', '✨'), + effect_type=status_data.get('type', 'buff'), + damage_per_tick=status_data.get('damage_per_tick', 0), + value=status_data.get('value', 0), + ticks_remaining=status_data.get('ticks', 3), + persist_after_combat=True, # Consumable effects usually persist + source=f"item:{item_def.id}" + ) + effects_applied.append(f"Applied {status_data['name']}") + + # 2. Cure Status Effects + if item_def.effects.get('cures'): + cures = item_def.effects['cures'] + for cure_effect in cures: + if await db.remove_effect(player['id'], cure_effect): + effects_applied.append(f"Cured {cure_effect}") + + # 3. Handle Direct healing (legacy/instant) + if item_def.effects.get('hp_restore') and item_def.effects['hp_restore'] > 0: + hp_restore = item_def.effects['hp_restore'] + old_hp = player['hp'] + new_hp = min(player.get('max_hp', 100), old_hp + hp_restore) + actual_restored = new_hp - old_hp + if actual_restored > 0: + await db.update_player_hp(player['id'], new_hp) + effects_applied.append(f"+{actual_restored} HP") + + if item_def.effects.get('stamina_restore'): + stamina_restore = item_def.effects['stamina_restore'] + old_stamina = player['stamina'] + new_stamina = min(player.get('max_stamina', 100), old_stamina + stamina_restore) + actual_restored = new_stamina - old_stamina + if actual_restored > 0: + await db.update_player_stamina(player['id'], new_stamina) + effects_applied.append(f"+{actual_restored} Stamina") + + # Handle combat effects (throwables) + combat_effects = item_def.combat_effects or {} + + # Direct damage from throwable + if combat_effects.get('damage_min') and combat_effects.get('damage_max'): + damage = random.randint(combat_effects['damage_min'], combat_effects['damage_max']) + new_npc_hp = max(0, combat['npc_hp'] - damage) + effects_applied.append(f"{damage} damage") + + messages.append(create_combat_message( + "item_damage", + origin="player", + damage=damage, + item_name=item_name + )) + + # Check if NPC is defeated + if new_npc_hp <= 0: + 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 + messages.append(create_combat_message( + "xp_gain", + origin="player", + amount=xp_gained + )) + await db.update_player(player['id'], xp=new_xp) + await db.update_player_statistics(player['id'], enemies_killed=1, damage_dealt=damage, increment=True) + + # Check for level up + level_up_result = await game_logic.check_and_apply_level_up(player['id']) + if level_up_result['leveled_up']: + 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 as json_module + corpse_loot = npc_def.corpse_loot if hasattr(npc_def, 'corpse_loot') else [] + corpse_loot_dicts = [] + for loot in corpse_loot: + if hasattr(loot, '__dict__'): + corpse_loot_dicts.append({ + 'item_id': loot.item_id, + 'quantity_min': loot.quantity_min, + 'quantity_max': loot.quantity_max, + 'required_tool': loot.required_tool + }) + else: + corpse_loot_dicts.append(loot) + await db.create_npc_corpse( + npc_id=combat['npc_id'], + location_id=player['location_id'], + loot_remaining=json_module.dumps(corpse_loot_dicts) + ) + await db.remove_non_persistent_effects(player['id']) + await db.end_combat(player['id']) + else: + # Update NPC HP + await db.update_combat(player['id'], {'npc_hp': new_npc_hp}) + + # Apply status effect from item (e.g., burning from molotov) + status_effect = combat_effects.get('status') + if status_effect and not combat_over: + # Apply to NPC via combat status (simplified - NPC status stored in combat record) + npc_status = f"{status_effect['name']}:{status_effect.get('damage_per_tick', 0)}:{status_effect.get('ticks', 1)}" + await db.update_combat(player['id'], {'npc_status_effects': npc_status}) + + messages.append(create_combat_message( + "effect_applied", + origin="player", + effect_name=status_effect['name'], + effect_icon=status_effect.get('icon', '🔥'), + target="enemy" + )) + + # Consume the item + await db.remove_item_from_inventory(player['id'], req.item_id, 1) + await db.update_player_statistics(player['id'], items_used=1, increment=True) + + # Add item used message + effects_str = f" ({', '.join(effects_applied)})" if effects_applied else "" + + # Calculate total restored amounts for frontend floating text + hp_restored_val = 0 + stamina_restored_val = 0 + + if item_def.effects.get('hp_restore'): + hp_restored_val = min(player.get('max_hp', 100), old_hp + item_def.effects['hp_restore']) - old_hp + + if item_def.effects.get('stamina_restore'): + stamina_restored_val = min(player.get('max_stamina', 100), old_stamina + item_def.effects['stamina_restore']) - old_stamina + + messages.append(create_combat_message( + "item_used", + origin="player", + item_name=item_name, + effects=effects_str, + hp_restore=hp_restored_val if hp_restored_val > 0 else None, + stamina_restore=stamina_restored_val if stamina_restored_val > 0 else None + )) + + # NPC's turn after using item (if combat not over) + if not combat_over: + npc_attack_messages, player_defeated = await game_logic.npc_attack( + player['id'], + {'npc_hp': combat['npc_hp'], 'npc_max_hp': combat['npc_max_hp']}, + npc_def, + reduce_armor_durability + ) + messages.extend(npc_attack_messages) + + if player_defeated: + await db.remove_non_persistent_effects(player['id']) + combat_over = True + + # Get updated combat state if not over updated_combat = None if not combat_over: diff --git a/api/routers/equipment.py b/api/routers/equipment.py index fab96f6..2c713c1 100644 --- a/api/routers/equipment.py +++ b/api/routers/equipment.py @@ -586,6 +586,7 @@ async def get_repairable_items(current_user: dict = Depends(get_current_user)): # Check if player has this tool (find one with highest durability) tool_found = False tool_durability = 0 + tool_max_durability = 0 best_tool_unique = None for check_item in inventory: @@ -596,6 +597,7 @@ async def get_repairable_items(current_user: dict = Depends(get_current_user)): best_tool_unique = unique tool_found = True tool_durability = unique.get('durability', 0) + tool_max_durability = unique.get('max_durability', 100) tools_info.append({ @@ -604,7 +606,8 @@ async def get_repairable_items(current_user: dict = Depends(get_current_user)): 'emoji': tool_def.emoji if tool_def else '🔧', 'durability_cost': durability_cost, 'has_tool': tool_found, - 'tool_durability': tool_durability + 'tool_durability': tool_durability, + 'tool_max_durability': tool_max_durability }) if not tool_found: has_tools = False @@ -633,7 +636,7 @@ async def get_repairable_items(current_user: dict = Depends(get_current_user)): }) # Sort: repairable items first (can_repair=True), then by durability percent (lowest first), then by name - repairable_items.sort(key=lambda x: (not x['can_repair'], -x['durability_percent'], x['name'])) + repairable_items.sort(key=lambda x: (not x['can_repair'], -x['durability_percent'], str(x['name']))) return {'repairable_items': repairable_items} diff --git a/api/routers/game_routes.py b/api/routers/game_routes.py index 42dc5d6..edd0519 100644 --- a/api/routers/game_routes.py +++ b/api/routers/game_routes.py @@ -158,6 +158,7 @@ async def _get_enriched_inventory(player_id: int): "unique_stats": unique_stats, "hp_restore": item.effects.get('hp_restore') if item.effects else None, "stamina_restore": item.effects.get('stamina_restore') if item.effects else None, + "effects": item.effects, "damage_min": item.stats.get('damage_min') if item.stats else None, "damage_max": item.stats.get('damage_max') if item.stats else None, "stats": item.stats, @@ -183,6 +184,10 @@ async def get_game_state(current_user: dict = Depends(get_current_user)): if not player: raise HTTPException(status_code=404, detail="Player not found") + # Get player status effects + status_effects = await db.get_player_effects(player_id) + player['status_effects'] = status_effects + # Get location location = LOCATIONS.get(player['location_id']) @@ -274,6 +279,7 @@ async def get_game_state(current_user: dict = Depends(get_current_user)): "tier": tier if tier is not None else None, "hp_restore": item.effects.get('hp_restore') if item.effects else None, "stamina_restore": item.effects.get('stamina_restore') if item.effects else None, + "effects": item.effects, "damage_min": item.stats.get('damage_min') if item.stats else None, "damage_max": item.stats.get('damage_max') if item.stats else None }) @@ -325,6 +331,10 @@ async def get_player_profile(current_user: dict = Depends(get_current_user)): player = await db.get_player_by_id(player_id) if not player: raise HTTPException(status_code=404, detail="Player not found") + + # Get player status effects + status_effects = await db.get_player_effects(player_id) + player['status_effects'] = status_effects # Get capacity metrics (weight/volume) using the helper function # We don't need the inventory array itself, just the capacity calculations diff --git a/api/services/helpers.py b/api/services/helpers.py index cc4bb6d..8612338 100644 --- a/api/services/helpers.py +++ b/api/services/helpers.py @@ -96,7 +96,13 @@ GAME_MESSAGES = { # 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"} + 'cannot_use': {'en': "This item cannot be used", 'es': "Este objeto no se puede usar"}, + 'cured': {'en': "Cured", 'es': "Curado"}, + + # Status Effects + 'statusDamage': {'en': "You took {damage} damage from status effects", 'es': "Has recibido {damage} de daño por efectos de estado"}, + 'statusHeal': {'en': "You recovered {heal} HP from status effects", 'es': "Has recuperado {heal} vida por efectos de estado"}, + 'diedFromStatus': {'en': "You died from status effects", 'es': "Has muerto por efectos de estado"}, } def get_game_message(key: str, lang: str = 'en', **kwargs) -> str: diff --git a/api/services/models.py b/api/services/models.py index 4b9b9c7..8729940 100644 --- a/api/services/models.py +++ b/api/services/models.py @@ -79,7 +79,8 @@ class InitiateCombatRequest(BaseModel): class CombatActionRequest(BaseModel): - action: str # 'attack', 'defend', 'flee' + action: str # 'attack', 'defend', 'flee', 'use_item' + item_id: Optional[str] = None # For use_item action class PvPCombatInitiateRequest(BaseModel): @@ -91,7 +92,8 @@ class PvPAcknowledgeRequest(BaseModel): class PvPCombatActionRequest(BaseModel): - action: str # 'attack', 'defend', 'flee' + action: str # 'attack', 'defend', 'flee', 'use_item' + item_id: Optional[str] = None # For use_item action # ============================================================================ diff --git a/build_log.txt b/build_log.txt new file mode 100644 index 0000000..f2db43f --- /dev/null +++ b/build_log.txt @@ -0,0 +1,115 @@ +--progress is a global compose flag, better use `docker compose --progress xx build ... + Image echoes_of_the_ashes-echoes_of_the_ashes_pwa Building + Image echoes_of_the_ashes-echoes_of_the_ashes_api Building +#1 [internal] load local bake definitions +#1 reading from stdin 1.25kB done +#1 DONE 0.0s + +#2 [internal] load build definition from Dockerfile.pwa +#2 transferring dockerfile: 810B done +#2 DONE 0.0s + +#3 [internal] load metadata for docker.io/library/nginx:alpine +#3 DONE 0.0s + +#4 [internal] load metadata for docker.io/library/node:20-alpine +#4 DONE 0.4s + +#5 [internal] load .dockerignore +#5 transferring context: 2B done +#5 DONE 0.0s + +#6 [build 1/6] FROM docker.io/library/node:20-alpine@sha256:09e2b3d9726018aecf269bd35325f46bf75046a643a66d28360ec71132750ec8 +#6 DONE 0.0s + +#7 [stage-1 1/4] FROM docker.io/library/nginx:alpine +#7 DONE 0.0s + +#8 [internal] load build context +#8 transferring context: 589.63MB 3.7s done +#8 DONE 3.8s + +#9 [build 2/6] WORKDIR /app +#9 CACHED + +#10 [build 3/6] COPY pwa/package*.json ./ +#10 CACHED + +#11 [build 4/6] RUN npm install +#11 CACHED + +#12 [build 5/6] COPY pwa/ ./ +#12 CACHED + +#13 [build 6/6] RUN npm run build +#13 0.305 +#13 0.305 > echoes-of-the-ashes-pwa@1.0.0 build +#13 0.305 > tsc && vite build +#13 0.305 +#13 4.381 vite v5.4.21 building for production... +#13 4.436 transforming... +#13 5.170 ✓ 111 modules transformed. +#13 5.474 [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D` +#13 7.266 +#13 7.266 PWA v0.17.5 +#13 7.266 mode generateSW +#13 7.266 precache 3 entries (0.00 KiB) +#13 7.266 files generated +#13 7.266 dist/sw.js +#13 7.266 dist/workbox-4b126c97.js +#13 7.267 warnings +#13 7.267 One of the glob patterns doesn't match any files. Please remove or fix the following: { +#13 7.267 "globDirectory": "/app/dist", +#13 7.267 "globPattern": "**/*.{js,css,html,ico,svg,woff,woff2}", +#13 7.267 "globIgnores": [ +#13 7.267 "**/node_modules/**/*", +#13 7.267 "sw.js", +#13 7.267 "workbox-*.js" +#13 7.267 ] +#13 7.267 } +#13 7.267 +#13 7.273 x Build failed in 2.87s +#13 7.273 error during build: +#13 7.273 [vite-plugin-pwa:build] [plugin vite-plugin-pwa:build] src/components/common/GameProgressBar.tsx: There was an error during the build: +#13 7.273 Could not resolve "./InventoryModal.css" from "src/components/common/GameProgressBar.tsx" +#13 7.273 Additionally, handling the error in the 'buildEnd' hook caused the following error: +#13 7.273 Could not resolve "./InventoryModal.css" from "src/components/common/GameProgressBar.tsx" +#13 7.273 file: /app/src/components/common/GameProgressBar.tsx +#13 7.273 at getRollupError (file:///app/node_modules/rollup/dist/es/shared/parseAst.js:401:41) +#13 7.273 at file:///app/node_modules/rollup/dist/es/shared/node-entry.js:23347:39 +#13 7.273 at async catchUnfinishedHookActions (file:///app/node_modules/rollup/dist/es/shared/node-entry.js:22805:16) +#13 7.273 at async rollupInternal (file:///app/node_modules/rollup/dist/es/shared/node-entry.js:23330:5) +#13 7.273 at async build (file:///app/node_modules/vite/dist/node/chunks/dep-BK3b2jBa.js:65709:14) +#13 7.273 at async CAC. (file:///app/node_modules/vite/dist/node/cli.js:829:5) +#13 ERROR: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1 +------ + > [build 6/6] RUN npm run build: +7.273 Could not resolve "./InventoryModal.css" from "src/components/common/GameProgressBar.tsx" +7.273 Additionally, handling the error in the 'buildEnd' hook caused the following error: +7.273 Could not resolve "./InventoryModal.css" from "src/components/common/GameProgressBar.tsx" +7.273 file: /app/src/components/common/GameProgressBar.tsx +7.273 at getRollupError (file:///app/node_modules/rollup/dist/es/shared/parseAst.js:401:41) +7.273 at file:///app/node_modules/rollup/dist/es/shared/node-entry.js:23347:39 +7.273 at async catchUnfinishedHookActions (file:///app/node_modules/rollup/dist/es/shared/node-entry.js:22805:16) +7.273 at async rollupInternal (file:///app/node_modules/rollup/dist/es/shared/node-entry.js:23330:5) +7.273 at async build (file:///app/node_modules/vite/dist/node/chunks/dep-BK3b2jBa.js:65709:14) +7.273 at async CAC. (file:///app/node_modules/vite/dist/node/cli.js:829:5) +------ +Dockerfile.pwa:22 + +-------------------- + + 20 | + + 21 | # Build the application + + 22 | >>> RUN npm run build + + 23 | + + 24 | # Production stage + +-------------------- + +failed to solve: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1 + diff --git a/build_log_2.txt b/build_log_2.txt new file mode 100644 index 0000000..8eacc92 --- /dev/null +++ b/build_log_2.txt @@ -0,0 +1,90 @@ +--progress is a global compose flag, better use `docker compose --progress xx build ... + Image echoes_of_the_ashes-echoes_of_the_ashes_pwa Building + Image echoes_of_the_ashes-echoes_of_the_ashes_api Building +#1 [internal] load local bake definitions +#1 reading from stdin 1.25kB done +#1 DONE 0.0s + +#2 [internal] load build definition from Dockerfile.pwa +#2 transferring dockerfile: 810B done +#2 DONE 0.0s + +#3 [internal] load metadata for docker.io/library/nginx:alpine +#3 DONE 0.0s + +#4 [internal] load metadata for docker.io/library/node:20-alpine +#4 DONE 0.7s + +#5 [internal] load .dockerignore +#5 transferring context: 2B done +#5 DONE 0.0s + +#6 [build 1/6] FROM docker.io/library/node:20-alpine@sha256:09e2b3d9726018aecf269bd35325f46bf75046a643a66d28360ec71132750ec8 +#6 DONE 0.0s + +#7 [stage-1 1/4] FROM docker.io/library/nginx:alpine +#7 DONE 0.0s + +#8 [internal] load build context +#8 transferring context: 2.35MB 0.6s done +#8 DONE 0.6s + +#9 [build 2/6] WORKDIR /app +#9 CACHED + +#10 [build 3/6] COPY pwa/package*.json ./ +#10 CACHED + +#11 [build 4/6] RUN npm install +#11 CACHED + +#12 [build 5/6] COPY pwa/ ./ +#12 DONE 2.9s + +#13 [build 6/6] RUN npm run build +#13 0.256 +#13 0.256 > echoes-of-the-ashes-pwa@1.0.0 build +#13 0.256 > tsc && vite build +#13 0.256 +#13 4.328 vite v5.4.21 building for production... +#13 4.379 transforming... +#13 5.865 ✓ 160 modules transformed. +#13 5.995 rendering chunks... +#13 6.118 computing gzip size... +#13 6.129 dist/manifest.webmanifest 0.46 kB +#13 6.129 dist/index.html 1.00 kB │ gzip: 0.50 kB +#13 6.129 dist/assets/index-DvVzkIfD.css 111.05 kB │ gzip: 19.61 kB +#13 6.129 dist/assets/workbox-window.prod.es5-vqzQaGvo.js 5.72 kB │ gzip: 2.35 kB +#13 6.129 dist/assets/index-RV0Szog0.js 454.56 kB │ gzip: 135.53 kB +#13 6.130 ✓ built in 1.78s +#13 6.404 [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D` +#13 8.218 +#13 8.218 PWA v0.17.5 +#13 8.218 mode generateSW +#13 8.218 precache 7 entries (559.91 KiB) +#13 8.218 files generated +#13 8.218 dist/sw.js +#13 8.218 dist/workbox-4b126c97.js +#13 DONE 8.3s + +#7 [stage-1 1/4] FROM docker.io/library/nginx:alpine +#7 CACHED + +#14 [stage-1 2/4] COPY --from=build /app/dist /usr/share/nginx/html +#14 DONE 0.0s + +#15 [stage-1 3/4] COPY images/ /usr/share/nginx/html/images/ +#15 DONE 0.0s + +#16 [stage-1 4/4] COPY nginx.conf /etc/nginx/conf.d/default.conf +#16 DONE 0.0s + +#17 exporting to image +#17 exporting layers 0.1s done +#17 writing image sha256:04e99cea3a418401aff49901e27724e5132a721881a9c01bda68dd302a496587 done +#17 naming to docker.io/library/echoes_of_the_ashes-echoes_of_the_ashes_pwa done +#17 DONE 0.1s + +#18 resolving provenance for metadata file +#18 DONE 0.0s + Image echoes_of_the_ashes-echoes_of_the_ashes_pwa Built diff --git a/gamedata/items.json b/gamedata/items.json index aedcc40..df3d833 100644 --- a/gamedata/items.json +++ b/gamedata/items.json @@ -33,7 +33,7 @@ "wood_planks": { "name": { "en": "Wood Planks", - "es": "Tablillas de madera" + "es": "Tablas de madera" }, "weight": 3.0, "volume": 2.0, @@ -314,14 +314,25 @@ "es": "Vendaje" }, "description": { - "en": "Clean cloth bandages for treating minor wounds. Can stop bleeding.", - "es": "Vendajes limpios de tela para tratar heridas menores. Pueden detener la sangrado." + "en": "Clean cloth bandages for treating minor wounds. Applies regeneration and stops bleeding.", + "es": "Vendajes limpios de tela para tratar heridas menores. Aplica regeneración y detiene el sangrado." }, "weight": 0.1, "volume": 0.1, "type": "consumable", - "hp_restore": 15, - "treats": "Bleeding", + "effects": { + "status_effect": { + "name": "regeneration", + "icon": "❤️", + "type": "buff", + "damage_per_tick": -5, + "ticks": 3, + "value": 15 + }, + "cures": [ + "bleeding" + ] + }, "emoji": "🩹", "image_path": "images/items/bandage.webp" }, @@ -458,11 +469,11 @@ "knife": { "name": { "en": "Knife", - "es": "" + "es": "Cuchillo" }, "description": { "en": "A sharp survival knife in decent condition.", - "es": "" + "es": "Un cuchillo de supervivencia afilado en buen estado." }, "weight": 0.3, "volume": 0.2, @@ -547,11 +558,11 @@ "rusty_pipe": { "name": { "en": "Rusty Pipe", - "es": "" + "es": "Tubería oxidada" }, "description": { "en": "Heavy metal pipe. Crude but effective.", - "es": "" + "es": "Tubería de metal oxidada. Bruta pero efectiva." }, "weight": 1.5, "volume": 0.8, @@ -567,11 +578,11 @@ "tattered_rucksack": { "name": { "en": "Tattered Rucksack", - "es": "" + "es": "Mochila rústica" }, "description": { "en": "An old backpack with torn straps. Still functional.", - "es": "" + "es": "Una mochila vieja con tirantes rotos. Todavía funcional." }, "weight": 1.0, "volume": 0.5, @@ -614,11 +625,11 @@ "hiking_backpack": { "name": { "en": "Hiking Backpack", - "es": "" + "es": "Mochila de senderismo" }, "description": { "en": "A quality backpack with multiple compartments.", - "es": "" + "es": "Una mochila de calidad con múltiples compartimentos." }, "weight": 1.5, "volume": 0.7, @@ -650,11 +661,11 @@ "flashlight": { "name": { "en": "Flashlight", - "es": "" + "es": "Linterna" }, "description": { "en": "A battery-powered flashlight. Batteries low but working.", - "es": "" + "es": "Una linterna alimentada por pilas. Las pilas están casi agotadas pero funcionan." }, "weight": 0.3, "volume": 0.2, @@ -670,7 +681,7 @@ "old_photograph": { "name": { "en": "Old Photograph", - "es": "" + "es": "Fotografía vieja" }, "weight": 0.01, "volume": 0.01, @@ -679,13 +690,13 @@ "image_path": "images/items/old_photograph.webp", "description": { "en": "A useful old photograph.", - "es": "" + "es": "Una fotografía vieja útil." } }, "key_ring": { "name": { "en": "Key Ring", - "es": "" + "es": "Anillo de llaves" }, "weight": 0.1, "volume": 0.05, @@ -694,17 +705,17 @@ "image_path": "images/items/key_ring.webp", "description": { "en": "A useful key ring.", - "es": "" + "es": "Un anillo de llaves útil." } }, "makeshift_spear": { "name": { "en": "Makeshift Spear", - "es": "" + "es": "Pica improvisado" }, "description": { "en": "A crude spear made from a sharpened stick and scrap metal.", - "es": "" + "es": "Una pica improvisada hecha de un palo afilado y metal desechado." }, "weight": 1.2, "volume": 2.0, @@ -751,11 +762,11 @@ "reinforced_bat": { "name": { "en": "Reinforced Bat", - "es": "" + "es": "Bate de béisbol reforzado" }, "description": { "en": "A wooden bat wrapped with scrap metal and nails. Brutal.", - "es": "" + "es": "Un bate de béisbol envuelto con metal desechado y clavos. Brutal." }, "weight": 1.8, "volume": 1.5, @@ -808,11 +819,11 @@ "leather_vest": { "name": { "en": "Leather Vest", - "es": "" + "es": "Chaleco de cuero" }, "description": { "en": "A makeshift vest crafted from leather scraps. Provides basic protection.", - "es": "" + "es": "Un chaleco improvisado hecho de cuero desechado. Proporciona protección básica." }, "weight": 1.5, "volume": 1.0, @@ -859,11 +870,11 @@ "cloth_bandana": { "name": { "en": "Cloth Bandana", - "es": "" + "es": "Banda de tela" }, "description": { "en": "A simple cloth head covering. Keeps the sun and dust out.", - "es": "" + "es": "Una cobertura simple para la cabeza. Mantiene el sol y la arena fuera." }, "weight": 0.1, "volume": 0.1, @@ -897,11 +908,11 @@ "sturdy_boots": { "name": { "en": "Sturdy Boots", - "es": "" + "es": "Botas fuertes" }, "description": { "en": "Reinforced boots for traversing the wasteland.", - "es": "" + "es": "Botas reforzadas para cruzar el desierto." }, "weight": 1.0, "volume": 0.8, @@ -948,11 +959,11 @@ "padded_pants": { "name": { "en": "Padded Pants", - "es": "" + "es": "Pantalones reforzados" }, "description": { "en": "Pants reinforced with extra padding for protection.", - "es": "" + "es": "Pantalones reforzados con un relleno extra para protección." }, "weight": 0.8, "volume": 0.6, @@ -995,11 +1006,11 @@ "reinforced_pack": { "name": { "en": "Reinforced Pack", - "es": "" + "es": "Mochila reforzada" }, "description": { "en": "A custom-built backpack with metal frame and extra pockets.", - "es": "" + "es": "Una mochila personalizada con un marco de metal y bolsillos extra." }, "weight": 2.0, "volume": 0.9, @@ -1085,11 +1096,11 @@ "hammer": { "name": { "en": "Hammer", - "es": "" + "es": "Martillo" }, "description": { "en": "A basic tool for crafting and repairs. Essential for any survivor.", - "es": "" + "es": "Una herramienta básica para la fabricación y reparaciones. Esencial para cualquier superviviente." }, "weight": 0.8, "volume": 0.4, @@ -1124,11 +1135,11 @@ "screwdriver": { "name": { "en": "Screwdriver", - "es": "" + "es": "Destornillador" }, "description": { "en": "A flathead screwdriver. Useful for repairs and scavenging.", - "es": "" + "es": "Un destornillador de cabeza plana. Útil para reparaciones y recogida de material." }, "weight": 0.2, "volume": 0.2, @@ -1163,6 +1174,130 @@ "damage_min": 5, "damage_max": 8 } + }, + "pipe_bomb": { + "name": { + "en": "Pipe Bomb", + "es": "Bomba improvisada" + }, + "type": "throwable", + "weight": 0.5, + "volume": 0.3, + "emoji": "💣", + "image_path": "images/items/pipe_bomb.webp", + "description": { + "en": "An improvised explosive. Deals heavy damage when thrown.", + "es": "Un explosivo improvisado. Causa gran daño cuando se lanza." + }, + "stackable": true, + "combat_usable": true, + "combat_effects": { + "damage_min": 15, + "damage_max": 25 + } + }, + "molotov_cocktail": { + "name": { + "en": "Molotov Cocktail", + "es": "Cóctel Molotov" + }, + "type": "throwable", + "weight": 0.4, + "volume": 0.3, + "emoji": "🔥", + "image_path": "images/items/molotov.webp", + "description": { + "en": "A bottle filled with flammable liquid. Sets the target on fire.", + "es": "Una botella llena de líquido inflamable. Prende fuego al objetivo." + }, + "stackable": true, + "combat_usable": true, + "combat_effects": { + "damage_min": 10, + "damage_max": 15, + "status": { + "name": "burning", + "icon": "🔥", + "damage_per_tick": 3, + "ticks": 3, + "persist_after_combat": true + } + } + }, + "smoke_bomb": { + "name": { + "en": "Smoke Bomb", + "es": "Bomba de humo" + }, + "type": "throwable", + "weight": 0.3, + "volume": 0.2, + "emoji": "💨", + "image_path": "images/items/smoke_bomb.webp", + "description": { + "en": "Creates a smoke screen. Greatly increases flee chance for 1 turn.", + "es": "Crea una cortina de humo. Aumenta la probabilidad de huir por 1 turno." + }, + "stackable": true, + "combat_usable": true, + "combat_only": true, + "combat_effects": { + "status": { + "name": "smoke_cover", + "icon": "💨", + "value": 50, + "ticks": 1, + "persist_after_combat": false + } + } + }, + "stim_pack": { + "name": { + "en": "Stim Pack", + "es": "Estimulante" + }, + "type": "consumable", + "weight": 0.2, + "volume": 0.1, + "emoji": "💉", + "image_path": "images/items/stim_pack.webp", + "description": { + "en": "A combat stimulant that instantly restores health. Only usable in combat.", + "es": "Un estimulante de combate que restaura salud instantáneamente. Solo usable en combate." + }, + "stackable": true, + "consumable": true, + "combat_usable": true, + "combat_only": true, + "hp_restore": 20 + }, + "adrenaline_shot": { + "name": { + "en": "Adrenaline Shot", + "es": "Inyección de adrenalina" + }, + "type": "consumable", + "weight": 0.1, + "volume": 0.1, + "emoji": "⚡", + "image_path": "images/items/adrenaline.webp", + "description": { + "en": "Increases damage output for 2 turns. Only usable in combat.", + "es": "Aumenta el daño durante 2 turnos. Solo usable en combate." + }, + "stackable": true, + "consumable": true, + "combat_usable": true, + "combat_only": true, + "combat_effects": { + "status": { + "name": "empowered", + "icon": "⚡", + "value": 25, + "ticks": 2, + "persist_after_combat": false + } + } } } } \ No newline at end of file diff --git a/gamedata/npcs.json b/gamedata/npcs.json index d46fab2..d23e68e 100644 --- a/gamedata/npcs.json +++ b/gamedata/npcs.json @@ -48,7 +48,10 @@ "flee_chance": 0.3, "status_inflict_chance": 0.15, "image_path": "images/npcs/feral_dog.webp", - "death_message": "The feral dog whimpers and collapses. Perhaps it was just hungry..." + "death_message": { + "en": "The feral dog whimpers and collapses. Perhaps it was just hungry...", + "es": "El perro salvaje gemía y se derrumbó. Quizás solo estaba hambriento..." + } }, "raider_scout": { "npc_id": "raider_scout", @@ -110,7 +113,10 @@ "flee_chance": 0.2, "status_inflict_chance": 0.1, "image_path": "images/npcs/raider_scout.webp", - "death_message": "The raider scout falls with a final gasp. Their supplies are yours." + "death_message": { + "en": "The raider scout falls with a final gasp. Their supplies are yours.", + "es": "El explorador cae con un último gemido. Sus suministros son tuyos." + } }, "mutant_rat": { "npc_id": "mutant_rat", @@ -154,7 +160,10 @@ "flee_chance": 0.5, "status_inflict_chance": 0.25, "image_path": "images/npcs/mutant_rat.webp", - "death_message": "The mutant rat squeals its last and goes still." + "death_message": { + "en": "The mutant rat squeals its last and goes still.", + "es": "La rata mutante gemía por última vez y se detuvo." + } }, "infected_human": { "npc_id": "infected_human", @@ -204,17 +213,20 @@ "flee_chance": 0.1, "status_inflict_chance": 0.3, "image_path": "images/npcs/infected_human.webp", - "death_message": "The infected human finally finds peace in death." + "death_message": { + "en": "The infected human finally finds peace in death.", + "es": "El humano infectado finalmente encuentra paz en la muerte." + } }, "scavenger": { "npc_id": "scavenger", "name": { "en": "Hostile Scavenger", - "es": "" + "es": "Superviviente hostil" }, "description": { "en": "Another survivor, but this one sees you as competition. They won't share territory.", - "es": "" + "es": "Otro superviviente, eres su competencia. No compartirá el territorio." }, "emoji": "💀", "hp_min": 25, @@ -278,7 +290,10 @@ "flee_chance": 0.25, "status_inflict_chance": 0.05, "image_path": "images/npcs/scavenger.webp", - "death_message": "The scavenger's struggle ends. Survival has no mercy." + "death_message": { + "en": "The scavenger's struggle ends. Survival has no mercy.", + "es": "El deseo de supervivencia del escavador se agota. La supervivencia no tiene misericordia." + } } }, "danger_levels": { diff --git a/images-source/items/stimpack.png b/images-source/items/stimpack.png new file mode 100644 index 0000000..fb1651c Binary files /dev/null and b/images-source/items/stimpack.png differ diff --git a/images-source/make_webp.sh b/images-source/make_webp.sh index ac40839..8408044 100755 --- a/images-source/make_webp.sh +++ b/images-source/make_webp.sh @@ -16,7 +16,7 @@ echo " Source: $SOURCE_DIR" echo " Output: $OUTPUT_DIR" echo "" -for category in items locations npcs interactables characters; do +for category in items locations npcs interactables characters placeholder; do src="$SOURCE_DIR/$category" out="$OUTPUT_DIR/$category" @@ -38,7 +38,7 @@ for category in items locations npcs interactables characters; do continue fi - if [[ "$category" == "items" ]]; then + if [[ "$category" == "items" || "$category" == "placeholder" ]]; then # Special processing for items: remove white background and resize echo " ➜ Converting item: $filename" tmp="/tmp/${base}_clean.png" diff --git a/images-source/placeholder/backpack_placeholder.png b/images-source/placeholder/backpack_placeholder.png new file mode 100644 index 0000000..9f74653 Binary files /dev/null and b/images-source/placeholder/backpack_placeholder.png differ diff --git a/images-source/placeholder/feet_placeholder.png b/images-source/placeholder/feet_placeholder.png new file mode 100644 index 0000000..62b54ce Binary files /dev/null and b/images-source/placeholder/feet_placeholder.png differ diff --git a/images-source/placeholder/head_placeholder.png b/images-source/placeholder/head_placeholder.png new file mode 100644 index 0000000..8496268 Binary files /dev/null and b/images-source/placeholder/head_placeholder.png differ diff --git a/images-source/placeholder/legs_placeholder.png b/images-source/placeholder/legs_placeholder.png new file mode 100644 index 0000000..a05e0c3 Binary files /dev/null and b/images-source/placeholder/legs_placeholder.png differ diff --git a/images-source/placeholder/torso_placeholder.png b/images-source/placeholder/torso_placeholder.png new file mode 100644 index 0000000..aa43ac9 Binary files /dev/null and b/images-source/placeholder/torso_placeholder.png differ diff --git a/images-source/placeholder/weapon_placeholder.png b/images-source/placeholder/weapon_placeholder.png new file mode 100644 index 0000000..8b3d818 Binary files /dev/null and b/images-source/placeholder/weapon_placeholder.png differ diff --git a/images/items/stimpack.webp b/images/items/stimpack.webp new file mode 100644 index 0000000..e34b901 Binary files /dev/null and b/images/items/stimpack.webp differ diff --git a/images/placeholder/backpack_placeholder.webp b/images/placeholder/backpack_placeholder.webp new file mode 100644 index 0000000..26f1fc6 Binary files /dev/null and b/images/placeholder/backpack_placeholder.webp differ diff --git a/images/placeholder/feet_placeholder.webp b/images/placeholder/feet_placeholder.webp new file mode 100644 index 0000000..82b51fb Binary files /dev/null and b/images/placeholder/feet_placeholder.webp differ diff --git a/images/placeholder/head_placeholder.webp b/images/placeholder/head_placeholder.webp new file mode 100644 index 0000000..e27ffd2 Binary files /dev/null and b/images/placeholder/head_placeholder.webp differ diff --git a/images/placeholder/legs_placeholder.webp b/images/placeholder/legs_placeholder.webp new file mode 100644 index 0000000..79ff3ec Binary files /dev/null and b/images/placeholder/legs_placeholder.webp differ diff --git a/images/placeholder/torso_placeholder.webp b/images/placeholder/torso_placeholder.webp new file mode 100644 index 0000000..35c56da Binary files /dev/null and b/images/placeholder/torso_placeholder.webp differ diff --git a/images/placeholder/weapon_placeholder.webp b/images/placeholder/weapon_placeholder.webp new file mode 100644 index 0000000..c609156 Binary files /dev/null and b/images/placeholder/weapon_placeholder.webp differ diff --git a/migrations/add_status_effects_table.sql b/migrations/add_status_effects_table.sql index d058acc..eed8e95 100644 --- a/migrations/add_status_effects_table.sql +++ b/migrations/add_status_effects_table.sql @@ -1,18 +1,24 @@ -- Add persistent status effects table -CREATE TABLE IF NOT EXISTS player_status_effects ( +DROP TABLE IF EXISTS player_status_effects CASCADE; + +CREATE TABLE player_status_effects ( id SERIAL PRIMARY KEY, - player_id INTEGER NOT NULL REFERENCES players(telegram_id) ON DELETE CASCADE, + character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, effect_name VARCHAR(50) NOT NULL, effect_icon VARCHAR(10) NOT NULL, damage_per_tick INTEGER NOT NULL DEFAULT 0, + effect_type VARCHAR(20) DEFAULT 'damage', + value INTEGER DEFAULT 0, ticks_remaining INTEGER NOT NULL, + persist_after_combat BOOLEAN DEFAULT FALSE, + source VARCHAR(50), applied_at FLOAT NOT NULL, CONSTRAINT valid_ticks CHECK (ticks_remaining >= 0), CONSTRAINT valid_damage CHECK (damage_per_tick >= 0) ); -- Create index for efficient querying by player -CREATE INDEX IF NOT EXISTS idx_status_effects_player ON player_status_effects(player_id); +CREATE INDEX IF NOT EXISTS idx_status_effects_player ON player_status_effects(character_id); -- Create index for background processor to find active effects -CREATE INDEX IF NOT EXISTS idx_status_effects_active ON player_status_effects(player_id, ticks_remaining) WHERE ticks_remaining > 0; +CREATE INDEX IF NOT EXISTS idx_status_effects_active ON player_status_effects(character_id, ticks_remaining) WHERE ticks_remaining > 0; diff --git a/migrations/apply_status_effects_migration.py b/migrations/apply_status_effects_migration.py index 84cbf11..311962b 100644 --- a/migrations/apply_status_effects_migration.py +++ b/migrations/apply_status_effects_migration.py @@ -7,6 +7,9 @@ import asyncio import os from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy import text +from dotenv import load_dotenv + +load_dotenv() # Database connection DB_USER = os.getenv("POSTGRES_USER") diff --git a/migrations/migrate_combat_effects.py b/migrations/migrate_combat_effects.py new file mode 100644 index 0000000..d7f7f07 --- /dev/null +++ b/migrations/migrate_combat_effects.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +Migration: Add combat effect fields to player_status_effects table. +Adds effect_type, value, persist_after_combat, and source columns. +""" +import asyncio +import os +import sys + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy import text + +DB_USER = os.getenv("POSTGRES_USER") +DB_PASS = os.getenv("POSTGRES_PASSWORD") +DB_NAME = os.getenv("POSTGRES_DB") +DB_HOST = os.getenv("POSTGRES_HOST", "echoes_of_the_ashes_db") +DB_PORT = os.getenv("POSTGRES_PORT", "5432") + +DATABASE_URL = f"postgresql+psycopg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + + +async def run_migration(): + engine = create_async_engine(DATABASE_URL, echo=True) + + async with engine.begin() as conn: + print("Adding new columns to player_status_effects table...") + + # Add effect_type column (damage, buff, debuff) + try: + await conn.execute(text(""" + ALTER TABLE player_status_effects + ADD COLUMN IF NOT EXISTS effect_type VARCHAR(20) DEFAULT 'damage' + """)) + print("✓ Added effect_type column") + except Exception as e: + print(f"Note: effect_type column may already exist: {e}") + + # Add value column (generic value for effect - damage amount, buff %, etc.) + # This replaces/supplements damage_per_tick for more flexibility + try: + await conn.execute(text(""" + ALTER TABLE player_status_effects + ADD COLUMN IF NOT EXISTS value INTEGER DEFAULT 0 + """)) + print("✓ Added value column") + except Exception as e: + print(f"Note: value column may already exist: {e}") + + # Add persist_after_combat column + try: + await conn.execute(text(""" + ALTER TABLE player_status_effects + ADD COLUMN IF NOT EXISTS persist_after_combat BOOLEAN DEFAULT FALSE + """)) + print("✓ Added persist_after_combat column") + except Exception as e: + print(f"Note: persist_after_combat column may already exist: {e}") + + # Add source column to track where effect came from + try: + await conn.execute(text(""" + ALTER TABLE player_status_effects + ADD COLUMN IF NOT EXISTS source VARCHAR(50) DEFAULT NULL + """)) + print("✓ Added source column") + except Exception as e: + print(f"Note: source column may already exist: {e}") + + # Create index on persist_after_combat for background task queries + try: + await conn.execute(text(""" + CREATE INDEX IF NOT EXISTS idx_status_effects_persist + ON player_status_effects(persist_after_combat) + WHERE persist_after_combat = TRUE + """)) + print("✓ Created persist_after_combat index") + except Exception as e: + print(f"Note: Index may already exist: {e}") + + print("\n✓ Migration completed successfully!") + + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(run_migration()) diff --git a/migrations/migrate_fix_damage_constraint.py b/migrations/migrate_fix_damage_constraint.py new file mode 100644 index 0000000..04d2ead --- /dev/null +++ b/migrations/migrate_fix_damage_constraint.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +""" +Migration: Drop valid_damage constraint from player_status_effects table. +This constraint prevents negative damage (healing) for status effects. +""" +import asyncio +import os +import sys + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy import text + +DB_USER = os.getenv("POSTGRES_USER") +DB_PASS = os.getenv("POSTGRES_PASSWORD") +DB_NAME = os.getenv("POSTGRES_DB") +DB_HOST = os.getenv("POSTGRES_HOST", "echoes_of_the_ashes_db") +DB_PORT = os.getenv("POSTGRES_PORT", "5432") + +DATABASE_URL = f"postgresql+psycopg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + + +async def run_migration(): + engine = create_async_engine(DATABASE_URL, echo=True) + + async with engine.begin() as conn: + print("Removing restrictive constraint from player_status_effects table...") + + try: + await conn.execute(text(""" + ALTER TABLE player_status_effects + DROP CONSTRAINT IF EXISTS valid_damage + """)) + print("✓ Dropped valid_damage constraint") + except Exception as e: + print(f"Error dropping constraint: {e}") + + print("\n✓ Migration completed successfully!") + + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(run_migration()) diff --git a/nginx.conf b/nginx.conf index 972ee32..f37a79c 100644 --- a/nginx.conf +++ b/nginx.conf @@ -16,9 +16,11 @@ server { add_header X-XSS-Protection "1; mode=block" always; # Cache static assets + # Cache static assets - DISABLE CACHE FOR DEVELOPMENT location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { - expires 1y; - add_header Cache-Control "public, immutable"; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; } # Service worker should never be cached diff --git a/pwa/public/audio/bgm-old.wav b/pwa/public/audio/bgm-old.wav new file mode 100644 index 0000000..e2e9fad Binary files /dev/null and b/pwa/public/audio/bgm-old.wav differ diff --git a/pwa/public/audio/bgm.wav b/pwa/public/audio/bgm.wav index e2e9fad..2249da2 100644 Binary files a/pwa/public/audio/bgm.wav and b/pwa/public/audio/bgm.wav differ diff --git a/pwa/public/audio/sfx/inventory_close.wav b/pwa/public/audio/sfx/inventory_close.wav new file mode 100644 index 0000000..b734930 Binary files /dev/null and b/pwa/public/audio/sfx/inventory_close.wav differ diff --git a/pwa/public/audio/sfx/inventory_open.wav b/pwa/public/audio/sfx/inventory_open.wav new file mode 100644 index 0000000..96e6ded Binary files /dev/null and b/pwa/public/audio/sfx/inventory_open.wav differ diff --git a/pwa/public/audio/sfx/victory.wav b/pwa/public/audio/sfx/victory.wav index 8d1466b..bc6489a 100644 Binary files a/pwa/public/audio/sfx/victory.wav and b/pwa/public/audio/sfx/victory.wav differ diff --git a/pwa/src/App.css b/pwa/src/App.css index cef1903..e9444f8 100644 --- a/pwa/src/App.css +++ b/pwa/src/App.css @@ -56,7 +56,8 @@ border-color: #535bf2; } -input, textarea { +input, +textarea { width: 100%; padding: 0.75rem; border: 1px solid #3a3a3a; @@ -66,7 +67,8 @@ input, textarea { font-size: 1rem; } -input:focus, textarea:focus { +input:focus, +textarea:focus { outline: none; border-color: #646cff; } @@ -85,8 +87,48 @@ input:focus, textarea:focus { .container { padding: 0.5rem; } - + .card { padding: 1rem; } } + +/* Status Effects */ +.status-effects-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 15px; +} + +.status-effect-badge { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + background-color: #333; + border-radius: 4px; + font-size: 0.85rem; + border: 1px solid #444; +} + +.status-effect-badge.damage { + background-color: rgba(231, 76, 60, 0.2); + border-color: #e74c3c; + color: #ffdce0; +} + +.status-effect-badge.buff { + background-color: rgba(46, 204, 113, 0.2); + border-color: #2ecc71; + color: #d4efdf; +} + +.effect-icon { + font-size: 1.1em; +} + +.effect-timer { + font-family: monospace; + opacity: 0.8; +} \ No newline at end of file diff --git a/pwa/src/components/BackgroundMusic.tsx b/pwa/src/components/BackgroundMusic.tsx index 74e8aa6..ff45dfa 100644 --- a/pwa/src/components/BackgroundMusic.tsx +++ b/pwa/src/components/BackgroundMusic.tsx @@ -1,16 +1,23 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { useAudio } from '../contexts/AudioContext'; import { isElectronApp } from '../utils/assetPath'; export default function BackgroundMusic() { const { pathname } = useLocation(); - const { masterVolume, musicVolume, isMuted } = useAudio(); - const audioRef = useRef(null); + const { audioContext, masterVolume, musicVolume, isMuted, getAudioBuffer } = useAudio(); + + // We only need refs for the source (track) and the gain (volume) + // The context is now shared. + const sourceNodeRef = useRef(null); + const musicGainNodeRef = useRef(null); + + const [audioBuffer, setAudioBuffer] = useState(null); + const [isLoading, setIsLoading] = useState(true); const [playbackError, setPlaybackError] = useState(false); // Routes where music should play - const shouldPlayMusic = () => { + const shouldPlayMusic = useCallback(() => { // Game main view if (pathname === '/game') return true; // Leaderboards @@ -21,73 +28,142 @@ export default function BackgroundMusic() { if (pathname.startsWith('/profile/')) return true; return false; - }; + }, [pathname]); // Calculate effective volume const effectiveVolume = isMuted ? 0 : masterVolume * musicVolume; + // Load Audio Buffer (using shared cache) useEffect(() => { - if (!audioRef.current) { - // For static assets in public folder: - // Browser: use absolute path from root - // Electron: use relative path + const loadAudio = async () => { + setIsLoading(true); const src = isElectronApp() ? './audio/bgm.wav' : '/audio/bgm.wav'; - audioRef.current = new Audio(src); - audioRef.current.loop = true; - } - - const audio = audioRef.current; - - // Update volume in real-time - audio.volume = effectiveVolume; - - const handlePlay = async () => { - try { - if (shouldPlayMusic()) { - if (audio.paused) { - await audio.play(); - setPlaybackError(false); - } - } else { - if (!audio.paused) { - audio.pause(); - audio.currentTime = 0; // Reset track when stopping - } - } - } catch (err) { - console.log('Audio playback failed:', err); + const buffer = await getAudioBuffer(src); + if (buffer) { + setAudioBuffer(buffer); + } else { + console.error('Failed to load background music buffer'); setPlaybackError(true); } + setIsLoading(false); }; - handlePlay(); + if (audioContext) { + loadAudio(); + } + }, [audioContext, getAudioBuffer]); - // Attempts to resume audio if the user interacts with the page - const retryPlay = () => { - if (shouldPlayMusic() && audio.paused) { - handlePlay(); + // Setup Gain Node + useEffect(() => { + if (audioContext && !musicGainNodeRef.current) { + const gain = audioContext.createGain(); + gain.connect(audioContext.destination); + musicGainNodeRef.current = gain; + } + }, [audioContext]); + + // Playback Logic + const playMusic = useCallback(() => { + if (!audioContext || !audioBuffer || !musicGainNodeRef.current) return; + + // If already playing, do nothing + if (sourceNodeRef.current) return; + + try { + // Ensure context is running (handled globally but good to check) + if (audioContext.state === 'suspended') { + audioContext.resume().catch(e => console.warn(e)); + } + + const source = audioContext.createBufferSource(); + source.buffer = audioBuffer; + source.loop = true; + source.connect(musicGainNodeRef.current); + source.start(0); + sourceNodeRef.current = source; + setPlaybackError(false); + + // Cleanup on end (though looping, so only if loop=false or stopped) + source.onended = () => { + if (sourceNodeRef.current === source) { + sourceNodeRef.current = null; + } + }; + + } catch (error) { + console.error('Start playback failed:', error); + setPlaybackError(true); + } + }, [audioContext, audioBuffer]); + + const stopMusic = useCallback(() => { + if (sourceNodeRef.current) { + try { + sourceNodeRef.current.stop(); + sourceNodeRef.current.disconnect(); + } catch (e) { + // ignore + } + sourceNodeRef.current = null; + } + }, []); + + // Handle Volume Changes + useEffect(() => { + if (musicGainNodeRef.current && audioContext) { + const currentTime = audioContext.currentTime; + musicGainNodeRef.current.gain.setTargetAtTime(effectiveVolume, currentTime, 0.1); + } + }, [effectiveVolume, audioContext]); + + // Control Play/Stop based on Route and Readiness + useEffect(() => { + if (isLoading || !audioContext) return; + + const handleAudioLogic = () => { + if (shouldPlayMusic()) { + if (!sourceNodeRef.current) { + playMusic(); + } + } else { + stopMusic(); } }; - if (playbackError) { - document.addEventListener('click', retryPlay, { once: true }); - } + handleAudioLogic(); + + }, [shouldPlayMusic, isLoading, audioContext, playMusic, stopMusic]); + + // Cleanup on unmount + useEffect(() => { + return () => { + stopMusic(); + // Don't close context, it's shared + }; + }, [stopMusic]); + + // Monitor state for overlay + const [isSuspended, setIsSuspended] = useState(false); + useEffect(() => { + if (!audioContext) return; + + const updateState = () => setIsSuspended(audioContext.state === 'suspended'); + updateState(); + + const interval = setInterval(updateState, 1000); + audioContext.addEventListener('statechange', updateState); return () => { - document.removeEventListener('click', retryPlay); + clearInterval(interval); + audioContext.removeEventListener('statechange', updateState); }; + }, [audioContext]); - }, [pathname, effectiveVolume, playbackError]); + // Render overlay if music should play but is blocked + if (!shouldPlayMusic()) return null; - // Handle volume changes specifically if they happen while playing - useEffect(() => { - if (audioRef.current) { - audioRef.current.volume = effectiveVolume; - } - }, [effectiveVolume]); - - // Render a small overlay if autoplay is blocked - if (!playbackError || !shouldPlayMusic()) return null; + // If not suspended and no error, don't show overlay + if (!isSuspended && !playbackError) return null; return (
{ - if (audioRef.current) { - audioRef.current.play() - .then(() => setPlaybackError(false)) - .catch(e => console.error(e)); + if (audioContext) { + audioContext.resume().then(() => { + // Attempt to play again + playMusic(); + }); } }} > - 🎵 Click to Enable Audio + {playbackError ? '⚠️ Audio Error' : '🎵 Click to Enable Audio'}
); } diff --git a/pwa/src/components/Game.css b/pwa/src/components/Game.css index 1ad5e4b..c79a7f4 100644 --- a/pwa/src/components/Game.css +++ b/pwa/src/components/Game.css @@ -1909,7 +1909,6 @@ body.no-scroll { align-items: center; gap: 0.25rem; width: 100%; - max-width: 50px; flex: 1; /* Allow content to grow */ justify-content: space-between; @@ -2047,15 +2046,11 @@ body.no-scroll { } .equipment-emoji { - max-width: 50px; - max-height: 50px; - font-size: 1.2rem; - /* Reduced for better fit */ - line-height: 1; - /* Prevent clipping */ - margin-top: 0.25rem; - /* Add small margin */ + width: 100%; + height: 100%; object-fit: contain; + margin: 0; + line-height: 1; } .equipment-emoji.hidden { diff --git a/pwa/src/components/Game.tsx b/pwa/src/components/Game.tsx index 41502e2..ce90335 100644 --- a/pwa/src/components/Game.tsx +++ b/pwa/src/components/Game.tsx @@ -219,6 +219,64 @@ function Game() { } break + case 'interactable_ready': + // Interactable cooldown finished + if (message.data?.action_name && message.data?.name) { + actions.addLocationMessage(t('messages.interactableReady', { + action: message.data.action_name, + name: message.data.name + })) + } else if (message.data?.message) { + actions.addLocationMessage(message.data.message) + } + break + + case 'status_effect_damage': + if (message.data?.damage) { + actions.addLocationMessage(t('messages.statusDamage', { damage: message.data.damage })) + actions.updatePlayerState({ hp: message.data.hp }) + + if (message.data.effects && Array.isArray(message.data.effects)) { + message.data.effects.forEach((e: any) => { + actions.updateStatusEffect(e.name, e.ticks_remaining) + }) + } else if (message.data.name && message.data.ticks_remaining !== undefined) { + actions.updateStatusEffect(message.data.name, message.data.ticks_remaining) + } + } + break + + case 'status_effect_heal': + if (message.data?.heal) { + actions.addLocationMessage(t('messages.statusHeal', { heal: message.data.heal })) + actions.updatePlayerState({ hp: message.data.hp }) + + if (message.data.effects && Array.isArray(message.data.effects)) { + message.data.effects.forEach((e: any) => { + actions.updateStatusEffect(e.name, e.ticks_remaining) + }) + } else if (message.data.name && message.data.ticks_remaining !== undefined) { + actions.updateStatusEffect(message.data.name, message.data.ticks_remaining) + } + } + break + + case 'player_died': + if (message.data?.is_dead) { + actions.addLocationMessage(t('messages.diedStatus')) + actions.updatePlayerState({ hp: 0, is_dead: true }) + } + break + + case 'stamina_update': + if (message.data?.stamina) { + // Only show message if significant change or if it's the regeneration event + // actions.addLocationMessage(t('messages.staminaRegenerated')) + // (commented out to avoid spam, usually stamina update is silent or subtle) + actions.updatePlayerState({ stamina: message.data.stamina }) + } + break + case 'player_count_update': // Handled by GameHeader, ignore here break diff --git a/pwa/src/components/common/GameProgressBar.tsx b/pwa/src/components/common/GameProgressBar.tsx new file mode 100644 index 0000000..12b3029 --- /dev/null +++ b/pwa/src/components/common/GameProgressBar.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import '../game/InventoryModal.css'; // Reusing existing styles for now, or ensure classes are global + +interface GameProgressBarProps { + value: number; + max: number; + type?: 'weight' | 'volume' | 'health' | 'enemy_health' | 'stamina' | 'xp' | 'durability'; // types map to colors + showText?: boolean; + label?: React.ReactNode; + unit?: string; + height?: string; + align?: 'left' | 'right'; + labelAlignment?: 'left' | 'right'; +} + +export const GameProgressBar: React.FC = ({ + value, + max, + type = 'weight', + showText = false, + label, + unit = '', + height = '8px', + align = 'left', + labelAlignment +}) => { + const percentage = Math.min(100, Math.max(0, (value / (max || 1)) * 100)); + + // Map types to CSS classes used in InventoryModal.css or inline styles + const getFillClass = () => { + switch (type) { + case 'weight': return 'metric-fill weight'; + case 'volume': return 'metric-fill volume'; + case 'health': return 'durability-fill high'; // borrowing green + case 'enemy_health': return 'durability-fill low'; // borrowing red + case 'stamina': return 'durability-fill medium'; // borrowing yellow + case 'xp': return 'durability-fill medium'; // XP usually gold/yellow + case 'durability': return 'metric-fill'; // Use inline gradient + default: return 'metric-fill'; + } + }; + + // Custom coloring for health/stamina if not using classes matching InventoryModal exactly + const getGradient = () => { + switch (type) { + // InventoryModal.css defines .weight and .volume gradients + // We can rely on classes if we import the CSS in parent or here + case 'health': return 'linear-gradient(90deg, #10b981, #059669)'; + case 'enemy_health': return 'linear-gradient(90deg, #ef4444, #b91c1c)'; // Red + case 'stamina': return 'linear-gradient(90deg, #eab308, #ca8a04)'; + case 'xp': return 'linear-gradient(90deg, #8b5cf6, #7c3aed)'; // Purple for XP? + case 'durability': + if (percentage < 15) return 'linear-gradient(90deg, #ef4444, #b91c1c)'; // Red + if (percentage < 50) return 'linear-gradient(90deg, #eab308, #ca8a04)'; // Yellow + return 'linear-gradient(90deg, #10b981, #059669)'; // Green + default: return undefined; + } + }; + + const displayValue = Number.isInteger(value) ? value : value.toFixed(1); + const displayMax = Number.isInteger(max) ? max : max.toFixed(1); + + const effectiveLabelAlign = labelAlignment || align; + + return ( +
+ {showText && ( +
+ {effectiveLabelAlign === 'left' ? ( + <> + {label && {label}} + {displayValue}/{displayMax}{unit ? ` ${unit}` : ''} + + ) : ( + <> + {displayValue}/{displayMax}{unit ? ` ${unit}` : ''} + {label && {label}} + + )} +
+ )} +
+
+
+
+ ); +}; diff --git a/pwa/src/components/game/Combat.tsx b/pwa/src/components/game/Combat.tsx index cb69edb..b259a5c 100644 --- a/pwa/src/components/game/Combat.tsx +++ b/pwa/src/components/game/Combat.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useAuth } from '../../contexts/AuthContext'; // import { useGame } from '../../contexts/GameContext'; // Removed invalid import import { CombatView } from './CombatView'; +import { CombatInventoryModal } from './CombatInventoryModal'; import { CombatState, CombatMessage, FloatingText, AnimationState, CombatActionResponse } from './CombatTypes'; import { useTranslation } from 'react-i18next'; @@ -143,6 +144,7 @@ export const Combat: React.FC = ({ const [messageQueue, setMessageQueue] = useState([]); const [isProcessingQueue, setIsProcessingQueue] = useState(false); const [combatResult, setCombatResult] = useState<'victory' | 'defeat' | 'fled' | null>(null); + const [showSuppliesModal, setShowSuppliesModal] = useState(false); // --- Refs --- const processingRef = useRef(false); @@ -465,6 +467,19 @@ export const Combat: React.FC = ({ }, 2000); break; + case 'item_used': + if (data.hp_restore) { + setTimeout(() => addFloatingText(`+${data.hp_restore}`, 'heal', 'player'), 200); + } + if (data.stamina_restore) { + setTimeout(() => addFloatingText(`+${data.stamina_restore}`, 'stamina', 'player'), 400); + } + break; + + case 'effect_applied': + addFloatingText(`${data.effect_icon || ''} ${data.effect_name}`, 'info', data.target === 'enemy' ? 'enemy' : 'player'); + break; + case 'flee_success': if (cleanupIntervalRef.current) clearInterval(cleanupIntervalRef.current); setTimeout(() => { @@ -655,17 +670,78 @@ export const Combat: React.FC = ({ }, 50); }; + const handleUseItem = async (itemId: string) => { + // Close modal and use item in combat + setShowSuppliesModal(false); + if (isPvP) { + await handlePvPActionWrapper('use_item'); + } else { + await handlePvEActionWithItem('use_item', itemId); + } + }; + + const handlePvEActionWithItem = async (action: string, itemId?: string) => { + if (isProcessingQueue) return; + + try { + if (localCombatState.turn !== 'player') return; + + // Build action payload + const actionPayload = itemId ? `${action}:${itemId}` : action; + const data: CombatActionResponse = await onCombatAction(actionPayload); + + if (data && data.success && data.messages) { + setMessageQueue(data.messages); + + if (data.combat) { + setLocalCombatState(prev => ({ + ...prev, + npcHp: data.combat.npc_hp, + npcMaxHp: data.combat.npc_max_hp, + turn: data.combat.turn, + round: data.combat.round, + npcName: resolveName(data.combat.npc_name) || prev.npcName + })); + } else if (data.combat_over && data.player_won) { + setLocalCombatState(prev => ({ + ...prev, + npcHp: 0 + })); + } + + if (data.player) { + pendingPlayerHpRef.current = { hp: data.player.hp, max_hp: data.player.max_hp }; + pendingPlayerXpRef.current = { xp: data.player.xp, level: data.player.level }; + refreshCharacters(); + } + } + } catch (err) { + console.error(err); + } + }; + return ( - + <> + setShowSuppliesModal(true)} + isProcessing={isProcessingQueue} + combatResult={combatResult} + equipment={_equipment} + playerName={profile?.name} + /> + {/* Supplies modal */} + setShowSuppliesModal(false)} + onUseItem={handleUseItem} + inventory={playerState?.inventory || []} + /> + ); }; diff --git a/pwa/src/components/game/CombatEffects.css b/pwa/src/components/game/CombatEffects.css index 420fbc4..2c2c26d 100644 --- a/pwa/src/components/game/CombatEffects.css +++ b/pwa/src/components/game/CombatEffects.css @@ -266,7 +266,11 @@ } .type-info { - color: #ffff44; + color: #44aaff; +} + +.type-stamina { + color: #ffd700; } @keyframes float-up { diff --git a/pwa/src/components/game/CombatInventoryModal.css b/pwa/src/components/game/CombatInventoryModal.css new file mode 100644 index 0000000..599458e --- /dev/null +++ b/pwa/src/components/game/CombatInventoryModal.css @@ -0,0 +1,369 @@ +/* Shared Backdrop (Refined) */ +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.85); + display: flex; + justify-content: center; + align-items: center; + z-index: 2000; + backdrop-filter: blur(4px); +} + +/* Combat Modal Container - Matches Inventory Redesign */ +.combat-inventory-modal { + width: 90%; + max-width: 600px; + /* Slightly wider for better card display */ + max-height: 80vh; + display: flex; + flex-direction: column; + background: linear-gradient(135deg, #1e2a38 0%, #121820 100%); + border: 1px solid #3a4b5c; + border-radius: 12px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.8); + overflow: hidden; + color: #e0e6ed; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + z-index: 2001; +} + +/* Header */ +.combat-inventory-modal .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + background: rgba(0, 0, 0, 0.3); + border-bottom: 1px solid #3a4b5c; +} + +.combat-inventory-modal .modal-header h3 { + margin: 0; + color: #ff6b6b; + /* Reddish for combat focus */ + font-size: 1.25rem; + letter-spacing: 0.5px; +} + +.combat-inventory-modal .close-btn { + background: none; + border: none; + color: #a0aec0; + font-size: 1.5rem; + cursor: pointer; + transition: color 0.2s; +} + +.combat-inventory-modal .close-btn:hover { + color: #fff; +} + +.modal-body { + padding: 1.5rem; + display: flex; + flex-direction: column; + overflow: hidden; + /* Flex container for scrollable list */ + flex: 1; +} + +/* Search Input */ +.search-input { + width: 100%; + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; + background: rgba(0, 0, 0, 0.2); + border: 1px solid #3a4b5c; + border-radius: 8px; + color: #fff; + font-size: 1rem; + outline: none; + transition: border-color 0.2s; +} + +.search-input:focus { + border-color: #ff6b6b; +} + +/* Items List */ +.items-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.75rem; + padding-right: 0.5rem; +} + +.no-items { + text-align: center; + color: #718096; + padding: 2rem; + font-style: italic; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + font-size: 1.1rem; +} + +/* Item Card - Matching Inventory Compact Style */ +.combat-item-card { + display: flex; + flex-direction: row; + background-color: rgba(26, 32, 44, 0.8); + border: 1px solid #2d3748; + border-radius: 0.5rem; + padding: 0.75rem; + gap: 1rem; + align-items: stretch; + transition: all 0.2s ease; + cursor: pointer; +} + +.combat-item-card:hover { + border-color: #ff6b6b; + background: rgba(255, 255, 255, 0.06); + transform: translateY(-1px); +} + +/* Image Section */ +.item-image-section { + width: 80px; + height: 80px; + flex-shrink: 0; + position: relative; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.3); + border-radius: 6px; + border: 1px solid #4a5568; +} + +.item-img-thumb { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.item-icon-large { + font-size: 2rem; + display: flex; + align-items: center; + justify-content: center; +} + +.item-icon-large.hidden { + display: none; +} + +.item-quantity-badge { + position: absolute; + bottom: -5px; + right: -5px; + background: #2d3748; + border: 1px solid #4a5568; + color: #fff; + font-size: 0.75rem; + padding: 2px 6px; + border-radius: 10px; + font-weight: bold; +} + +/* Info Section */ +.item-details { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.25rem; + min-width: 0; +} + +.item-name { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-description { + font-size: 0.85rem; + color: #a0aec0; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin-bottom: 0.25rem; +} + +/* Stat Badges */ +.item-effects { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.stat-badge { + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + border: 1px solid; + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + font-weight: 600; + background-color: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.1); + color: #e2e8f0; +} + +/* Badge Colors */ +.stat-badge.healing, +.stat-badge.health { + background-color: rgba(16, 185, 129, 0.2); + color: #6ee7b7; + border-color: rgba(16, 185, 129, 0.4); +} + +.stat-badge.stamina, +.stat-badge.crit { + background-color: rgba(234, 179, 8, 0.2); + color: #fde047; + border-color: rgba(234, 179, 8, 0.4); +} + +.stat-badge.damage, +.stat-badge.penetration { + background-color: rgba(239, 68, 68, 0.2); + color: #fca5a5; + border-color: rgba(239, 68, 68, 0.4); +} + +.stat-badge.armor { + background-color: rgba(59, 130, 246, 0.2); + color: #93c5fd; + border-color: rgba(59, 130, 246, 0.4); +} + +.stat-badge.accuracy { + background-color: rgba(20, 184, 166, 0.2); + color: #5eead4; + border-color: rgba(20, 184, 166, 0.4); +} + +.stat-badge.dodge { + background-color: rgba(99, 102, 241, 0.2); + color: #a5b4fc; + border-color: rgba(99, 102, 241, 0.4); +} + +.stat-badge.lifesteal { + background-color: rgba(236, 72, 153, 0.2); + color: #f9a8d4; + border-color: rgba(236, 72, 153, 0.4); +} + +.stat-badge.strength { + background-color: rgba(249, 115, 22, 0.2); + color: #fdba74; + border-color: rgba(249, 115, 22, 0.4); +} + +.stat-badge.agility { + background-color: rgba(6, 182, 212, 0.2); + color: #67e8f9; + border-color: rgba(6, 182, 212, 0.4); +} + +.stat-badge.endurance { + background-color: rgba(16, 185, 129, 0.2); + color: #6ee7b7; + border-color: rgba(16, 185, 129, 0.4); +} + +.stat-badge.capacity { + background-color: rgba(16, 185, 129, 0.2); + color: #6ee7b7; + border-color: rgba(16, 185, 129, 0.4); +} + +/* Use Button (Embedded in card) */ +.btn-use { + background: rgba(72, 187, 120, 0.2); + color: #48bb78; + border: 1px solid rgba(72, 187, 120, 0.4); + padding: 0.5rem 1rem; + border-radius: 6px; + cursor: pointer; + font-weight: bold; + margin-left: 0.5rem; + height: fit-content; + align-self: center; + transition: all 0.2s; +} + +.btn-use:hover { + background: rgba(72, 187, 120, 0.3); + transform: translateY(-1px); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); +} + +/* Tier Colors for Names/Icons */ +.text-tier-0 { + color: #a0aec0; +} + +.text-tier-1 { + color: #ffffff; +} + +.text-tier-2 { + color: #68d391; +} + +.text-tier-3 { + color: #63b3ed; +} + +.text-tier-4 { + color: #9f7aea; +} + +.text-tier-5 { + color: #ed8936; +} + +.item-icon-large.tier-0 { + text-shadow: 0 0 10px rgba(160, 174, 192, 0.3); +} + +.item-icon-large.tier-1 { + text-shadow: 0 0 10px rgba(255, 255, 255, 0.3); +} + +.item-icon-large.tier-2 { + text-shadow: 0 0 10px rgba(104, 211, 145, 0.3); +} + +.item-icon-large.tier-3 { + text-shadow: 0 0 10px rgba(99, 179, 237, 0.3); +} + +.item-icon-large.tier-4 { + text-shadow: 0 0 10px rgba(159, 122, 234, 0.3); +} + +.item-icon-large.tier-5 { + text-shadow: 0 0 10px rgba(237, 137, 54, 0.3); +} \ No newline at end of file diff --git a/pwa/src/components/game/CombatInventoryModal.tsx b/pwa/src/components/game/CombatInventoryModal.tsx new file mode 100644 index 0000000..cc03d9c --- /dev/null +++ b/pwa/src/components/game/CombatInventoryModal.tsx @@ -0,0 +1,212 @@ +import React, { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getAssetPath } from '../../utils/assetPath'; +import { getTranslatedText } from '../../utils/i18nUtils'; +import './CombatInventoryModal.css'; +import { EffectBadge } from './EffectBadge'; + +interface CombatInventoryModalProps { + isOpen: boolean; + onClose: () => void; + onUseItem: (itemId: string) => void; + inventory: any[]; +} + +export const CombatInventoryModal: React.FC = ({ + isOpen, + onClose, + onUseItem, + inventory +}) => { + const { t } = useTranslation(); + const [searchTerm, setSearchTerm] = useState(''); + + const combatItems = useMemo(() => { + if (!inventory) return []; + + return inventory.filter(item => { + // Check if item is usable in combat + // If explicit combat_usable flag is present, respect it. + // If not, fallback to 'consumable' type check, but ideally we want explicit flags. + // Some items might be consumable but not combat usable (e.g. quest items, or long-cast items) + // For now, checks: combat_usable OR (consumable AND has effects) + + const isCombatUsable = item.combat_usable === true; + const isConsumable = item.type === 'consumable' || item.category === 'consumable' || item.consumable === true; + + // Allow if strictly combat_usable, or if consumable and not explicitly restricted + const allowed = isCombatUsable || (isConsumable && item.combat_only !== false); + + const itemName = getTranslatedText(item.name).toLowerCase(); + const matchesSearch = itemName.includes(searchTerm.toLowerCase()); + + return allowed && matchesSearch && item.quantity > 0; + }); + }, [inventory, searchTerm]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+

{t('combat.modal.supplies_title')}

+ +
+ +
+ setSearchTerm(e.target.value)} + autoFocus + /> + +
+ {combatItems.length === 0 ? ( +
+ 📦 + {t('combat.modal.no_combat_items')} +
+ ) : ( + combatItems.map((item, index) => ( +
onUseItem(item.item_id)}> + {/* Image Section */} +
+ {item.image_path ? ( + {getTranslatedText(item.name)} { + (e.target as HTMLImageElement).style.display = 'none'; + const icon = (e.target as HTMLImageElement).nextElementSibling; + if (icon) icon.classList.remove('hidden'); + }} + /> + ) : null} +
+ {item.emoji || '📦'} +
+ {item.quantity > 1 &&
x{item.quantity}
} +
+ + {/* Info Section */} +
+

+ {getTranslatedText(item.name)} +

+ {item.description && ( +

{getTranslatedText(item.description)}

+ )} + +
+ {/* Logic adapted from InventoryModal to show all relevant stats */} + + {/* Consumables (Priority for combat) */} + {(item.effects?.hp_restore || item.hp_restore) && ( + + ❤️ +{item.effects?.hp_restore || item.hp_restore} HP + + )} + {(item.effects?.stamina_restore || item.stamina_restore) && ( + + ⚡ +{item.effects?.stamina_restore || item.stamina_restore} Stm + + )} + + + {/* Status Effects & Cures */} + {item.effects?.status_effect && ( + + )} + + {item.effects?.cures && item.effects.cures.length > 0 && ( + + 💊 {t('game.cures')}: {item.effects.cures.map((c: string) => getTranslatedText(c)).join(', ')} + + )} + + {/* Combat Effects (Throwables, etc) */} + {item.combat_effects?.damage_min && ( + + 💥 {item.combat_effects.damage_min}-{item.combat_effects.damage_max} Dmg + + )} + {item.combat_effects?.status && ( + + ☠️ {t(`effects.${item.combat_effects.status.name}`, item.combat_effects.status.name) as string} + + )} + + {/* Stats & Unique Stats (If applicable) */} + {(item.unique_stats?.damage_min || item.stats?.damage_min) && ( + + ⚔️ {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max} + + )} + {(item.unique_stats?.armor || item.stats?.armor) && ( + + 🛡️ +{item.unique_stats?.armor || item.stats?.armor} + + )} + {(item.unique_stats?.armor_penetration || item.stats?.armor_penetration) && ( + + 💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} {t('stats.pen') as string} + + )} + {(item.unique_stats?.crit_chance || item.stats?.crit_chance) && ( + + 🎯 +{Math.round((item.unique_stats?.crit_chance || item.stats?.crit_chance) * 100)}% {t('stats.crit') as string} + + )} + {(item.unique_stats?.accuracy || item.stats?.accuracy) && ( + + 👁️ +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% {t('stats.acc') as string} + + )} + {(item.unique_stats?.dodge_chance || item.stats?.dodge_chance) && ( + + 💨 +{Math.round((item.unique_stats?.dodge_chance || item.stats?.dodge_chance) * 100)}% Dodge + + )} + {(item.unique_stats?.lifesteal || item.stats?.lifesteal) && ( + + 🧛 +{Math.round((item.unique_stats?.lifesteal || item.stats?.lifesteal) * 100)}% {t('stats.life') as string} + + )} + + {/* Attributes */} + {(item.unique_stats?.strength_bonus || item.stats?.strength_bonus) && ( + + 💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} {t('stats.str') as string} + + )} + {(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && ( + + 🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} {t('stats.agi') as string} + + )} + {(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && ( + + 🏋️ +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} {t('stats.end') as string} + + )} +
+
+ + {/* Action Button */} + +
+ )) + )} +
+
+
+
+ ); +}; diff --git a/pwa/src/components/game/CombatTypes.ts b/pwa/src/components/game/CombatTypes.ts index 679a6d4..8822901 100644 --- a/pwa/src/components/game/CombatTypes.ts +++ b/pwa/src/components/game/CombatTypes.ts @@ -8,7 +8,7 @@ export interface CombatMessage { export interface FloatingText { id: string; text: string; - type: 'damage' | 'heal' | 'info' | 'crit' | 'miss' | 'xp'; + type: 'damage' | 'heal' | 'info' | 'crit' | 'miss' | 'xp' | 'stamina'; x: number; // Percentage 0-100 y: number; // Percentage 0-100 origin: 'player' | 'enemy'; diff --git a/pwa/src/components/game/CombatView.tsx b/pwa/src/components/game/CombatView.tsx index b3d9dca..f218206 100644 --- a/pwa/src/components/game/CombatView.tsx +++ b/pwa/src/components/game/CombatView.tsx @@ -4,16 +4,19 @@ import { useAudio } from '../../contexts/AudioContext'; import { CombatState, AnimationState, FloatingText } from './CombatTypes'; import { Equipment } from './types'; import './CombatEffects.css'; +import { GameProgressBar } from '../common/GameProgressBar'; interface CombatViewProps { state: CombatState; animState: AnimationState; floatingTexts: FloatingText[]; - onAction: (action: string) => void; + onAction: (action: string, itemId?: string) => void; onClose: () => void; + onShowSupplies: () => void; isProcessing: boolean; combatResult: 'victory' | 'defeat' | 'fled' | null; equipment?: Equipment | any; + playerName?: string; } export const CombatView: React.FC = ({ @@ -22,9 +25,11 @@ export const CombatView: React.FC = ({ floatingTexts, onAction, onClose, + onShowSupplies, isProcessing, combatResult, - equipment + equipment, + playerName }) => { const { t } = useTranslation(); const { playSfx } = useAudio(); @@ -109,10 +114,6 @@ export const CombatView: React.FC = ({ } }, [state.messages]); - const getHealthPercent = (current: number, max: number) => { - return Math.max(0, Math.min(100, (current / max) * 100)); - }; - return (
@@ -158,7 +159,6 @@ export const CombatView: React.FC = ({
{/* Enemy HP (Left) */} - {/* Also shake the stat block on npcHit if desired, or just avatar. User said "both image and health bar should shake" */}
{floatingTexts.filter(ft => ft.origin === 'enemy').map(ft => ( @@ -167,13 +167,15 @@ export const CombatView: React.FC = ({
))}
-
- {t('common.enemy')} - {state.npcHp} / {state.npcMaxHp} -
-
-
-
+
{/* Player HP (Right) */} @@ -185,14 +187,16 @@ export const CombatView: React.FC = ({
))}
- -
- {t('common.you')} - {state.playerHp} / {state.playerMaxHp} -
-
-
-
+ @@ -206,7 +210,7 @@ export const CombatView: React.FC = ({ {t('common.close')} -
+
+ + + +
{/* Overlay for Enemy Turn / Processing */} - {/* Overlay for Enemy Turn / Processing */} - {isProcessing && !combatResult && state.turn === 'enemy' && ( -
- {t('combat.enemy_turn')} -
- )} -
+ { + isProcessing && !combatResult && state.turn === 'enemy' && ( +
+ {t('combat.enemy_turn')} +
+ ) + } + ); }; diff --git a/pwa/src/components/game/EffectBadge.tsx b/pwa/src/components/game/EffectBadge.tsx new file mode 100644 index 0000000..b341bf6 --- /dev/null +++ b/pwa/src/components/game/EffectBadge.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { getTranslatedText } from '../../utils/i18nUtils'; + +interface EffectBadgeProps { + effect: { + name: string | any; + icon?: string; + type?: 'buff' | 'debuff' | 'damage'; + damage_per_tick?: number; + ticks?: number; + }; +} + +export const EffectBadge: React.FC = ({ effect }) => { + const { t } = useTranslation(); + + // Determine class based on type or fallback to damage logic + const badgeClass = effect.type === 'buff' ? 'buff' : 'damage'; + + // For translation of effect name + const effectName = typeof effect.name === 'string' + ? t(`game.effects.${effect.name}`, effect.name) + : getTranslatedText(effect.name); + + return ( + + {effect.icon} + {effect.damage_per_tick ? ( + <> + {effect.damage_per_tick < 0 ? + `+${Math.abs(effect.damage_per_tick)}` : + `-${effect.damage_per_tick}`} HP + {effect.ticks && ` (${effect.ticks})`} + + ) : ( + effectName + )} + + ); +}; diff --git a/pwa/src/components/game/InventoryModal.css b/pwa/src/components/game/InventoryModal.css index 65ef7aa..c0f3319 100644 --- a/pwa/src/components/game/InventoryModal.css +++ b/pwa/src/components/game/InventoryModal.css @@ -356,6 +356,7 @@ line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; + line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } @@ -513,12 +514,19 @@ /* Variant Colors */ .stat-badge.capacity, .stat-badge.endurance, -.stat-badge.health { +.stat-badge.health, +.stat-badge.buff { background-color: rgba(16, 185, 129, 0.2); color: #6ee7b7; border-color: rgba(16, 185, 129, 0.4); } +.stat-badge.cure { + background-color: rgba(45, 212, 191, 0.2); + color: #5eead4; + border-color: rgba(45, 212, 191, 0.4); +} + .stat-badge.damage, .stat-badge.penetration { background-color: rgba(239, 68, 68, 0.2); @@ -662,6 +670,18 @@ white-space: nowrap; } +.action-btn:disabled, +.action-btn.disabled { + opacity: 0.5; + cursor: not-allowed; + filter: grayscale(100%); + pointer-events: none; + background: rgba(144, 144, 144, 0.2) !important; + color: #a0aec0 !important; + border-color: rgba(160, 174, 192, 0.4) !important; + transform: none !important; +} + .action-btn.use { background: rgba(72, 187, 120, 0.2); color: #48bb78; diff --git a/pwa/src/components/game/InventoryModal.tsx b/pwa/src/components/game/InventoryModal.tsx index d33fb10..ff355ad 100644 --- a/pwa/src/components/game/InventoryModal.tsx +++ b/pwa/src/components/game/InventoryModal.tsx @@ -1,10 +1,11 @@ -import { MouseEvent, ChangeEvent } from 'react' +import { MouseEvent, ChangeEvent, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useAudio } from '../../contexts/AudioContext' import { PlayerState, Profile, Equipment } from './types' import { getAssetPath } from '../../utils/assetPath' import { getTranslatedText } from '../../utils/i18nUtils' import './InventoryModal.css' +import { EffectBadge } from './EffectBadge' interface InventoryModalProps { playerState: PlayerState @@ -37,7 +38,22 @@ function InventoryModal({ }: InventoryModalProps) { const { t } = useTranslation() const { playSfx } = useAudio() - // Categories for the sidebar + + // Play sound on mount + useEffect(() => { + playSfx('/audio/sfx/inventory_open.wav') + + // Return cleanup/close sound? usage of "onClose" typically handles it. + // We can't easily do it on unmount if the parent unmounts it instantly. + // But for "close" button click we can play it. + }, []) + + const handleClose = () => { + playSfx('/audio/sfx/inventory_close.wav') + onClose() + } + + // ... existing categories ... const categories = [ { id: 'all', label: t('categories.all'), icon: '🎒' }, { id: 'weapon', label: t('categories.weapon'), icon: '⚔️' }, @@ -213,6 +229,18 @@ function InventoryModal({ ⚡ +{item.stamina_restore} Stm )} + + {/* Status Effects */} + {item.effects?.status_effect && ( + + )} + + {item.effects?.cures && item.effects.cures.length > 0 && ( + + 💊 {t('game.cures')}: {item.effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')} + + )} + {/* Durability Bar */} @@ -248,10 +276,30 @@ function InventoryModal({ {/* Right: Actions */}
{item.consumable && ( - + (() => { + const statusEffect = item.effects?.status_effect; + const isEffectActive = statusEffect && playerState.status_effects.some((e: any) => { + const effectName = typeof e.effect_name === 'string' ? e.effect_name : e.effect_name['en']; + const itemName = typeof statusEffect.name === 'string' ? statusEffect.name : statusEffect.name['en']; + return effectName === itemName; + }); + + return ( + + ); + })() )} {item.equippable && !item.is_equipped && ( {item.quantity >= 5 && ( +
{/* Left Sidebar: Categories */} +
{categories.map(cat => ( @@ -53,22 +51,31 @@ function PlayerSidebar({ src={getAssetPath(item.image_path)} alt={getTranslatedText(item.name)} className="equipment-emoji" - onError={(e) => { - (e.target as HTMLImageElement).style.display = 'none'; - const icon = (e.target as HTMLImageElement).nextElementSibling; - if (icon) icon.classList.remove('hidden'); - }} + style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> - ) : null} - {item.emoji} - {getTranslatedText(item.name)} - {item.durability && item.durability !== null && ( - {item.durability}/{item.max_durability} + ) : ( + {item.emoji} + )} + {item.durability !== undefined && item.durability !== null && ( +
+ +
)}
+
{getTranslatedText(item.name)}
+ {item.tier !== undefined && item.tier !== null && item.tier > 0 && ( +
+ ⭐ Tier: {item.tier} +
+ )} {item.description &&
{getTranslatedText(item.description)}
} - {/* Use unique_stats if available, otherwise fall back to base stats */} {(item.unique_stats || item.stats) && Object.keys(item.unique_stats || item.stats).length > 0 && ( <> {(item.unique_stats?.armor || item.stats?.armor) && ( @@ -106,84 +113,97 @@ function PlayerSidebar({ )} {item.durability !== undefined && item.durability !== null && (
- {t('stats.durability')}: {item.durability}/{item.max_durability} -
- )} - {item.tier !== undefined && item.tier !== null && item.tier > 0 && ( -
- ⭐ Tier: {item.tier} +
+ {t('stats.durability')}: + {item.durability}/{item.max_durability} +
+
)}
) : ( <> - {emoji} - {label} + {label} )}
) - - return (
{/* Profile Stats */}
-

{t('game.character')}

+

+ {profile?.name || 'Character'} (Lv. {profile?.level || 1}) +

-
- {t('stats.hp')} - {playerState.health}/{playerState.max_health} -
-
-
- {Math.round((playerState.health / playerState.max_health) * 100)}% -
+ + {t('stats.hp')} +
+ {playerState.status_effects?.filter((e: any) => e.damage_per_tick !== 0).map((e: any) => ( + 0 ? 'negative' : 'positive'}`} style={{ + color: e.damage_per_tick > 0 ? '#ff6b6b' : '#4caf50', + fontSize: '0.85rem', + fontWeight: 'bold' + }}> + {e.damage_per_tick > 0 ? `-${e.damage_per_tick}` : `+${Math.abs(e.damage_per_tick)}`}/t ({e.ticks_remaining}) + + ))} +
+
+ } + />
-
- {t('stats.stamina')} - {playerState.stamina}/{playerState.max_stamina} -
-
-
- {Math.round((playerState.stamina / playerState.max_stamina) * 100)}% + +
+ +
+ +
+ {Math.floor(((profile?.level || 1) * 100) - (profile?.xp || 0))} XP to next level
{profile && (
-
- {t('stats.level')}: - {profile.level} -
- -
-
- {t('stats.xp')} - {profile.xp} / {(profile.level * 100)} -
-
-
- {Math.round((profile.xp / (profile.level * 100)) * 100)}% -
-
- {profile.unspent_points > 0 && (
{t('stats.unspentPoints')}: @@ -229,86 +249,78 @@ function PlayerSidebar({ {/* Inventory Capacity - matching HP/Stamina/XP style */}
-
- {t('stats.weight')} - {(profile.current_weight || 0).toFixed(1)}/{profile.max_weight || 0}kg -
-
-
- {Math.round(Math.min(100, ((profile.current_weight || 0) / (profile.max_weight || 1)) * 100))}% -
+
-
- {t('stats.volume')} - {(profile.current_volume || 0).toFixed(1)}/{profile.max_volume || 0}L -
-
-
- {Math.round(Math.min(100, ((profile.current_volume || 0) / (profile.max_volume || 1)) * 100))}% -
+
- -
)} + +
- {/* Equipment Display - Proper Grid Layout */}

{t('game.equipment')}

{/* Row 1: Head */}
- {renderEquipmentSlot('head', equipment.head, '🪖', t('equipment.head'))} + {renderEquipmentSlot('head', equipment.head, t('equipment.head'))}
{/* Row 2: Weapon, Torso, Backpack */}
- {renderEquipmentSlot('weapon', equipment.weapon, '⚔️', t('equipment.weapon'))} - {renderEquipmentSlot('torso', equipment.torso, '👕', t('equipment.torso'))} - {renderEquipmentSlot('backpack', equipment.backpack, '🎒', t('equipment.backpack'))} + {renderEquipmentSlot('weapon', equipment.weapon, t('equipment.weapon'))} + {renderEquipmentSlot('torso', equipment.torso, t('equipment.torso'))} + {renderEquipmentSlot('backpack', equipment.backpack, t('equipment.backpack'))}
{/* Row 3: Legs & Feet */}
- {renderEquipmentSlot('legs', equipment.legs, '👖', t('equipment.legs'))} - {renderEquipmentSlot('feet', equipment.feet, '👟', t('equipment.feet'))} + {renderEquipmentSlot('legs', equipment.legs, t('equipment.legs'))} + {renderEquipmentSlot('feet', equipment.feet, t('equipment.feet'))}
- - - {/* Inventory Modal */} {showInventory && profile && ( {tool.emoji} {getTranslatedText(tool.name)} - {tool.has_tool ? `✅ ${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`} + {tool.has_tool ? `✅ ${tool.tool_durability}/${tool.tool_max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
))} diff --git a/pwa/src/components/game/hooks/useGameEngine.ts b/pwa/src/components/game/hooks/useGameEngine.ts index 1ff36ad..0065d7e 100644 --- a/pwa/src/components/game/hooks/useGameEngine.ts +++ b/pwa/src/components/game/hooks/useGameEngine.ts @@ -136,6 +136,7 @@ export interface GameEngineActions { removePlayerFromLocation: (playerId: number) => void addNPCToLocation: (npc: any) => void removeNPCFromLocation: (enemyId: string) => void + updateStatusEffect: (effectName: string | any, remainingTicks: number) => void } export function useGameEngine( @@ -243,7 +244,7 @@ export function useGameEngine( stamina: gameState.player.stamina, max_stamina: gameState.player.max_stamina, inventory: gameState.inventory || [], - status_effects: [] + status_effects: gameState.player.status_effects || [] }) setEquipment(gameState.equipment || {}) @@ -275,7 +276,7 @@ export function useGameEngine( stamina: gameState.player.stamina, max_stamina: gameState.player.max_stamina, inventory: gameState.inventory || [], - status_effects: [] + status_effects: gameState.player.status_effects || [] }) setLocation(locationRes.data) @@ -458,6 +459,42 @@ export function useGameEngine( setLoadedTabs(new Set()) } + const updateStatusEffect = useCallback((effectName: string | any, remainingTicks: number) => { + setPlayerState((prev: PlayerState | null) => { + if (!prev) return null + + if (!prev) return null + const target = typeof effectName === 'object' + ? (effectName.en || Object.values(effectName)[0]) + : effectName + + if (remainingTicks <= 0) { + return { + ...prev, + status_effects: prev.status_effects.filter(e => { + const current = typeof e.effect_name === 'object' + ? (e.effect_name.en || Object.values(e.effect_name)[0]) + : e.effect_name + return current !== target + }) + } + } + + return { + ...prev, + status_effects: prev.status_effects.map(e => { + const current = typeof e.effect_name === 'object' + ? (e.effect_name.en || Object.values(e.effect_name)[0]) + : e.effect_name + if (current === target) { + return { ...e, ticks_remaining: remainingTicks } + } + return e + }) + } + }) + }, []) + // State object const state: GameEngineState = { playerState, @@ -720,8 +757,13 @@ export function useGameEngine( const handleCombatAction = async (action: string) => { try { - // setEnemyTurnMessage('Processing...') // Handled by Combat.tsx now - const response = await api.post('/api/game/combat/action', { action }) + let payload: any = { action } + if (action.includes(':')) { + const [act, itemId] = action.split(':') + payload = { action: act, item_id: itemId } + } + + const response = await api.post('/api/game/combat/action', payload) return response.data } catch (error: any) { setMessage(error.response?.data?.detail || 'Combat action failed') @@ -754,11 +796,19 @@ export function useGameEngine( const handlePvPAction = async (action: string, _targetId: number) => { try { - const response = await api.post('/api/game/pvp/action', { action }) + let payload: any = { action } + if (action.includes(':')) { + const [act, itemId] = action.split(':') + payload = { action: act, item_id: itemId } + } + + const response = await api.post('/api/game/pvp/action', payload) setMessage(response.data.message || 'Action performed!') await fetchGameData() + return response.data // Return data so caller can use it } catch (error: any) { setMessage(error.response?.data?.detail || 'PvP action failed') + throw error // Re-throw so caller knows it failed } } @@ -1086,7 +1136,8 @@ export function useGameEngine( } return newSet }) - } + }, + updateStatusEffect } // Polling fallback for PvP Combat reliability diff --git a/pwa/src/components/game/types.ts b/pwa/src/components/game/types.ts index a390fcb..02daaae 100644 --- a/pwa/src/components/game/types.ts +++ b/pwa/src/components/game/types.ts @@ -8,7 +8,17 @@ export interface PlayerState { stamina: number max_stamina: number inventory: any[] - status_effects: any[] + status_effects: StatusEffect[] +} + +export interface StatusEffect { + id: number + effect_name: string | any + effect_icon: string + effect_type: string + damage_per_tick: number + value: number + ticks_remaining: number } export interface DirectionDetail { @@ -53,6 +63,8 @@ export interface Profile { current_weight?: number max_volume?: number current_volume?: number + + status_effects?: StatusEffect[] } export interface CombatLogEntry { diff --git a/pwa/src/contexts/AudioContext.tsx b/pwa/src/contexts/AudioContext.tsx index 18ed6c2..063f34e 100644 --- a/pwa/src/contexts/AudioContext.tsx +++ b/pwa/src/contexts/AudioContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState } from 'react'; +import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'; import { isElectronApp } from '../utils/assetPath'; interface AudioContextType { @@ -11,12 +11,17 @@ interface AudioContextType { setSfxVolume: (val: number) => void; setIsMuted: (val: boolean) => void; playSfx: (path: string, fallbackPath?: string) => void; + audioContext: AudioContext | null; + getAudioBuffer: (path: string) => Promise; } const AudioContext = createContext(undefined); +// Cache for decoded audio buffers to prevent re-fetching/re-decoding +const bufferCache: Record = {}; + export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - // Initialize state from localStorage or defaults + // Volume State const [masterVolume, setMasterVolumeState] = useState(() => { const saved = localStorage.getItem('audio_masterVolume'); return saved ? parseFloat(saved) : 1.0; @@ -34,7 +39,62 @@ export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ childre return saved ? JSON.parse(saved) : false; }); - // Persistence wrappers + // Web Audio API State + const [audioContext, setAudioContext] = useState(null); + const audioContextRef = useRef(null); // Ref for immediate access in loops/events + const sfxGainNodeRef = useRef(null); + + // Initialize AudioContext on mount + useEffect(() => { + const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext; + const ctx = new AudioContextClass(); + audioContextRef.current = ctx; + setAudioContext(ctx); + + // Create a dedicated GainNode for SFX + const sfxGain = ctx.createGain(); + sfxGain.connect(ctx.destination); + sfxGainNodeRef.current = sfxGain; + + return () => { + if (ctx.state !== 'closed') { + ctx.close(); + } + }; + }, []); + + // Global "Unlock" Listener for Autoplay Policy + useEffect(() => { + const unlockAudio = () => { + const ctx = audioContextRef.current; + if (ctx && ctx.state === 'suspended') { + ctx.resume().then(() => { + console.log('AudioContext resumed via user interaction'); + }).catch(e => console.error('Failed to resume AudioContext:', e)); + } + // Once resumed, we can generally remove these listeners, + // but Chrome sometimes needs multiple checks if it suspends again. + // Usually one accepted interaction is enough for the session. + }; + + const events = ['click', 'touchstart', 'keydown', 'mousedown']; + events.forEach(e => document.addEventListener(e, unlockAudio, { passive: true })); + + return () => { + events.forEach(e => document.removeEventListener(e, unlockAudio)); + }; + }, []); + + // Update SFX Gain when volumes change + useEffect(() => { + if (sfxGainNodeRef.current && audioContextRef.current) { + const effectiveSfxVol = isMuted ? 0 : masterVolume * sfxVolume; + const currentTime = audioContextRef.current.currentTime; + sfxGainNodeRef.current.gain.setTargetAtTime(effectiveSfxVol, currentTime, 0.1); + } + }, [masterVolume, sfxVolume, isMuted]); + + // Volume Setters with Persistence const setMasterVolume = (val: number) => { setMasterVolumeState(val); localStorage.setItem('audio_masterVolume', val.toString()); @@ -55,39 +115,75 @@ export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ childre localStorage.setItem('audio_isMuted', JSON.stringify(val)); }; - const playSfx = (path: string, fallbackPath?: string) => { - if (isMuted) return; + // Helper: Resolve Path + const resolvePath = useCallback((p: string) => { + if (p.startsWith('http') || p.startsWith('file')) return p; + const cleanPath = p.startsWith('/') ? p.slice(1) : p; + return isElectronApp() ? `./${cleanPath}` : `/${cleanPath}`; + }, []); - // Calculate effective volume - const effectiveVolume = masterVolume * sfxVolume; - if (effectiveVolume <= 0) return; + // Helper: Fetch and Decode Audio + const getAudioBuffer = useCallback(async (path: string): Promise => { + const ctx = audioContextRef.current; + if (!ctx) return null; - // Handle path correction for Electron vs Browser - const resolvePath = (p: string) => { - if (p.startsWith('http') || p.startsWith('file')) return p; - // Ensure leading slash for browser, dot slash for electron relative - const cleanPath = p.startsWith('/') ? p.slice(1) : p; - return isElectronApp() ? `./${cleanPath}` : `/${cleanPath}`; - }; + const resolvedPath = resolvePath(path); - const primarySrc = resolvePath(path); - const audio = new Audio(primarySrc); - audio.volume = effectiveVolume; + // Check cache + if (bufferCache[resolvedPath]) { + return bufferCache[resolvedPath]; + } - const playPromise = audio.play(); + try { + const response = await fetch(resolvedPath); + if (!response.ok) throw new Error(`HTTP error ${response.status}`); + const arrayBuffer = await response.arrayBuffer(); + const decodedBuffer = await ctx.decodeAudioData(arrayBuffer); - playPromise.catch((error) => { - // If primary fails (e.g. 404 or format issue), try fallback - console.warn(`SFX failed: ${path}`, error); - if (fallbackPath) { - const fallbackSrc = resolvePath(fallbackPath); - console.log(`Trying fallback SFX: ${fallbackPath}`); - const fallbackAudio = new Audio(fallbackSrc); - fallbackAudio.volume = effectiveVolume; - fallbackAudio.play().catch(e => console.error(`Fallback SFX failed: ${fallbackPath}`, e)); + // Store in cache + bufferCache[resolvedPath] = decodedBuffer; + return decodedBuffer; + } catch (error) { + console.error(`Failed to load audio: ${path}`, error); + return null; + } + }, [resolvePath]); + + // Play SFX + const playSfx = useCallback(async (path: string, fallbackPath?: string) => { + // Early exit if essentially muted + if (isMuted || (masterVolume * sfxVolume) <= 0) return; + + const ctx = audioContextRef.current; + const sfxGain = sfxGainNodeRef.current; + + if (!ctx || !sfxGain) return; + + // Ensure context is running + if (ctx.state === 'suspended') { + try { + await ctx.resume(); + } catch (e) { + // If this fails (e.g. no user gesture yet), we can't play + console.warn('AudioContext suspended, cannot play SFX'); + return; } - }); - }; + } + + let buffer = await getAudioBuffer(path); + + if (!buffer && fallbackPath) { + console.warn(`Primary SFX failed: ${path}, trying fallback: ${fallbackPath}`); + buffer = await getAudioBuffer(fallbackPath); + } + + if (buffer) { + const source = ctx.createBufferSource(); + source.buffer = buffer; + source.connect(sfxGain); + source.start(0); + } + }, [isMuted, masterVolume, sfxVolume, getAudioBuffer]); return ( = ({ childre setMusicVolume, setSfxVolume, setIsMuted, - playSfx + playSfx, + audioContext, + getAudioBuffer }}> {children} diff --git a/pwa/src/i18n/locales/en.json b/pwa/src/i18n/locales/en.json index 9bb27be..a005daa 100644 --- a/pwa/src/i18n/locales/en.json +++ b/pwa/src/i18n/locales/en.json @@ -82,7 +82,15 @@ "durability": "Durability", "noItemsFound": "No items found in this category", "levelDifferenceTooHigh": "Level difference too high", - "areaTooSafeForPvP": "Area too safe for PvP" + "areaTooSafeForPvP": "Area too safe for PvP", + "cures": "Cures", + "effects": { + "regeneration": "Regeneration", + "bleeding": "Bleeding", + "burning": "Burning", + "poisoned": "Poisoned" + }, + "effectAlreadyActive": "Effect already active" }, "location": { "recentActivity": "📜 Recent Activity", @@ -179,12 +187,16 @@ "turnTimer": "Turn Timer", "actions": { "attack": "Attack", + "defend": "Defend", "flee": "Flee", + "supplies": "Supplies", "useItem": "Use Item" }, "status": { "attacking": "Attacking...", + "defending": "Bracing for impact...", "fleeing": "Fleeing...", + "usingItem": "Using item...", "waiting": "Waiting for opponent..." }, "events": { @@ -193,7 +205,11 @@ "playerMiss": "You missed!", "enemyMiss": "Enemy missed!", "armorAbsorbed": "Armor absorbed {{armor}} damage", - "itemBroke": "{{item}} broke!" + "itemBroke": "{{item}} broke!", + "defendSuccess": "You brace yourself, reducing incoming damage!", + "damageReduced": "Defending! Damage reduced by {{reduction}}%", + "itemUsed": "Used {{item}}{{effects}}", + "itemDamage": "{{item}} deals {{damage}} damage!" }, "log": { "combat_start": "Combat started!", @@ -207,7 +223,17 @@ "enemy_miss": "Enemy missed!", "item_broken": "Your {{item}} broke!", "xp_gain": "You gained {{xp}} XP!", - "flee_success": "You managed to escape!" + "flee_success": "You managed to escape!", + "defend": "You brace for impact!", + "item_used": "Used {{item}}", + "effect_applied": "Applied {{effect}} to {{target}}", + "item_damage": "{{item}} deals {{damage}} damage!", + "damage_reduced": "Damage reduced by {{reduction}}%" + }, + "modal": { + "supplies_title": "Combat Supplies", + "no_combat_items": "No combat items available", + "search_items": "Search items..." } }, "equipment": { @@ -271,6 +297,13 @@ "enemyDespawned": "A wandering enemy has left the area", "corpsesDecayed": "{{count}} corpses have decayed", "itemsDecayed": "{{count}} dropped items have decayed", + "statusDamage": "You took {{damage}} damage from status effects", + "statusHeal": "You recovered {{heal}} HP from status effects", + "diedStatus": "You died from status effects", + "wanderingEnemyAppeared": "A wandering enemy left the area", + "staminaRegenerated": "Stamina regenerated", + "combatTimeout": "⏱️ Turn skipped due to timeout!", + "interactableReady": "{{action}} is ready on {{name}}", "waitBeforeMovingSimple": "Wait {{seconds}}s before moving" }, "directions": { diff --git a/pwa/src/i18n/locales/es.json b/pwa/src/i18n/locales/es.json index 7b0ebf2..43bd404 100644 --- a/pwa/src/i18n/locales/es.json +++ b/pwa/src/i18n/locales/es.json @@ -80,7 +80,15 @@ "durability": "Durabilidad", "noItemsFound": "No se encontraron objetos en esta categoría", "levelDifferenceTooHigh": "Nivel demasiado alto", - "areaTooSafeForPvP": "Área demasiado segura para PvP" + "areaTooSafeForPvP": "Área demasiado segura para PvP", + "cures": "Cura", + "effects": { + "regeneration": "Regeneración", + "bleeding": "Sangrado", + "burning": "Quemadura", + "poisoned": "Envenenamiento" + }, + "effectAlreadyActive": "Efecto ya activo" }, "location": { "recentActivity": "📜 Actividad Reciente", @@ -177,12 +185,16 @@ }, "actions": { "attack": "Atacar", + "defend": "Defender", "flee": "Huir", + "supplies": "Suministros", "useItem": "Usar Objeto" }, "status": { "attacking": "Atacando...", + "defending": "Preparándose...", "fleeing": "Huyendo...", + "usingItem": "Usando objeto...", "waiting": "Esperando al oponente..." }, "events": { @@ -191,7 +203,11 @@ "playerMiss": "¡Fallaste!", "enemyMiss": "¡El enemigo falló!", "armorAbsorbed": "La armadura absorbió {{armor}} de daño", - "itemBroke": "¡{{item}} se rompió!" + "itemBroke": "¡{{item}} se rompió!", + "defendSuccess": "¡Te preparas para resistir, reduciendo el daño recibido!", + "damageReduced": "¡Defendiendo! Daño reducido en {{reduction}}%", + "itemUsed": "Usaste {{item}}{{effects}}", + "itemDamage": "{{item}} inflige {{damage}} de daño!" }, "log": { "combat_start": "¡Combate iniciado!", @@ -205,7 +221,17 @@ "enemy_miss": "¡El enemigo falló!", "item_broken": "¡Tu {{item}} se rompió!", "flee_success": "¡Lograste escapar!", - "flee_fail": "¡No pudiste escapar!" + "flee_fail": "¡No pudiste escapar!", + "defend": "¡Te preparas para el impacto!", + "item_used": "Usaste {{item}}", + "effect_applied": "Aplicado {{effect}} a {{target}}", + "item_damage": "{{item}} inflige {{damage}} de daño!", + "damage_reduced": "Daño reducido en {{reduction}}%" + }, + "modal": { + "supplies_title": "Suministros de Combate", + "no_combat_items": "No hay objetos de combate disponibles", + "search_items": "Buscar objetos..." } }, "equipment": { @@ -268,7 +294,14 @@ "enemyAppeared": "¡Un {{name}} ha aparecido!", "enemyDespawned": "Un enemigo errante ha abandonado el área", "corpsesDecayed": "{{count}} cadáveres se han descompuesto", - "itemsDecayed": "{{count}} objetos caídos se han descompuesto", + "itemsDecayed": "{{count}} objeto(s) tirado(s) se han descompuesto", + "statusDamage": "Has recibido {{damage}} de daño por efectos de estado", + "statusHeal": "Has recuperado {{heal}} PS por efectos de estado", + "diedStatus": "Has muerto debido a efectos de estado", + "wanderingEnemyAppeared": "¡Un enemigo errante abandonó el área", + "staminaRegenerated": "Estamina regenerada", + "combatTimeout": "⏱️ ¡Turno saltado por tiempo agotado!", + "interactableReady": "{{action}} está listo en {{name}}", "waitBeforeMovingSimple": "Espera {{seconds}}s antes de moverte" }, "directions": {