Backup before cleanup

This commit is contained in:
Joan
2026-02-05 15:00:49 +01:00
parent e6747b1d05
commit 1b7ffd614d
60 changed files with 3013 additions and 460 deletions

View File

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

View File

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

View File

@@ -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 clean_expired_status_effects():
"""Remove all status effects with <= 0 ticks."""
async with DatabaseSession() as session:
await session.execute(
delete(player_status_effects).where(player_status_effects.c.ticks_remaining <= 0)
)
await session.commit()
async def decrement_all_status_effect_ticks():
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
)
)
)
await session.commit()
async def tick_player_effects(player_id: int) -> List[Dict[str, Any]]:
"""
Decrement ticks for all active status effects and return affected player IDs.
Used by background processor.
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 player IDs with effects before updating
from sqlalchemy import distinct
# Get effects before decrementing
result = await session.execute(
select(distinct(player_status_effects.c.character_id)).where(
select(player_status_effects).where(
and_(
player_status_effects.c.character_id == player_id,
player_status_effects.c.ticks_remaining > 0
)
)
affected_players = [row[0] for row in result.fetchall()]
)
effects = [dict(row._mapping) for row in result.fetchall()]
if not effects:
return []
# Decrement ticks
await session.execute(
update(player_status_effects).where(
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
# ============================================================================

View File

@@ -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,10 +695,10 @@ 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 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)
@@ -612,6 +751,20 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
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)

View File

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

View File

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

View File

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

View File

@@ -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
})
@@ -326,6 +332,10 @@ async def get_player_profile(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 capacity metrics (weight/volume) using the helper function
# We don't need the inventory array itself, just the capacity calculations
_, total_weight, total_volume, max_weight, max_volume = await _get_enriched_inventory(player_id)

View File

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

View File

@@ -79,7 +79,8 @@ class InitiateCombatRequest(BaseModel):
class CombatActionRequest(BaseModel):
action: str # 'attack', 'defend', 'flee'
action: str # 'attack', 'defend', 'flee', 'use_item'
item_id: Optional[str] = None # For use_item action
class PvPCombatInitiateRequest(BaseModel):
@@ -91,7 +92,8 @@ class PvPAcknowledgeRequest(BaseModel):
class PvPCombatActionRequest(BaseModel):
action: str # 'attack', 'defend', 'flee'
action: str # 'attack', 'defend', 'flee', 'use_item'
item_id: Optional[str] = None # For use_item action
# ============================================================================

115
build_log.txt Normal file
View File

@@ -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.<anonymous> (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.<anonymous> (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

90
build_log_2.txt Normal file
View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

BIN
images/items/stimpack.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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;
}
@@ -90,3 +92,43 @@ input:focus, textarea:focus {
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;
}

View File

@@ -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<HTMLAudioElement | null>(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<AudioBufferSourceNode | null>(null);
const musicGainNodeRef = useRef<GainNode | null>(null);
const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(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);
}
const buffer = await getAudioBuffer(src);
if (buffer) {
setAudioBuffer(buffer);
} else {
if (!audio.paused) {
audio.pause();
audio.currentTime = 0; // Reset track when stopping
}
}
} catch (err) {
console.log('Audio playback failed:', err);
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;
}
};
if (playbackError) {
document.addEventListener('click', retryPlay, { once: true });
} 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();
}
};
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 (
<div
@@ -96,7 +172,7 @@ export default function BackgroundMusic() {
bottom: '20px',
right: '20px',
zIndex: 9999,
background: 'rgba(74, 158, 255, 0.9)',
background: 'rgba(255, 74, 74, 0.9)',
color: 'white',
padding: '10px 20px',
borderRadius: '8px',
@@ -106,14 +182,15 @@ export default function BackgroundMusic() {
animation: 'pulse 2s infinite'
}}
onClick={() => {
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'}
</div>
);
}

View File

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

View File

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

View File

@@ -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<GameProgressBarProps> = ({
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 (
<div className="game-progress-container" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '2px' }}>
{showText && (
<div className="progress-text" style={{ fontSize: '0.8rem', color: '#cbd5e0', display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
{effectiveLabelAlign === 'left' ? (
<>
{label && <span style={{ fontWeight: 500 }}>{label}</span>}
<span style={{ fontFamily: 'monospace' }}>{displayValue}/{displayMax}{unit ? ` ${unit}` : ''}</span>
</>
) : (
<>
<span style={{ fontFamily: 'monospace' }}>{displayValue}/{displayMax}{unit ? ` ${unit}` : ''}</span>
{label && <span style={{ fontWeight: 500 }}>{label}</span>}
</>
)}
</div>
)}
<div className="progress-track" style={{
height,
backgroundColor: '#2d3748',
borderRadius: '4px',
overflow: 'hidden',
display: 'flex',
justifyContent: align === 'right' ? 'flex-end' : 'flex-start'
}}>
<div
className={getFillClass()}
style={{
width: `${percentage}%`,
height: '100%',
background: getGradient(),
transition: 'width 0.3s ease'
}}
/>
</div>
</div>
);
};

View File

@@ -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<CombatProps> = ({
const [messageQueue, setMessageQueue] = useState<CombatMessage[]>([]);
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<CombatProps> = ({
}, 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<CombatProps> = ({
}, 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 (
<>
<CombatView
state={localCombatState}
animState={animState}
floatingTexts={floatingTexts}
onAction={isPvP ? handlePvPActionWrapper : handlePvEAction}
onClose={handleCloseWrapper}
onShowSupplies={() => setShowSuppliesModal(true)}
isProcessing={isProcessingQueue}
combatResult={combatResult}
equipment={_equipment}
playerName={profile?.name}
/>
{/* Supplies modal */}
<CombatInventoryModal
isOpen={showSuppliesModal}
onClose={() => setShowSuppliesModal(false)}
onUseItem={handleUseItem}
inventory={playerState?.inventory || []}
/>
</>
);
};

View File

@@ -266,7 +266,11 @@
}
.type-info {
color: #ffff44;
color: #44aaff;
}
.type-stamina {
color: #ffd700;
}
@keyframes float-up {

View File

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

View File

@@ -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<CombatInventoryModalProps> = ({
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 (
<div className="modal-backdrop" onClick={onClose}>
<div className="combat-inventory-modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>{t('combat.modal.supplies_title')}</h3>
<button className="close-btn" onClick={onClose}>&times;</button>
</div>
<div className="modal-body">
<input
type="text"
className="search-input"
placeholder={t('combat.modal.search_items')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
autoFocus
/>
<div className="items-list">
{combatItems.length === 0 ? (
<div className="no-items">
<span style={{ fontSize: '3rem', opacity: 0.5 }}>📦</span>
{t('combat.modal.no_combat_items')}
</div>
) : (
combatItems.map((item, index) => (
<div key={`${item.item_id}-${index}`} className="combat-item-card" onClick={() => onUseItem(item.item_id)}>
{/* Image Section */}
<div className="item-image-section">
{item.image_path ? (
<img
src={getAssetPath(item.image_path)}
alt={getTranslatedText(item.name)}
className="item-img-thumb"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const icon = (e.target as HTMLImageElement).nextElementSibling;
if (icon) icon.classList.remove('hidden');
}}
/>
) : null}
<div className={`item-icon-large ${item.tier ? `tier-${item.tier}` : 'tier-0'} ${item.image_path ? 'hidden' : ''}`}>
{item.emoji || '📦'}
</div>
{item.quantity > 1 && <div className="item-quantity-badge">x{item.quantity}</div>}
</div>
{/* Info Section */}
<div className="item-details">
<h4 className={`item-name text-tier-${item.tier || 0}`}>
{getTranslatedText(item.name)}
</h4>
{item.description && (
<p className="item-description">{getTranslatedText(item.description)}</p>
)}
<div className="item-effects">
{/* Logic adapted from InventoryModal to show all relevant stats */}
{/* Consumables (Priority for combat) */}
{(item.effects?.hp_restore || item.hp_restore) && (
<span className="stat-badge healing">
+{item.effects?.hp_restore || item.hp_restore} HP
</span>
)}
{(item.effects?.stamina_restore || item.stamina_restore) && (
<span className="stat-badge stamina">
+{item.effects?.stamina_restore || item.stamina_restore} Stm
</span>
)}
{/* Status Effects & Cures */}
{item.effects?.status_effect && (
<EffectBadge effect={item.effects.status_effect} />
)}
{item.effects?.cures && item.effects.cures.length > 0 && (
<span className="stat-badge cure">
💊 {t('game.cures')}: {item.effects.cures.map((c: string) => getTranslatedText(c)).join(', ')}
</span>
)}
{/* Combat Effects (Throwables, etc) */}
{item.combat_effects?.damage_min && (
<span className="stat-badge damage">
💥 {item.combat_effects.damage_min}-{item.combat_effects.damage_max} Dmg
</span>
)}
{item.combat_effects?.status && (
<span className="stat-badge damage">
{t(`effects.${item.combat_effects.status.name}`, item.combat_effects.status.name) as string}
</span>
)}
{/* Stats & Unique Stats (If applicable) */}
{(item.unique_stats?.damage_min || item.stats?.damage_min) && (
<span className="stat-badge damage">
{item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}
</span>
)}
{(item.unique_stats?.armor || item.stats?.armor) && (
<span className="stat-badge armor">
🛡 +{item.unique_stats?.armor || item.stats?.armor}
</span>
)}
{(item.unique_stats?.armor_penetration || item.stats?.armor_penetration) && (
<span className="stat-badge penetration">
💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} {t('stats.pen') as string}
</span>
)}
{(item.unique_stats?.crit_chance || item.stats?.crit_chance) && (
<span className="stat-badge crit">
🎯 +{Math.round((item.unique_stats?.crit_chance || item.stats?.crit_chance) * 100)}% {t('stats.crit') as string}
</span>
)}
{(item.unique_stats?.accuracy || item.stats?.accuracy) && (
<span className="stat-badge accuracy">
👁 +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% {t('stats.acc') as string}
</span>
)}
{(item.unique_stats?.dodge_chance || item.stats?.dodge_chance) && (
<span className="stat-badge dodge">
💨 +{Math.round((item.unique_stats?.dodge_chance || item.stats?.dodge_chance) * 100)}% Dodge
</span>
)}
{(item.unique_stats?.lifesteal || item.stats?.lifesteal) && (
<span className="stat-badge lifesteal">
🧛 +{Math.round((item.unique_stats?.lifesteal || item.stats?.lifesteal) * 100)}% {t('stats.life') as string}
</span>
)}
{/* Attributes */}
{(item.unique_stats?.strength_bonus || item.stats?.strength_bonus) && (
<span className="stat-badge strength">
💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} {t('stats.str') as string}
</span>
)}
{(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && (
<span className="stat-badge agility">
🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} {t('stats.agi') as string}
</span>
)}
{(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && (
<span className="stat-badge endurance">
🏋 +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} {t('stats.end') as string}
</span>
)}
</div>
</div>
{/* Action Button */}
<button className="btn-use">
{t('game.use')}
</button>
</div>
))
)}
</div>
</div>
</div>
</div>
);
};

View File

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

View File

@@ -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<CombatViewProps> = ({
@@ -22,9 +25,11 @@ export const CombatView: React.FC<CombatViewProps> = ({
floatingTexts,
onAction,
onClose,
onShowSupplies,
isProcessing,
combatResult,
equipment
equipment,
playerName
}) => {
const { t } = useTranslation();
const { playSfx } = useAudio();
@@ -109,10 +114,6 @@ export const CombatView: React.FC<CombatViewProps> = ({
}
}, [state.messages]);
const getHealthPercent = (current: number, max: number) => {
return Math.max(0, Math.min(100, (current / max) * 100));
};
return (
<div className="combat-container">
@@ -158,7 +159,6 @@ export const CombatView: React.FC<CombatViewProps> = ({
<div className="combat-stats-container">
{/* Enemy HP (Left) */}
{/* Also shake the stat block on npcHit if desired, or just avatar. User said "both image and health bar should shake" */}
<div className={`stat-block enemy ${animState.npcHit ? 'shake-effect' : ''}`}>
<div className="floating-text-layer" style={{ height: '0', overflow: 'visible' }}>
{floatingTexts.filter(ft => ft.origin === 'enemy').map(ft => (
@@ -167,13 +167,15 @@ export const CombatView: React.FC<CombatViewProps> = ({
</div>
))}
</div>
<div className="stat-header">
<span className="stat-label">{t('common.enemy')}</span>
<span className="stat-numbers">{state.npcHp} / {state.npcMaxHp}</span>
</div>
<div className="progress-bar">
<div className="progress-fill health" style={{ width: `${getHealthPercent(state.npcHp, state.npcMaxHp)}%`, background: 'linear-gradient(90deg, #dc3545 0%, #ff6b6b 100%)' }}></div>
</div>
<GameProgressBar
label={state.npcName || t('common.enemy')}
value={state.npcHp}
max={state.npcMaxHp}
type="enemy_health"
showText={true}
height="10px"
labelAlignment="right"
/>
</div>
{/* Player HP (Right) */}
@@ -185,14 +187,16 @@ export const CombatView: React.FC<CombatViewProps> = ({
</div>
))}
</div>
<div className="stat-header">
<span className="stat-label">{t('common.you')}</span>
<span className="stat-numbers">{state.playerHp} / {state.playerMaxHp}</span>
</div>
<div className="progress-bar">
<div className="progress-fill health" style={{ width: `${getHealthPercent(state.playerHp, state.playerMaxHp)}%`, background: 'linear-gradient(90deg, #4caf50 0%, #8bc34a 100%)' }}></div>
</div>
<GameProgressBar
label={playerName || t('common.you')}
value={state.playerHp}
max={state.playerMaxHp}
type="health"
showText={true}
height="10px"
align="right"
labelAlignment="left"
/>
</div>
</div>
@@ -206,7 +210,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
{t('common.close')}
</button>
<div className="combat-actions-group" style={{ display: !combatResult ? 'flex' : 'none', gap: '1rem', width: '100%', justifyContent: 'center' }}>
<div className="combat-actions-group" style={{ display: !combatResult ? 'grid' : 'none', gridTemplateColumns: '1fr 1fr', gap: '0.75rem', width: '100%', maxWidth: '400px', margin: '0 auto' }}>
<button
className="btn btn-attack"
onClick={() => onAction('attack')}
@@ -215,6 +219,22 @@ export const CombatView: React.FC<CombatViewProps> = ({
👊 {t('combat.actions.attack')}
</button>
<button
className="btn btn-defend"
onClick={() => onAction('defend')}
disabled={isProcessing || !state.yourTurn}
>
🛡 {t('combat.actions.defend')}
</button>
<button
className="btn btn-supplies"
onClick={onShowSupplies}
disabled={isProcessing || !state.yourTurn}
>
🎒 {t('combat.actions.supplies')}
</button>
<button
className="btn btn-flee"
onClick={() => onAction('flee')}
@@ -238,6 +258,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
} else {
switch (msg.type) {
case 'combat_start': text = t('combat.start'); break;
case 'combat_timeout': text = t('combat.turn_timeout'); className += " text-warning"; break;
case 'player_attack':
if (msg.origin === 'enemy') {
text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 });
@@ -267,6 +288,18 @@ export const CombatView: React.FC<CombatViewProps> = ({
}
break;
case 'text': text = msg.data?.text || ""; break;
case 'item_used':
text = t('combat.log.item_used', { item: msg.data?.item_name || '' });
if (msg.data?.effects) text += msg.data.effects; // Append effects string if backend still sends it
className += " text-info";
break;
case 'effect_applied':
text = t('combat.log.effect_applied', {
effect: msg.data?.effect_name,
target: msg.data?.target === 'enemy' ? t('common.enemy') : t('common.you')
});
className += " text-warning";
break;
default: text = msg.type;
}
}
@@ -286,12 +319,13 @@ export const CombatView: React.FC<CombatViewProps> = ({
</div>
{/* Overlay for Enemy Turn / Processing */}
{/* Overlay for Enemy Turn / Processing */}
{isProcessing && !combatResult && state.turn === 'enemy' && (
{
isProcessing && !combatResult && state.turn === 'enemy' && (
<div className="turn-overlay">
{t('combat.enemy_turn')}
</div>
)}
</div>
)
}
</div >
);
};

View File

@@ -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<EffectBadgeProps> = ({ 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 (
<span className={`stat-badge ${badgeClass}`}>
{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
)}
</span>
);
};

View File

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

View File

@@ -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
</span>
)}
{/* Status Effects */}
{item.effects?.status_effect && (
<EffectBadge effect={item.effects.status_effect} />
)}
{item.effects?.cures && item.effects.cures.length > 0 && (
<span className="stat-badge cure">
💊 {t('game.cures')}: {item.effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')}
</span>
)}
</div>
{/* Durability Bar */}
@@ -248,10 +276,30 @@ function InventoryModal({
{/* Right: Actions */}
<div className="item-actions-section">
{item.consumable && (
<button className="action-btn use" onClick={() => {
(() => {
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 (
<button
className={`action-btn use ${isEffectActive ? 'disabled' : ''}`}
disabled={isEffectActive}
title={isEffectActive ? t('game.effectAlreadyActive') : ''}
onClick={() => {
if (!isEffectActive) {
playSfx('/audio/sfx/use.wav')
onUseItem(item.item_id, item.id)
}}>{t('game.use')}</button>
}
}}
>
{t('game.use')}
</button>
);
})()
)}
{item.equippable && !item.is_equipped && (
<button className="action-btn equip" onClick={() => {
@@ -271,7 +319,7 @@ function InventoryModal({
playSfx('/audio/sfx/drop.wav')
onDropItem(item.item_id, item.id, 1)
}}>
{item.quantity === 1 ? t('game.drop') : 'x1' }
{item.quantity === 1 ? t('game.drop') : 'x1'}
</button>
{item.quantity >= 5 && (
<button className="action-btn drop" onClick={() => {
@@ -301,7 +349,7 @@ function InventoryModal({
return (
<div className="workbench-overlay" onClick={(e: MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) onClose()
if (e.target === e.currentTarget) handleClose()
}}>
<div className="workbench-menu inventory-modal-redesign">
{/* Top Bar: Capacity & Backpack Info */}
@@ -354,12 +402,13 @@ function InventoryModal({
<span>{t('game.noBackpack')}</span>
</div>
)}
<button className="close-btn" onClick={onClose}></button>
<button className="close-btn" onClick={handleClose}></button>
</div>
</div>
<div className="inventory-main-layout">
{/* Left Sidebar: Categories */}
<div className="inventory-sidebar-filters">
{categories.map(cat => (
<button

View File

@@ -4,6 +4,7 @@ import type { PlayerState, Profile, Equipment } from './types'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
import InventoryModal from './InventoryModal'
import { GameProgressBar } from '../common/GameProgressBar'
interface PlayerSidebarProps {
playerState: PlayerState
@@ -37,13 +38,10 @@ function PlayerSidebar({
onSpendPoint
}: PlayerSidebarProps) {
const [showInventory, setShowInventory] = useState(false)
const { t } = useTranslation()
const renderEquipmentSlot = (slot: string, item: any, emoji: string, label: string) => (
<div className={`equipment-slot ${item ? 'filled' : 'empty'}`}>
const renderEquipmentSlot = (slot: string, item: any, label: string) => (
<div className={`equipment-slot ${item ? 'filled' : 'empty'}`} title={!item ? label : ''}>
{item ? (
<>
<button className="equipment-unequip-btn" onClick={() => onUnequipItem(slot)} title={t('game.unequip')}></button>
@@ -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}
<span className={`equipment-emoji ${item.image_path ? 'hidden' : ''}`}>{item.emoji}</span>
<span className={`equipment-name ${item.tier ? `tier-${item.tier}` : ''}`}>{getTranslatedText(item.name)}</span>
{item.durability && item.durability !== null && (
<span className="equipment-durability">{item.durability}/{item.max_durability}</span>
) : (
<span className="equipment-emoji" style={{ fontSize: '2rem' }}>{item.emoji}</span>
)}
{item.durability !== undefined && item.durability !== null && (
<div className="equipment-durability-bar-container" style={{ width: '90%', marginTop: 'auto', marginBottom: '4px' }}>
<GameProgressBar
value={item.durability}
max={item.max_durability}
type="durability"
height="4px"
showText={false}
/>
</div>
)}
</div>
<div className="equipment-tooltip">
<div className="item-tooltip-name">{getTranslatedText(item.name)}</div>
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
<div className="item-tooltip-stat" style={{ color: '#fbbf24' }}>
Tier: {item.tier}
</div>
)}
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
{/* 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 && (
<div className="item-tooltip-stat">
{t('stats.durability')}: {item.durability}/{item.max_durability}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
<span>{t('stats.durability')}:</span>
<span>{item.durability}/{item.max_durability}</span>
</div>
)}
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
<div className="item-tooltip-stat">
Tier: {item.tier}
<GameProgressBar
value={item.durability}
max={item.max_durability}
type="durability"
height="6px"
showText={false}
/>
</div>
)}
</div>
</>
) : (
<>
<span className="equipment-emoji">{emoji}</span>
<span className="equipment-slot-label">{label}</span>
<img
src={getAssetPath(`/images/placeholder/${slot}_placeholder.webp`)}
alt={label}
className="equipment-placeholder-img"
style={{ width: '100%', height: '100%', objectFit: 'contain', opacity: 0.5 }}
/>
</>
)}
</div>
)
return (
<div className={`right-sidebar mobile-menu-panel ${mobileMenuOpen === 'right' ? 'open' : ''}`}>
{/* Profile Stats */}
<div className="profile-sidebar">
<h3>{t('game.character')}</h3>
<h3 className="sidebar-title">
{profile?.name || 'Character'} <span className="title-level">(Lv. {profile?.level || 1})</span>
</h3>
<div className="sidebar-stat-bars">
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label">{t('stats.hp')}</span>
<span className="sidebar-stat-numbers">{playerState.health}/{playerState.max_health}</span>
<GameProgressBar
value={playerState.health}
max={playerState.max_health}
type="health"
showText={true}
height="10px"
label={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{t('stats.hp')}
<div className="status-indicators" style={{ display: 'flex', gap: '5px' }}>
{playerState.status_effects?.filter((e: any) => e.damage_per_tick !== 0).map((e: any) => (
<span key={e.id} className={`stat-indicator ${e.damage_per_tick > 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})
</span>
))}
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill health"
style={{ width: `${(playerState.health / playerState.max_health) * 100}%` }}
></div>
<span className="progress-percentage">{Math.round((playerState.health / playerState.max_health) * 100)}%</span>
</div>
}
/>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label">{t('stats.stamina')}</span>
<span className="sidebar-stat-numbers">{playerState.stamina}/{playerState.max_stamina}</span>
<GameProgressBar
value={playerState.stamina}
max={playerState.max_stamina}
type="stamina"
showText={true}
height="10px"
label={t('stats.stamina')}
/>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill stamina"
style={{ width: `${(playerState.stamina / playerState.max_stamina) * 100}%` }}
></div>
<span className="progress-percentage">{Math.round((playerState.stamina / playerState.max_stamina) * 100)}%</span>
<div className="sidebar-stat-bar">
<GameProgressBar
value={profile?.xp || 0}
max={(profile?.level || 1) * 100}
type="xp"
showText={true}
height="10px"
label={t('stats.xp')}
/>
<div className="xp-text-detail" style={{ fontSize: '0.7rem', color: '#718096', textAlign: 'right', marginTop: '2px' }}>
{Math.floor(((profile?.level || 1) * 100) - (profile?.xp || 0))} XP to next level
</div>
</div>
</div>
{profile && (
<div className="sidebar-stats">
<div className="sidebar-stat-row">
<span className="sidebar-label">{t('stats.level')}:</span>
<span className="sidebar-value">{profile.level}</span>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label">{t('stats.xp')}</span>
<span className="sidebar-stat-numbers">{profile.xp} / {(profile.level * 100)}</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill xp"
style={{ width: `${(profile.xp / (profile.level * 100)) * 100}%` }}
></div>
<span className="progress-percentage">{Math.round((profile.xp / (profile.level * 100)) * 100)}%</span>
</div>
</div>
{profile.unspent_points > 0 && (
<div className="sidebar-stat-row highlight">
<span className="sidebar-label">{t('stats.unspentPoints')}:</span>
@@ -229,45 +249,43 @@ function PlayerSidebar({
{/* Inventory Capacity - matching HP/Stamina/XP style */}
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label">{t('stats.weight')}</span>
<span className="sidebar-stat-numbers">{(profile.current_weight || 0).toFixed(1)}/{profile.max_weight || 0}kg</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill weight"
style={{ width: `${Math.min(100, ((profile.current_weight || 0) / (profile.max_weight || 1)) * 100)}%` }}
></div>
<span className="progress-percentage">{Math.round(Math.min(100, ((profile.current_weight || 0) / (profile.max_weight || 1)) * 100))}%</span>
</div>
<GameProgressBar
value={profile.current_weight || 0}
max={profile.max_weight || 0}
type="weight"
showText={true}
label={t('stats.weight')}
unit="kg"
height="10px"
/>
</div>
<div className="sidebar-stat-bar">
<div className="sidebar-stat-header">
<span className="sidebar-stat-label">{t('stats.volume')}</span>
<span className="sidebar-stat-numbers">{(profile.current_volume || 0).toFixed(1)}/{profile.max_volume || 0}L</span>
</div>
<div className="sidebar-progress-bar">
<div
className="sidebar-progress-fill volume"
style={{ width: `${Math.min(100, ((profile.current_volume || 0) / (profile.max_volume || 1)) * 100)}%` }}
></div>
<span className="progress-percentage">{Math.round(Math.min(100, ((profile.current_volume || 0) / (profile.max_volume || 1)) * 100))}%</span>
<GameProgressBar
value={profile.current_volume || 0}
max={profile.max_volume || 0}
type="volume"
showText={true}
label={t('stats.volume')}
unit="L"
height="10px"
/>
</div>
</div>
)}
<button
className="open-inventory-btn"
onClick={() => setShowInventory(true)}
style={{
width: '100%',
padding: '1rem',
padding: '0.5rem',
marginTop: '1rem',
backgroundColor: '#2c3e50',
border: '1px solid #34495e',
borderRadius: '8px',
color: '#ecf0f1',
fontSize: '1.1rem',
fontSize: '1rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
@@ -279,36 +297,30 @@ function PlayerSidebar({
{t('game.inventory')}
</button>
</div>
)}
</div>
{/* Equipment Display - Proper Grid Layout */}
<div className="equipment-sidebar">
<h3>{t('game.equipment')}</h3>
<div className="equipment-grid">
{/* Row 1: Head */}
<div className="equipment-row">
{renderEquipmentSlot('head', equipment.head, '🪖', t('equipment.head'))}
{renderEquipmentSlot('head', equipment.head, t('equipment.head'))}
</div>
{/* Row 2: Weapon, Torso, Backpack */}
<div className="equipment-row three-cols">
{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'))}
</div>
{/* Row 3: Legs & Feet */}
<div className="equipment-row two-cols">
{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'))}
</div>
</div>
</div>
{/* Inventory Modal */}
{showInventory && profile && (
<InventoryModal
playerState={playerState}

View File

@@ -305,7 +305,7 @@ function Workbench({
<div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}>
<span>{tool.emoji} {getTranslatedText(tool.name)}</span>
<span>
{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})`}
</span>
</div>
))}

View File

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

View File

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

View File

@@ -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<AudioBuffer | null>;
}
const AudioContext = createContext<AudioContextType | undefined>(undefined);
// Cache for decoded audio buffers to prevent re-fetching/re-decoding
const bufferCache: Record<string, AudioBuffer> = {};
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<AudioContext | null>(null);
const audioContextRef = useRef<AudioContext | null>(null); // Ref for immediate access in loops/events
const sfxGainNodeRef = useRef<GainNode | null>(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;
// Calculate effective volume
const effectiveVolume = masterVolume * sfxVolume;
if (effectiveVolume <= 0) return;
// Handle path correction for Electron vs Browser
const resolvePath = (p: string) => {
// Helper: Resolve Path
const resolvePath = useCallback((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 primarySrc = resolvePath(path);
const audio = new Audio(primarySrc);
audio.volume = effectiveVolume;
// Helper: Fetch and Decode Audio
const getAudioBuffer = useCallback(async (path: string): Promise<AudioBuffer | null> => {
const ctx = audioContextRef.current;
if (!ctx) return null;
const playPromise = audio.play();
const resolvedPath = resolvePath(path);
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));
// Check cache
if (bufferCache[resolvedPath]) {
return bufferCache[resolvedPath];
}
});
};
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);
// 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 (
<AudioContext.Provider value={{
@@ -99,7 +195,9 @@ export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ childre
setMusicVolume,
setSfxVolume,
setIsMuted,
playSfx
playSfx,
audioContext,
getAudioBuffer
}}>
{children}
</AudioContext.Provider>

View File

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

View File

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