8 Commits

Author SHA1 Message Date
Joan
a8dc8211d5 Pre-menu-integration snapshot: combat, crafting, status effects, gamedata updates 2026-03-11 12:43:23 +01:00
Joan
d5afd28eb9 Refactor: unified combat engine for PvE/PvP
- Create api/services/combat_engine.py with all shared combat logic
- Rewrite combat.py from 2820 to ~600 lines (thin orchestration)
- Fix buff consumption: fortify, berserker_rage, evade, foresight, iron_skin now actually work
- Fix stun: PvE skills now write stun to npc_status_effects
- Fix skill damage: now uses stats.attack_power consistently (includes perks)
- Fix PvPCombatActionRequest: add skill_id field for proper PvP skill support
- Remove dead code: PvP skill/item blocks copy-pasted into PvE endpoint
- Update game_logic.npc_attack to check buff modifiers (dodge, damage reduction, etc.)
2026-02-25 12:10:45 +01:00
Joan
540df02ae7 Pre-combat-refactor: current state with PvP sync, boss setup scripts, combat fixes 2026-02-25 12:00:06 +01:00
Joan
bd27404941 chore: Add new location and interactable assets 2026-02-25 10:05:14 +01:00
Joan
6f9ce8b448 feat(frontend): UI polishing, Character Sheet redesign, and translation updates 2026-02-25 10:05:14 +01:00
Joan
fd94387d54 feat(backend): Integrate Derived Stats into combat, loot, and crafting mechanics 2026-02-25 10:05:14 +01:00
Joan
185781d168 feat(backend): Implement base framework for Perks, Skills, and Derived Stats 2026-02-25 10:05:14 +01:00
Joan
aa71a6be7c docs: Update GEMINI and Visuals Guide 2026-02-25 10:05:01 +01:00
90 changed files with 7892 additions and 1330 deletions

53
GEMINI.md Normal file
View File

@@ -0,0 +1,53 @@
# GEMINI.md - Echoes of the Ash
## Project Overview
- **Type**: Dark Fantasy RPG Adventure
- **Stack**: Monorepo with Python/FastAPI backend and React/Vite/TypeScript frontend.
- **Infrastructure**: Docker Compose (Postgres, Redis, Traefik).
- **Primary Target**: Web (PWA + API). Electron is secondary.
## Commands
### Development & Deployment
- **Start (Dev)**: `docker compose up -d`
- **Apply Changes**: `docker compose build && docker compose up -d` (Required for both code and env changes)
- **Restart API**: `docker compose restart echoes_of_the_ashes_api` (This does not apply changes to the code)
- **View Logs**: `docker compose logs -f [service_name]` (e.g., `echoes_of_the_ashes_api`, `echoes_of_the_ashes_pwa`)
### Frontend (PWA)
- **Directory**: `pwa/`
- **Build and run**: `docker compose build echoes_of_the_ashes_pwa && docker compose up -d` (Required for both code and env changes)
### Backend (API)
- **Directory**: `api/`
- **Dependencies**: `requirements.txt`
- **Build and run**: `docker compose build echoes_of_the_ashes_api && docker compose up -d` (Required for both code and env changes)
### Testing
- No automated testing
- Do not use the automated browser
## Architecture & Code Structure
### Backend (`api/`)
- **Entry**: `main.py`
- **Routers**: `routers/` (Modular endpoints: `game_routes.py`, `combat.py`, `auth.py`, etc.)
- **Core**: `core/` (Config, Security, WebSockets)
- **Services**: `services/` (Models, Helpers)
- **Pattern**:
- Use `routers` for new features.
- Register routers in `main.py` (auto-registration logic exists but explicit is clearer).
- Pydantic models in `services/models.py`.
### Frontend (`pwa/`)
- **Entry**: `src/main.tsx`
- **Styling**: Standard CSS files per component (e.g., `components/Game.css`). No Tailwind/Modules. Read `VISUALS_GUIDE.md` for styling guidelines.
- **State**: Zustand stores (`src/stores/`).
- **Translation**: i18next (`src/i18n/`).
- **Reusable Components**: `src/components/common/` (e.g., `Button.tsx`, `Modal.tsx`, etc.)
## Style Guidelines
- **Python**: PEP8 standard. No strict linter enforced.
- **TypeScript**: Standard ESLint rules from Vite template.
- **CSS**: Plain CSS. Keep component styles in dedicated files. Read `VISUALS_GUIDE.md` for styling guidelines.
- **Docs**: update `QUICK_REFERENCE.md` if simplified logic or architecture changes.

164
VISUALS_GUIDE.md Normal file
View File

@@ -0,0 +1,164 @@
# Visual Style Guide - Echoes of the Ash
This document defines the unified visual system for the application, ensuring a consistent, mature "videogame" aesthetic.
## 1. Core Design Philosophy
- **Aesthetic**: **Mature Post-Apocalyptic**. Think "High-Tech Scavenger".
- **Dark & Gritty**: Deep blacks (`#0a0a0a`) mixed with industrial dark greys (`#1a1a20`).
- **Sharp & Distinct**: Avoid overly rounded "Web 2.0" corners. Use chamfered corners (clip-path) for a militaristic/industrial feel.
- **Glassmorphism**: Use semi-transparent backgrounds with blur (`backdrop-filter`) to keep the player connected to the world.
- **Cinematic**: High contrast text, subtle glows on active elements, and distinct borders.
- **Interaction**: **Instant Feedback**.
- **Custom Tooltips**: **MANDATORY**. Do NOT use the HTML `title` attribute. All information must appear instantly in a custom game-styled tooltip anchor.
- **Micro-animations**: Subtle pulses, border glows on hover.
## 2. CSS Variables (Design Tokens)
Defined in `:root`.
### Colors
```css
:root {
/* Backgrounds */
--game-bg-app: #050505; /* Deepest black */
--game-bg-panel: rgba(18, 18, 24, 0.96); /* Almost solid panels */
--game-bg-glass: rgba(10, 10, 15, 0.85); /* Overlays */
--game-bg-slot: rgba(0, 0, 0, 0.5); /* Item slots - darker than panels */
--game-bg-slot-hover: rgba(255, 255, 255, 0.08);
/* Borders / Separators */
--game-border-color: rgba(255, 255, 255, 0.12);
--game-border-active: rgba(255, 255, 255, 0.4);
--game-border-highlight: #ff6b6b; /* Red accent border */
/* Corner Radius - Tighter for mature look */
--game-radius-xs: 2px;
--game-radius-sm: 4px;
--game-radius-md: 6px;
/* Typography */
--game-font-main: 'Saira Condensed', system-ui, sans-serif;
--game-text-primary: #e0e0e0; /* Off-white is better for eyes than pure white */
--game-text-secondary: #94a3b8; /* Cool grey */
--game-text-highlight: #fbbf24; /* Amber/Gold */
--game-text-danger: #ef4444;
/* Semantic Colors (Desaturated/Industrial) */
--game-color-primary: #e11d48; /* Blood Red - Action/Health */
--game-color-stamina: #d97706; /* Amber - Stamina */
--game-color-magic: #3b82f6; /* Blue - Mana/Tech */
--game-color-success: #10b981; /* Emerald - Durability High */
--game-color-warning: #f59e0b; /* Amber - Warning */
/* Rarity Colors */
--rarity-common: #9ca3af;
--rarity-uncommon: #ffffff;
--rarity-rare: #34d399;
--rarity-epic: #60a5fa;
--rarity-legendary: #fbbf24;
/* Effects */
--game-shadow-panel: 0 8px 32px rgba(0, 0, 0, 0.8);
--game-shadow-glow: 0 0 15px rgba(225, 29, 72, 0.3); /* Subtle red glow */
}
```
## 3. Tooltip System (CRITICAL)
**Goal**: Replicate a native game HUD tooltip.
**Rule**: NEVER use `title="Description"`.
### The Component: `<GameTooltip />`
Every interactive element with info must be wrapped or associated with this component.
- **Behavior**: Appears instantly on hover (0ms delay). Follows cursor OR anchored to element.
- **Visuals**:
- Background: Solid dark (`#0f0f12`) with high opacity (98%).
- Border: Thin accent border (`1px solid --game-border-color`).
- Shadow: Strong drop shadow to separate from UI.
- Content: Supports HTML (rich text, stats, icons).
```tsx
// Usage Example
<GameTooltip content={<ItemStats item={sword} />}>
<button className="game-slot">
<img src="sword.png" />
</button>
</GameTooltip>
## 4. Reusable Components
Use these React components located in `pwa/src/components/common/` to ensure visual consistency.
### Buttons: `<GameButton />`
**Look**: Rectangular, tactile, with optional mecha-cut corners.
- **Variants**: `primary`, `secondary`, `success`, `danger`, `info`, `warning`.
- **Sizes**: `sm`, `md`, `lg`.
- **States**: Auto-handles hover glows and active transforms.
```tsx
<GameButton variant="success" size="md" onClick={handleSave}>
Save Game
</GameButton>
```
### Tooltips: `<GameTooltip />`
**Rule**: NEVER use the HTML `title` attribute.
- **Behavior**: Appears instantly (0ms), follows cursor. Rendering via React Portal avoids z-index clipping.
- **Usage**: Wrap any interactive element.
```tsx
<GameTooltip content="Drains 10 Stamina">
<button className="ability-slot">Dash</button>
</GameTooltip>
```
### Item Content: `<ItemTooltipContent />`
Specialized sub-component for rich item data (stats, weight, durability bars).
- **Props**: `item`, `showValue` (for trading), `showDurability`.
```tsx
<GameTooltip content={<ItemTooltipContent item={sword} />}>
<div className="inventory-slot">...</div>
</GameTooltip>
```
### Progress Bars: `<GameProgressBar />`
For health, stamina, XP, weight, and volume.
- **Types**: `health`, `stamina`, `xp`, `weight`, `volume`, `durability`.
- **Modes**: Supports `showText`, custom `label`, and alignment.
- **Logic**: Auto-colors (e.g., weight turns red as it nears capacity).
```tsx
<GameProgressBar
value={80}
max={100}
type="health"
showText
label="Health"
/>
```
### Dropdowns: `<GameDropdown />`
Contextual menus that render outside the DOM hierarchy.
- **Features**: Auto-positioning (flips up if near bottom), click-outside to close.
```tsx
<GameDropdown isOpen={menuOpen} onClose={() => setMenuOpen(false)}>
<div className="menu-item">Inspect</div>
<div className="menu-item">Discard</div>
</GameDropdown>
```
### Notifications: `<NotificationContainer />`
Global toast system. Used via `useNotification` hook.
- **Types**: `success`, `error`, `info`, `warning`.
- **Visuals**: Slide-in/out animations, industrial styling.
## 5. UI Layout Patterns
### Panels & Containers (`.game-panel`)
Use this class for modals and sidebars.
- **Visuals**: 96% opacity dark panels, 8px blur, 1px subtle border.
### Grid Slots (`.game-slot`)
Consistent 1:1 ratio slots for items or abilities. Darker background than panels to create depth.
---
*Updated: Feb 16, 2026*

26
add_boss.py Normal file
View File

@@ -0,0 +1,26 @@
import json
with open('gamedata/npcs.json', 'r') as f:
data = json.load(f)
if 'test_boss' not in data['npcs']:
data['npcs']['test_boss'] = {
'name': {'en': 'Level 50 Test Boss', 'es': 'Jefe de Prueba Nivel 50'},
'description': {'en': 'A huge terrifying monster.', 'es': 'Un monstruo enorme y aterrador.'},
'emoji': '👹',
'hp_min': 1000,
'hp_max': 1500,
'damage_min': 25,
'damage_max': 45,
'defense': 15,
'xp_reward': 500,
'loot_table': [],
'flee_chance": 0.0,
'status_inflict_chance': 0.5,
'death_message': {'en': 'The boss is defeated.', 'es': 'El jefe ha sido derrotado.'}
}
with open('gamedata/npcs.json', 'w') as f:
json.dump(data, f, indent=2)
print("Boss added.")
else:
print("Boss exists.")

View File

@@ -308,6 +308,16 @@ player_statistics = Table(
) )
character_perks = Table(
"character_perks",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False),
Column("perk_id", String(50), nullable=False),
Column("acquired_at", Float, nullable=False),
UniqueConstraint("character_id", "perk_id", name="uix_character_perk")
)
# ======================================================================== # ========================================================================
# QUESTS AND TRADE TABLES # QUESTS AND TRADE TABLES
# ======================================================================== # ========================================================================
@@ -2937,3 +2947,47 @@ async def acknowledge_pvp_combat(combat_id: int, player_id: int) -> bool:
await session.commit() await session.commit()
return True return True
# ========================================================================
# CHARACTER PERKS
# ========================================================================
async def get_character_perks(character_id: int) -> list:
"""Get all perks owned by a character."""
async with DatabaseSession() as session:
stmt = select(character_perks).where(
character_perks.c.character_id == character_id
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result.fetchall()]
async def add_character_perk(character_id: int, perk_id: str) -> bool:
"""Add a perk to a character. Returns False if already owned."""
import time
async with DatabaseSession() as session:
try:
stmt = insert(character_perks).values(
character_id=character_id,
perk_id=perk_id,
acquired_at=time.time()
)
await session.execute(stmt)
await session.commit()
return True
except Exception:
return False
async def remove_character_perk(character_id: int, perk_id: str) -> bool:
"""Remove a perk from a character."""
async with DatabaseSession() as session:
stmt = delete(character_perks).where(
character_perks.c.character_id == character_id,
character_perks.c.perk_id == perk_id
)
result = await session.execute(stmt)
await session.commit()
return result.rowcount > 0

View File

@@ -208,12 +208,17 @@ async def interact_with_object(
items_dropped = [] items_dropped = []
damage_taken = outcome.damage_taken damage_taken = outcome.damage_taken
# Calculate current capacity # Calculate current capacity and fetch derived stats
from api.services.helpers import calculate_player_capacity from api.services.helpers import calculate_player_capacity
from api.services.stats import calculate_derived_stats
from api.items import items_manager as ITEMS_MANAGER from api.items import items_manager as ITEMS_MANAGER
inventory = await db.get_inventory(player_id) inventory = await db.get_inventory(player_id)
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER) current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
stats = await calculate_derived_stats(player_id)
loot_quality = stats.get('loot_quality', 1.0)
# Add items to inventory (or drop if over capacity) # Add items to inventory (or drop if over capacity)
for item_id, quantity in outcome.items_reward.items(): for item_id, quantity in outcome.items_reward.items():
item = items_manager.get_item(item_id) item = items_manager.get_item(item_id)
@@ -258,7 +263,12 @@ async def interact_with_object(
await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id) await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id)
items_dropped.append(f"{emoji} {item_name}") items_dropped.append(f"{emoji} {item_name}")
else: else:
# Stackable items - process as before # Stackable items - apply loot quality bonus for resources and consumables
if getattr(item, 'category', item.type) in ['resource', 'consumable'] and loot_quality > 1.0:
bonus_chance = loot_quality - 1.0
if random.random() < bonus_chance:
quantity += 1
item_weight = item.weight * quantity item_weight = item.weight * quantity
item_volume = item.volume * quantity item_volume = item.volume * quantity
@@ -312,6 +322,15 @@ async def use_item(player_id: int, item_id: str, items_manager, locale: str = 'e
if not player: if not player:
return {"success": False, "message": "Player not found"} return {"success": False, "message": "Player not found"}
# Get derived stats for item effectiveness
# In some paths redis_manager might not be injected, so we attempt to fetch it from websockets module if needed,
# or let stats service fetch without cache
from api.services.stats import calculate_derived_stats
import api.core.websockets as ws
redis_mgr = getattr(ws.manager, 'redis_manager', None)
stats = await calculate_derived_stats(player['id'], redis_mgr)
item_effectiveness = stats.get('item_effectiveness', 1.0)
# Check if player has the item # Check if player has the item
inventory = await db.get_inventory(player_id) inventory = await db.get_inventory(player_id)
item_entry = None item_entry = None
@@ -385,7 +404,8 @@ async def use_item(player_id: int, item_id: str, items_manager, locale: str = 'e
# 3. Direct Healing (Legacy/Instant) # 3. Direct Healing (Legacy/Instant)
if 'hp_restore' in item.effects: if 'hp_restore' in item.effects:
hp_restore = item.effects['hp_restore'] base_hp_restore = item.effects['hp_restore']
hp_restore = int(base_hp_restore * item_effectiveness)
old_hp = player['hp'] old_hp = player['hp']
new_hp = min(player['max_hp'], old_hp + hp_restore) new_hp = min(player['max_hp'], old_hp + hp_restore)
actual_restored = new_hp - old_hp actual_restored = new_hp - old_hp
@@ -395,7 +415,8 @@ async def use_item(player_id: int, item_id: str, items_manager, locale: str = 'e
effects_msg.append(f"+{actual_restored} HP") effects_msg.append(f"+{actual_restored} HP")
if 'stamina_restore' in item.effects: if 'stamina_restore' in item.effects:
stamina_restore = item.effects['stamina_restore'] base_stamina_restore = item.effects['stamina_restore']
stamina_restore = int(base_stamina_restore * item_effectiveness)
old_stamina = player['stamina'] old_stamina = player['stamina']
new_stamina = min(player['max_stamina'], old_stamina + stamina_restore) new_stamina = min(player['max_stamina'], old_stamina + stamina_restore)
actual_restored = new_stamina - old_stamina actual_restored = new_stamina - old_stamina
@@ -532,6 +553,14 @@ async def check_and_apply_level_up(player_id: int) -> Dict[str, Any]:
unspent_points=new_unspent_points unspent_points=new_unspent_points
) )
# Invalidate cached derived stats (level affects max_hp, max_stamina, attack_power, etc.)
from api.services.stats import invalidate_stats_cache
try:
from api.core.websockets import manager as ws_manager
await invalidate_stats_cache(player_id, getattr(ws_manager, 'redis_manager', None))
except Exception:
pass
return { return {
"leveled_up": True, "leveled_up": True,
"new_level": current_level, "new_level": current_level,
@@ -570,29 +599,85 @@ def generate_npc_intent(npc_def, combat_state: dict) -> dict:
Generate the NEXT intent for an NPC. Generate the NEXT intent for an NPC.
Returns a dict with intent type and details. Returns a dict with intent type and details.
""" """
# Default intent is attack import random
intent = {"type": "attack", "value": 0} from api.services.skills import skills_manager
# Logic could be more complex based on NPC type, HP, etc. npc_hp_pct = combat_state['npc_hp'] / combat_state['npc_max_hp'] if combat_state['npc_max_hp'] > 0 else 0
roll = random.random() skills = getattr(npc_def, 'skills', [])
# 20% chance to defend if HP < 50% active_effects = combat_state.get('npc_status_effects', '')
if (combat_state['npc_hp'] / combat_state['npc_max_hp'] < 0.5) and roll < 0.2:
intent = {"type": "defend", "value": 0} cooldowns = {}
# 15% chance for special attack (if defined, otherwise strong attack) if active_effects:
elif roll < 0.35: for eff in active_effects.split('|'):
intent = {"type": "special", "value": 0} if eff.startswith('cd_'):
else: parts = eff.split(':')
intent = {"type": "attack", "value": 0} if len(parts) >= 2:
cooldowns[parts[0][3:]] = int(parts[1])
available_skills = []
has_heal = None
has_buff = None
damage_skills = []
for skill_id in skills:
if cooldowns.get(skill_id, 0) > 0:
continue
skill = skills_manager.get_skill(skill_id)
if not skill: continue
available_skills.append(skill)
return intent if 'heal_percent' in skill.effects:
has_heal = skill
elif 'buff' in skill.effects:
has_buff = skill
else:
damage_skills.append(skill)
# 1. Survival First
if has_heal and npc_hp_pct < 0.3:
if random.random() < 0.8:
return {"type": "skill", "value": has_heal.id}
# 2. Buffs
if has_buff:
buff_name = has_buff.effects['buff']
is_buff_active = False
if active_effects:
for eff in active_effects.split('|'):
if eff.startswith(buff_name + ':'):
is_buff_active = True
break
if not is_buff_active and random.random() < 0.6:
return {"type": "skill", "value": has_buff.id}
# 3. Telegraphed Attack Check (15% chance if health > 30%)
if npc_hp_pct > 0.3 and random.random() < 0.15:
return {"type": "charge", "value": "charging_attack"}
# 4. Damage Skills
if damage_skills and random.random() < 0.4:
chosen = random.choice(damage_skills)
return {"type": "skill", "value": chosen.id}
# Default to attack or defend (legacy logic)
roll = random.random()
if npc_hp_pct < 0.5 and roll < 0.1:
return {"type": "defend", "value": 0}
return {"type": "attack", "value": 0}
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -> Tuple[List[dict], bool]: async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, player_stats: dict = None, locale: str = 'en') -> Tuple[List[dict], bool]:
""" """
Execute NPC turn based on PREVIOUS intent, then generate NEXT intent. Execute NPC turn based on PREVIOUS intent, then generate NEXT intent.
Returns: (messages_list, player_defeated) Returns: (messages_list, player_defeated)
""" """
import random
import time
from api import database as db
from api.services.helpers import create_combat_message, get_game_message, get_locale_string
from api.services.skills import skills_manager
player = await db.get_player_by_id(player_id) player = await db.get_player_by_id(player_id)
if not player: if not player:
return [], True return [], True
@@ -603,10 +688,9 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
npc_hp = combat['npc_hp'] npc_hp = combat['npc_hp']
npc_max_hp = combat['npc_max_hp'] npc_max_hp = combat['npc_max_hp']
npc_status_str = combat.get('npc_status_effects', '') npc_status_str = combat.get('npc_status_effects', '')
is_stunned = False
if npc_status_str: 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('|') effects_list = npc_status_str.split('|')
active_effects = [] active_effects = []
npc_damage_taken = 0 npc_damage_taken = 0
@@ -616,12 +700,33 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
if not effect_str: continue if not effect_str: continue
try: try:
parts = effect_str.split(':') parts = effect_str.split(':')
name = parts[0]
if name == 'stun' and len(parts) >= 2:
ticks = int(parts[1])
if ticks > 0:
is_stunned = True
messages.append(create_combat_message(
"skill_effect",
origin="enemy",
message=get_game_message('npc_stunned_cannot_act', locale, npc_name=get_locale_string(npc_def.name, locale))
))
ticks -= 1
if ticks > 0:
active_effects.append(f"stun:{ticks}")
continue
if name.startswith('cd_') and len(parts) >= 3:
ticks = int(parts[2])
ticks -= 1
if ticks > 0:
active_effects.append(f"{name}:{parts[1]}:{ticks}")
continue
if len(parts) >= 3: if len(parts) >= 3:
name = parts[0]
dmg = int(parts[1]) dmg = int(parts[1])
ticks = int(parts[2]) ticks = int(parts[2])
# Apply effect
if ticks > 0: if ticks > 0:
if dmg > 0: if dmg > 0:
npc_damage_taken += dmg npc_damage_taken += dmg
@@ -636,196 +741,210 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
heal = abs(dmg) heal = abs(dmg)
npc_healing_received += heal npc_healing_received += heal
messages.append(create_combat_message( messages.append(create_combat_message(
"effect_heal", # Check if this message type exists or fallback "effect_heal",
origin="enemy", origin="enemy",
heal=heal, heal=heal,
effect_name=name, effect_name=name,
npc_name=npc_def.name npc_name=npc_def.name
)) ))
elif name in ["berserker_rage", "fortify", "analyzed"]:
pass
# Decrement tick
ticks -= 1 ticks -= 1
if ticks > 0: if ticks > 0:
active_effects.append(f"{name}:{dmg}:{ticks}") active_effects.append(f"{name}:{dmg}:{ticks}")
except Exception as e: except Exception as e:
print(f"Error parsing NPC status: {e}") print(f"Error parsing NPC status: {e}")
# Update NPC active effects
new_status_str = "|".join(active_effects) new_status_str = "|".join(active_effects)
if new_status_str != npc_status_str: if new_status_str != npc_status_str:
await db.update_combat(player_id, {'npc_status_effects': new_status_str}) await db.update_combat(player_id, {'npc_status_effects': new_status_str})
# Apply Total Damage/Healing
if npc_damage_taken > 0: if npc_damage_taken > 0:
npc_hp = max(0, npc_hp - npc_damage_taken) npc_hp = max(0, npc_hp - npc_damage_taken)
if npc_healing_received > 0: if npc_healing_received > 0:
npc_hp = min(npc_max_hp, npc_hp + npc_healing_received) 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}) await db.update_combat(player_id, {'npc_hp': npc_hp})
# Check if NPC died from effects
if npc_hp <= 0: if npc_hp <= 0:
messages.append(create_combat_message( messages.append(create_combat_message("victory", origin="neutral", npc_name=npc_def.name))
"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 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') current_intent_str = combat.get('npc_intent', 'attack')
# Handle legacy/null
if not current_intent_str: if not current_intent_str:
current_intent_str = 'attack' current_intent_str = 'attack'
intent_type = current_intent_str intent_parts = current_intent_str.split(':')
intent_type = intent_parts[0]
intent_value = intent_parts[1] if len(intent_parts) > 1 else None
actual_damage = 0 actual_damage = 0
new_player_hp = player['hp']
# EXECUTE INTENT
if npc_hp > 0: # Only attack if alive if npc_hp > 0 and not is_stunned:
if intent_type == 'defend': if intent_type == 'defend':
# NPC defends - heals 5% HP
heal_amount = int(combat['npc_max_hp'] * 0.05) heal_amount = int(combat['npc_max_hp'] * 0.05)
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount) new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
await db.update_combat(player_id, {'npc_hp': new_npc_hp}) await db.update_combat(player_id, {'npc_hp': new_npc_hp})
messages.append(create_combat_message("enemy_defend", origin="enemy", npc_name=npc_def.name, heal=heal_amount))
elif intent_type == 'charge':
messages.append(create_combat_message( messages.append(create_combat_message(
"enemy_defend", "skill_effect", origin="enemy", message=get_game_message('enemy_charging', locale, enemy=get_locale_string(npc_def.name, locale))
origin="enemy",
npc_name=npc_def.name,
heal=heal_amount
)) ))
elif intent_type == 'special': elif intent_type in ('charging_attack', 'special', 'attack', 'skill'):
# Strong attack (1.5x damage)
npc_damage = int(random.randint(npc_def.damage_min, npc_def.damage_max) * 1.5)
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
actual_damage = max(1, npc_damage - armor_absorbed)
new_player_hp = max(0, player['hp'] - actual_damage)
messages.append(create_combat_message(
"enemy_special",
origin="enemy",
npc_name=npc_def.name,
damage=npc_damage,
armor_absorbed=armor_absorbed
))
if broken_armor:
for armor in broken_armor:
messages.append(create_combat_message(
"item_broken",
origin="player",
item_name=armor['name'],
emoji=armor['emoji']
))
await db.update_player(player_id, hp=new_player_hp)
else: # Default 'attack'
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max) npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
skill = None
is_charging = intent_type == 'charging_attack'
# Enrage bonus if NPC is below 30% HP if intent_type == 'charging_attack':
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3 npc_damage = int(npc_damage * 2.5)
if is_enraged: elif intent_type == 'special':
npc_damage = int(npc_damage * 1.5) npc_damage = int(npc_damage * 1.5)
messages.append(create_combat_message( elif intent_type == 'skill' and intent_value:
"enemy_enraged", skill = skills_manager.get_skill(intent_value)
origin="enemy", if skill:
npc_name=npc_def.name if skill.cooldown > 0:
)) cd_str = f"cd_{skill.id}:0:{skill.cooldown}"
curr_combat = await db.get_active_combat(player_id)
# Check if player is defending (reduces damage by value%) curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else ''
player_effects = await db.get_player_effects(player_id) new_status = curr_status + f"|{cd_str}" if curr_status else cd_str
defending_effect = next((e for e in player_effects if e['effect_name'] == 'defending'), None) await db.update_combat(player_id, {'npc_status_effects': new_status})
if defending_effect:
reduction = defending_effect.get('value', 50) / 100 # Default 50% reduction
npc_damage = int(npc_damage * (1 - reduction))
messages.append(create_combat_message(
"damage_reduced",
origin="player",
reduction=int(reduction * 100)
))
# Remove defending effect after use
await db.remove_effect(player_id, 'defending')
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
actual_damage = max(1, npc_damage - armor_absorbed)
new_player_hp = max(0, player['hp'] - actual_damage)
messages.append(create_combat_message(
"enemy_attack",
origin="enemy",
npc_name=npc_def.name,
damage=npc_damage,
armor_absorbed=armor_absorbed
))
if broken_armor:
for armor in broken_armor:
messages.append(create_combat_message(
"item_broken",
origin="player",
item_name=armor['name'],
emoji=armor['emoji']
))
await db.update_player(player_id, hp=new_player_hp) effects = skill.effects
if 'heal_percent' in effects:
heal_amount = int(combat['npc_max_hp'] * effects['heal_percent'])
new_npc_hp = min(combat['npc_max_hp'], npc_hp + heal_amount)
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
messages.append(create_combat_message("skill_heal", origin="enemy", heal=heal_amount, skill_icon=skill.icon, skill_name=get_locale_string(skill.name, locale), npc_name=npc_def.name))
npc_damage = 0
if 'buff' in effects:
buff_str = f"{effects['buff']}:0:{effects['buff_duration']}"
curr_combat = await db.get_active_combat(player_id)
curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else ''
new_status = curr_status + f"|{buff_str}" if curr_status else buff_str
await db.update_combat(player_id, {'npc_status_effects': new_status})
messages.append(create_combat_message("skill_buff", origin="enemy", skill_name=get_locale_string(skill.name, locale), skill_icon=skill.icon, duration=effects['buff_duration'], npc_name=npc_def.name))
if 'damage_multiplier' not in effects and 'poison_damage' not in effects:
npc_damage = 0
if 'damage_multiplier' in effects:
npc_damage = max(1, int(npc_damage * effects['damage_multiplier']))
from api.services.helpers import calculate_dynamic_status_damage
poison_dmg = calculate_dynamic_status_damage(effects, 'poison', player)
if poison_dmg is not None:
await db.add_effect(player_id=player_id, effect_name="Poison", effect_icon="🧪", effect_type="damage", damage_per_tick=poison_dmg, ticks_remaining=effects.get('poison_duration', 3), persist_after_combat=True, source=f"enemy_skill:{skill.id}")
burn_dmg = calculate_dynamic_status_damage(effects, 'burn', player)
if burn_dmg is not None:
await db.add_effect(player_id=player_id, effect_name="Burning", effect_icon="🔥", effect_type="damage", damage_per_tick=burn_dmg, ticks_remaining=effects.get('burn_duration', 3), persist_after_combat=True, source=f"enemy_skill:{skill.id}")
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3
if is_enraged and npc_damage > 0:
npc_damage = int(npc_damage * 1.5)
messages.append(create_combat_message("enemy_enraged", origin="enemy", npc_name=npc_def.name))
curr_combat = await db.get_active_combat(player_id)
curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else ''
if 'berserker_rage' in curr_status and npc_damage > 0:
npc_damage = int(npc_damage * 1.5)
if npc_damage > 0:
dodged = False
is_defending = False
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:
is_defending = True
reduction = defending_effect.get('value', 50) / 100
npc_damage = max(1, int(npc_damage * (1 - reduction)))
messages.append(create_combat_message("damage_reduced", origin="player", reduction=int(reduction * 100)))
await db.remove_effect(player_id, 'defending')
buff_dmg_reduction = player_stats.get('buff_damage_reduction', 0.0) if player_stats else 0.0
if buff_dmg_reduction > 0:
npc_damage = max(1, int(npc_damage * (1 - buff_dmg_reduction)))
messages.append(create_combat_message("damage_reduced", origin="player", reduction=int(buff_dmg_reduction * 100)))
buff_dmg_taken_increase = player_stats.get('buff_damage_taken_increase', 0.0) if player_stats else 0.0
if buff_dmg_taken_increase > 0:
npc_damage = int(npc_damage * (1 + buff_dmg_taken_increase))
if player_stats and player_stats.get('buff_guaranteed_dodge', False):
dodged = True
messages.append(create_combat_message("combat_dodge", origin="player"))
await db.remove_effect(player_id, 'evade')
elif player_stats and player_stats.get('buff_enemy_miss', False):
dodged = True
messages.append(create_combat_message("combat_dodge", origin="player"))
elif player_stats and 'dodge_chance' in player_stats and random.random() < player_stats['dodge_chance']:
dodged = True
messages.append(create_combat_message("combat_dodge", origin="player"))
if not dodged and player_stats and player_stats.get('has_shield', False) and random.random() < player_stats.get('block_chance', 0):
messages.append(create_combat_message("combat_block", origin="player"))
npc_damage = max(1, int(npc_damage * 0.2))
if not dodged:
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage, is_defending)
if player_stats and player_stats.get('armor_reduction', 0) > 0:
pct_reduction = player_stats['armor_reduction']
actual_damage = max(1, int(npc_damage * (1 - pct_reduction)))
armor_absorbed_visual = npc_damage - actual_damage
else:
actual_damage = max(1, npc_damage - armor_absorbed)
armor_absorbed_visual = armor_absorbed
new_player_hp = max(0, player['hp'] - actual_damage)
if skill and 'damage_multiplier' in skill.effects:
messages.append(create_combat_message("skill_attack", origin="enemy", damage=actual_damage, skill_name=get_locale_string(skill.name, locale), skill_icon=skill.icon, hits=1))
elif is_charging:
messages.append(create_combat_message("enemy_special", origin="enemy", npc_name=npc_def.name, damage=actual_damage, armor_absorbed=armor_absorbed_visual))
else:
messages.append(create_combat_message("enemy_attack", origin="enemy", npc_name=npc_def.name, damage=actual_damage, armor_absorbed=armor_absorbed_visual))
if broken_armor:
for armor in broken_armor:
messages.append(create_combat_message("item_broken", origin="player", item_name=armor['name'], emoji=armor['emoji']))
await db.update_player(player_id, hp=new_player_hp)
# GENERATE NEXT INTENT
# Check if player defeated
player_defeated = False player_defeated = False
if player['hp'] - actual_damage <= 0 and intent_type != 'defend': # Check HP after damage if new_player_hp <= 0 and intent_type != 'defend' and intent_type != 'charge':
# Re-fetch to be sure or just trust calculation messages.append(create_combat_message("player_defeated", origin="neutral", npc_name=npc_def.name))
if new_player_hp <= 0: player_defeated = True
messages.append(create_combat_message( await db.update_player(player_id, hp=0, is_dead=True)
"player_defeated", await db.update_player_statistics(player_id, deaths=1, damage_taken=actual_damage, increment=True)
origin="neutral", await db.end_combat(player_id)
npc_name=npc_def.name return messages, player_defeated
))
player_defeated = True
await db.update_player(player_id, hp=0, is_dead=True)
await db.update_player_statistics(player_id, deaths=1, damage_taken=actual_damage, increment=True)
await db.end_combat(player_id)
return messages, player_defeated
if not player_defeated: if actual_damage > 0:
if actual_damage > 0: await db.update_player_statistics(player_id, damage_taken=actual_damage, increment=True)
await db.update_player_statistics(player_id, damage_taken=actual_damage, increment=True)
current_npc_hp = combat['npc_hp']
# Generate NEXT intent if intent_type == 'defend':
# We need the updated NPC HP for the logic current_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + int(combat['npc_max_hp'] * 0.05))
current_npc_hp = combat['npc_hp']
if intent_type == 'defend': temp_combat_state = combat.copy()
current_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + int(combat['npc_max_hp'] * 0.05)) temp_combat_state['npc_hp'] = current_npc_hp
temp_combat_state = combat.copy() if intent_type == 'charge':
temp_combat_state['npc_hp'] = current_npc_hp next_intent_str = 'charging_attack'
else:
next_intent = generate_npc_intent(npc_def, temp_combat_state) next_intent = generate_npc_intent(npc_def, temp_combat_state)
next_intent_str = f"{next_intent['type']}:{next_intent['value']}" if next_intent['type'] == 'skill' else next_intent['type']
# Update combat with new intent and turn
await db.update_combat(player_id, { await db.update_combat(player_id, {
'turn': 'player', 'turn': 'player',
'turn_started_at': time.time(), 'turn_started_at': time.time(),
'npc_intent': next_intent['type'] 'npc_intent': next_intent_str
}) })
return messages, player_defeated return messages, player_defeated

53
api/give_gear.py Normal file
View File

@@ -0,0 +1,53 @@
import asyncio
import uuid
import asyncpg
import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from core.config import settings
async def main():
conn = await asyncpg.connect(settings.DATABASE_URL)
# Get user
user = await conn.fetchrow("SELECT id FROM characters ORDER BY created_at DESC LIMIT 1 OFFSET 0")
if not user:
print("No user found")
return
c_id = user['id']
print(f"Adding items to character {c_id}")
# Items: Greatsword, Full Plate, Kite Shield
items = [
{"item_id": "iron_greatsword", "base_durability": 100},
{"item_id": "steel_plate", "base_durability": 200},
{"item_id": "iron_kite_shield", "base_durability": 120}
]
for item in items:
# Create unique item
unique_id = str(uuid.uuid4())
await conn.execute(
"""
INSERT INTO unique_items (id, item_id, durability, max_durability)
VALUES ($1, $2, $3, $3)
""",
unique_id, item['item_id'], item['base_durability']
)
# Add to inventory
await conn.execute(
"""
INSERT INTO inventory_items (character_id, item_id, quantity, is_equipped, unique_item_id)
VALUES ($1, $2, 1, false, $3)
""",
c_id, item['item_id'], unique_id
)
print(f"Added {item['item_id']} ({unique_id}) to inventory.")
await conn.close()
if __name__ == "__main__":
asyncio.run(main())

17
api/give_gear_final.sql Normal file
View File

@@ -0,0 +1,17 @@
DO $$
DECLARE
char_id INTEGER;
uid INTEGER;
BEGIN
SELECT id INTO char_id FROM characters ORDER BY created_at DESC LIMIT 1;
IF char_id IS NOT NULL THEN
INSERT INTO unique_items (item_id, durability, max_durability) VALUES ('iron_greatsword', 100, 100) RETURNING id INTO uid;
INSERT INTO inventory (character_id, item_id, quantity, is_equipped, unique_item_id) VALUES (char_id, 'iron_greatsword', 1, false, uid);
INSERT INTO unique_items (item_id, durability, max_durability) VALUES ('steel_plate', 200, 200) RETURNING id INTO uid;
INSERT INTO inventory (character_id, item_id, quantity, is_equipped, unique_item_id) VALUES (char_id, 'steel_plate', 1, false, uid);
INSERT INTO unique_items (item_id, durability, max_durability) VALUES ('iron_kite_shield', 120, 120) RETURNING id INTO uid;
INSERT INTO inventory (character_id, item_id, quantity, is_equipped, unique_item_id) VALUES (char_id, 'iron_kite_shield', 1, false, uid);
END IF;
END $$;

View File

@@ -31,6 +31,8 @@ class Item:
tier: int = 1 # Item tier (1-5) tier: int = 1 # Item tier (1-5)
encumbrance: int = 0 # Encumbrance penalty when equipped encumbrance: int = 0 # Encumbrance penalty when equipped
weapon_effects: Dict[str, Any] = None # Weapon effects: bleeding, stun, etc. weapon_effects: Dict[str, Any] = None # Weapon effects: bleeding, stun, etc.
weapon_type: str = None # e.g. 'two_handed', 'one_handed', 'dagger', 'bow'
equip_requirements: Dict[str, int] = None # e.g. {'level': 15, 'strength': 20}
# Repair system # Repair system
repairable: bool = False # Can this item be repaired? repairable: bool = False # Can this item be repaired?
repair_materials: list = None # Materials needed for repair repair_materials: list = None # Materials needed for repair
@@ -72,6 +74,8 @@ class Item:
self.uncraft_tools = [] self.uncraft_tools = []
if self.combat_effects is None: if self.combat_effects is None:
self.combat_effects = {} self.combat_effects = {}
if self.equip_requirements is None:
self.equip_requirements = {}
class ItemsManager: class ItemsManager:
@@ -139,7 +143,9 @@ class ItemsManager:
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_usable=item_data.get('combat_usable', is_consumable), # Default: consumables are combat usable
combat_only=item_data.get('combat_only', False), combat_only=item_data.get('combat_only', False),
combat_effects=item_data.get('combat_effects', {}) combat_effects=item_data.get('combat_effects', {}),
weapon_type=item_data.get('weapon_type'),
equip_requirements=item_data.get('equip_requirements', {})
) )
self.items[item_id] = item self.items[item_id] = item

View File

@@ -186,9 +186,9 @@ except Exception as e:
# Initialize routers with game data dependencies # Initialize routers with game data dependencies
game_routes.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager) game_routes.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
combat.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager, QUESTS_DATA) combat.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager, QUESTS_DATA)
equipment.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD) equipment.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD) crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD) loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
statistics.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD) statistics.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
admin.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR) admin.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR)
quests.init_router_dependencies(ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA, LOCATIONS) quests.init_router_dependencies(ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA, LOCATIONS)

File diff suppressed because it is too large Load Diff

View File

@@ -25,13 +25,15 @@ logger = logging.getLogger(__name__)
LOCATIONS = None LOCATIONS = None
ITEMS_MANAGER = None ITEMS_MANAGER = None
WORLD = None WORLD = None
redis_manager = None
def init_router_dependencies(locations, items_manager, world): def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
"""Initialize router with game data dependencies""" """Initialize router with game data dependencies"""
global LOCATIONS, ITEMS_MANAGER, WORLD global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
LOCATIONS = locations LOCATIONS = locations
ITEMS_MANAGER = items_manager ITEMS_MANAGER = items_manager
WORLD = world WORLD = world
redis_manager = redis_mgr
router = APIRouter(tags=["crafting"]) router = APIRouter(tags=["crafting"])
@@ -179,6 +181,11 @@ async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get
if not player: if not player:
raise HTTPException(status_code=404, detail="Player not found") raise HTTPException(status_code=404, detail="Player not found")
# Get derived stats for crafting bonus
from ..services.stats import calculate_derived_stats
stats = await calculate_derived_stats(player['id'], redis_manager)
crafting_bonus = stats.get('crafting_bonus', 0.0)
location_id = player['location_id'] location_id = player['location_id']
location = LOCATIONS.get(location_id) location = LOCATIONS.get(location_id)
@@ -287,11 +294,13 @@ async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get
if hasattr(item_def, 'durability') and item_def.durability: if hasattr(item_def, 'durability') and item_def.durability:
# This is a unique item - generate random stats # This is a unique item - generate random stats
base_durability = item_def.durability base_durability = item_def.durability
# Random durability: 90-110% of base
random_durability = int(base_durability * random.uniform(0.9, 1.1))
# Generate tier based on durability roll # Random durability: 90-110% of base, plus crafting_bonus (e.g. +0.05 from Intellect)
durability_percent = (random_durability / base_durability) base_roll = random.uniform(0.9, 1.1)
durability_percent = base_roll + crafting_bonus
random_durability = int(base_durability * durability_percent)
# Generate tier based on the final durability percentage
if durability_percent >= 1.08: if durability_percent >= 1.08:
tier = 5 # Gold tier = 5 # Gold
elif durability_percent >= 1.04: elif durability_percent >= 1.04:
@@ -308,8 +317,9 @@ async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get
if hasattr(item_def, 'stats') and item_def.stats: if hasattr(item_def, 'stats') and item_def.stats:
for stat_key, stat_value in item_def.stats.items(): for stat_key, stat_value in item_def.stats.items():
if isinstance(stat_value, (int, float)): if isinstance(stat_value, (int, float)):
# Random stat: 90-110% of base # Random stat: same multiplier logic applied to base stats
random_stats[stat_key] = int(stat_value * random.uniform(0.9, 1.1)) stat_percent = random.uniform(0.9, 1.1) + crafting_bonus
random_stats[stat_key] = int(stat_value * stat_percent)
else: else:
random_stats[stat_key] = stat_value random_stats[stat_key] = stat_value
@@ -501,9 +511,8 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
adjusted_quantity = int(round(base_quantity * durability_ratio)) adjusted_quantity = int(round(base_quantity * durability_ratio))
mat_def = ITEMS_MANAGER.items.get(material['item_id']) mat_def = ITEMS_MANAGER.items.get(material['item_id'])
mat_name = mat_def.name if mat_def else material['item_id']
loss_key = (material['item_id'], mat_name) loss_key = material['item_id']
# If durability is too low (< 10%), yield nothing for this material # If durability is too low (< 10%), yield nothing for this material
if durability_ratio < 0.1 or adjusted_quantity <= 0: if durability_ratio < 0.1 or adjusted_quantity <= 0:
@@ -527,7 +536,7 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
# But we need to check capacity. # But we need to check capacity.
# Let's accumulate pending yield. # Let's accumulate pending yield.
yield_key = (material['item_id'], mat_name, mat_def.emoji if mat_def else '📦', mat_def) yield_key = material['item_id']
if yield_key not in materials_yielded_dict: if yield_key not in materials_yielded_dict:
materials_yielded_dict[yield_key] = 0 materials_yielded_dict[yield_key] = 0
materials_yielded_dict[yield_key] += adjusted_quantity materials_yielded_dict[yield_key] += adjusted_quantity
@@ -538,18 +547,23 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
materials_dropped = [] materials_dropped = []
# Convert lost dict to list # Convert lost dict to list
for (item_id, name), qty in materials_lost_dict.items(): for item_id, qty in materials_lost_dict.items():
mat_def = ITEMS_MANAGER.items.get(item_id)
materials_lost.append({ materials_lost.append({
'item_id': item_id, 'item_id': item_id,
'name': name, 'name': mat_def.name if mat_def else item_id,
'quantity': qty, 'emoji': mat_def.emoji if mat_def else '📦',
'reason': 'lost_or_low_durability' 'quantity': qty
}) })
# Process yield # Process yield
for (item_id, name, emoji, mat_def), qty in materials_yielded_dict.items(): for item_id, qty in materials_yielded_dict.items():
mat_weight = getattr(mat_def, 'weight', 0) * qty mat_def = ITEMS_MANAGER.items.get(item_id)
mat_volume = getattr(mat_def, 'volume', 0) * qty mat_name = mat_def.name if mat_def else item_id
emoji = mat_def.emoji if mat_def else '📦'
mat_weight = getattr(mat_def, 'weight', 0) * qty if mat_def else 0
mat_volume = getattr(mat_def, 'volume', 0) * qty if mat_def else 0
# Simple check against capacity (assuming current_weight was just updated from DB) # Simple check against capacity (assuming current_weight was just updated from DB)
# Note: we might fill up mid-loop. ideally we add one by one or check total. # Note: we might fill up mid-loop. ideally we add one by one or check total.

View File

@@ -24,13 +24,15 @@ logger = logging.getLogger(__name__)
LOCATIONS = None LOCATIONS = None
ITEMS_MANAGER = None ITEMS_MANAGER = None
WORLD = None WORLD = None
redis_manager = None
def init_router_dependencies(locations, items_manager, world): def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
"""Initialize router with game data dependencies""" """Initialize router with game data dependencies"""
global LOCATIONS, ITEMS_MANAGER, WORLD global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
LOCATIONS = locations LOCATIONS = locations
ITEMS_MANAGER = items_manager ITEMS_MANAGER = items_manager
WORLD = world WORLD = world
redis_manager = redis_mgr
router = APIRouter(tags=["equipment"]) router = APIRouter(tags=["equipment"])
@@ -48,6 +50,14 @@ async def equip_item(
player_id = current_user['id'] player_id = current_user['id']
locale = request.headers.get('Accept-Language', 'en') locale = request.headers.get('Accept-Language', 'en')
# Check if in combat
in_combat = await db.get_active_combat(player_id)
if in_combat:
raise HTTPException(
status_code=400,
detail=get_game_message('cannot_equip_combat', locale)
)
# Get the inventory item # Get the inventory item
inv_item = await db.get_inventory_item_by_id(equip_req.inventory_id) inv_item = await db.get_inventory_item_by_id(equip_req.inventory_id)
if not inv_item or inv_item['character_id'] != player_id: if not inv_item or inv_item['character_id'] != player_id:
@@ -62,6 +72,25 @@ async def equip_item(
if not item_def.equippable or not item_def.slot: if not item_def.equippable or not item_def.slot:
raise HTTPException(status_code=400, detail="This item cannot be equipped") raise HTTPException(status_code=400, detail="This item cannot be equipped")
# Check equipment requirements (level + stat gates)
if item_def.equip_requirements:
player = await db.get_player_by_id(player_id) if 'level' not in current_user else current_user
req_level = item_def.equip_requirements.get('level', 0)
if player.get('level', 1) < req_level:
raise HTTPException(
status_code=400,
detail=get_game_message('equip_level_required', locale, level=req_level)
)
for stat_name in ['strength', 'agility', 'endurance', 'intellect']:
req_value = item_def.equip_requirements.get(stat_name, 0)
if req_value > 0 and player.get(stat_name, 0) < req_value:
raise HTTPException(
status_code=400,
detail=get_game_message('equip_stat_required', locale, stat=stat_name.capitalize(), value=req_value)
)
# Check if slot is valid # Check if slot is valid
valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack'] valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack']
if item_def.slot not in valid_slots: if item_def.slot not in valid_slots:
@@ -113,6 +142,10 @@ async def equip_item(
else: else:
message = get_game_message('equipped', locale, item=get_locale_string(item_def.name, locale)) message = get_game_message('equipped', locale, item=get_locale_string(item_def.name, locale))
# Invalidate cached derived stats (equipment changed)
from ..services.stats import invalidate_stats_cache
await invalidate_stats_cache(player_id, redis_manager)
return { return {
"success": True, "success": True,
"message": message, "message": message,
@@ -131,6 +164,14 @@ async def unequip_item(
player_id = current_user['id'] player_id = current_user['id']
locale = request.headers.get('Accept-Language', 'en') locale = request.headers.get('Accept-Language', 'en')
# Check if in combat
in_combat = await db.get_active_combat(player_id)
if in_combat:
raise HTTPException(
status_code=400,
detail=get_game_message('cannot_equip_combat', locale)
)
# Check if slot is valid # Check if slot is valid
valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack'] valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack']
if unequip_req.slot not in valid_slots: if unequip_req.slot not in valid_slots:
@@ -192,6 +233,10 @@ async def unequip_item(
await db.drop_item(player_id, inv_item['item_id'], 1, current_user['location_id']) await db.drop_item(player_id, inv_item['item_id'], 1, current_user['location_id'])
await db.remove_from_inventory(player_id, inv_item['item_id'], 1) await db.remove_from_inventory(player_id, inv_item['item_id'], 1)
# Invalidate cached derived stats
from ..services.stats import invalidate_stats_cache
await invalidate_stats_cache(player_id, redis_manager)
return { return {
"success": True, "success": True,
"message": get_game_message('unequip_dropped', locale, item=get_locale_string(item_def.name, locale)), "message": get_game_message('unequip_dropped', locale, item=get_locale_string(item_def.name, locale)),
@@ -202,6 +247,10 @@ async def unequip_item(
await db.unequip_item(player_id, unequip_req.slot) await db.unequip_item(player_id, unequip_req.slot)
await db.update_inventory_item(equipped['item_id'], is_equipped=False) await db.update_inventory_item(equipped['item_id'], is_equipped=False)
# Invalidate cached derived stats
from ..services.stats import invalidate_stats_cache
await invalidate_stats_cache(player_id, redis_manager)
return { return {
"success": True, "success": True,
"message": get_game_message('unequipped', locale, item=get_locale_string(item_def.name, locale)), "message": get_game_message('unequipped', locale, item=get_locale_string(item_def.name, locale)),
@@ -379,7 +428,7 @@ async def repair_item(
async def reduce_armor_durability(player_id: int, damage_taken: int) -> tuple: async def reduce_armor_durability(player_id: int, damage_taken: int, is_defending: bool = False) -> tuple:
""" """
Reduce durability of equipped armor pieces when taking damage. Reduce durability of equipped armor pieces when taking damage.
Formula: durability_loss = max(1, (damage_taken / armor_value) * base_reduction_rate) Formula: durability_loss = max(1, (damage_taken / armor_value) * base_reduction_rate)
@@ -419,7 +468,7 @@ async def reduce_armor_durability(player_id: int, damage_taken: int) -> tuple:
# Calculate durability loss for each armor piece # Calculate durability loss for each armor piece
# Balanced formula: armor should last many combats (10-20+ hits for low tier) # Balanced formula: armor should last many combats (10-20+ hits for low tier)
base_reduction_rate = 0.1 # Reduced from 0.5 to make armor more durable base_reduction_rate = 0.2 if is_defending else 0.1 # Reduced from 0.5 to make armor more durable
broken_armor = [] broken_armor = []
for armor in equipped_armor: for armor in equipped_armor:

View File

@@ -17,6 +17,7 @@ from .. import database as db
from ..items import ItemsManager from ..items import ItemsManager
from .. import game_logic from .. import game_logic
from ..core.websockets import manager from ..core.websockets import manager
from ..services.stats import STAT_CAP, invalidate_stats_cache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -227,7 +228,8 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
raise HTTPException(status_code=404, detail="Player not found") raise HTTPException(status_code=404, detail="Player not found")
# Get player status effects # Get player status effects
status_effects = await db.get_player_effects(player_id) from ..services.helpers import get_resolved_player_effects
status_effects = await get_resolved_player_effects(player_id)
player['status_effects'] = status_effects player['status_effects'] = status_effects
# Get location # Get location
@@ -374,13 +376,21 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
"tags": getattr(location, 'tags', []) "tags": getattr(location, 'tags', [])
} }
from ..services.stats import calculate_derived_stats
derived_stats = await calculate_derived_stats(player_id, redis_manager)
# Add weight/volume to player data # Add weight/volume to player data
player_with_capacity = dict(player) player_with_capacity = dict(player)
player_with_capacity['current_weight'] = round(total_weight, 2) player_with_capacity['current_weight'] = round(total_weight, 2)
player_with_capacity['max_weight'] = round(max_weight, 2)
player_with_capacity['current_volume'] = round(total_volume, 2) player_with_capacity['current_volume'] = round(total_volume, 2)
player_with_capacity['max_weight'] = round(derived_stats.get('carry_weight', max_weight), 2)
player_with_capacity['max_volume'] = round(max_volume, 2) player_with_capacity['max_volume'] = round(max_volume, 2)
player_with_capacity['max_hp'] = derived_stats.get('max_hp', player['max_hp'])
player_with_capacity['max_stamina'] = derived_stats.get('max_stamina', player['max_stamina'])
player_with_capacity['derived_stats'] = derived_stats
# Calculate movement cooldown # Calculate movement cooldown
import time import time
current_time = time.time() current_time = time.time()
@@ -411,20 +421,29 @@ async def get_player_profile(current_user: dict = Depends(get_current_user)):
raise HTTPException(status_code=404, detail="Player not found") raise HTTPException(status_code=404, detail="Player not found")
# Get player status effects # Get player status effects
status_effects = await db.get_player_effects(player_id) from ..services.helpers import get_resolved_player_effects
status_effects = await get_resolved_player_effects(player_id)
player['status_effects'] = status_effects player['status_effects'] = status_effects
# Get capacity metrics (weight/volume) using the helper function # Get capacity metrics (weight/volume) using the helper function
# We don't need the inventory array itself, just the capacity calculations # 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) _, total_weight, total_volume, max_weight, max_volume = await _get_enriched_inventory(player_id)
from ..services.stats import calculate_derived_stats
derived_stats = await calculate_derived_stats(player_id, redis_manager)
# Add weight/volume to player data # Add weight/volume to player data
player_with_capacity = dict(player) player_with_capacity = dict(player)
player_with_capacity['current_weight'] = round(total_weight, 2) player_with_capacity['current_weight'] = round(total_weight, 2)
player_with_capacity['max_weight'] = round(max_weight, 2)
player_with_capacity['current_volume'] = round(total_volume, 2) player_with_capacity['current_volume'] = round(total_volume, 2)
player_with_capacity['max_weight'] = round(derived_stats.get('carry_weight', max_weight), 2)
player_with_capacity['max_volume'] = round(max_volume, 2) player_with_capacity['max_volume'] = round(max_volume, 2)
player_with_capacity['max_hp'] = derived_stats.get('max_hp', player['max_hp'])
player_with_capacity['max_stamina'] = derived_stats.get('max_stamina', player['max_stamina'])
player_with_capacity['derived_stats'] = derived_stats
# Calculate movement cooldown # Calculate movement cooldown
import time import time
current_time = time.time() current_time = time.time()
@@ -457,19 +476,28 @@ async def spend_stat_point(
if stat not in valid_stats: if stat not in valid_stats:
raise HTTPException(status_code=400, detail=f"Invalid stat. Must be one of: {', '.join(valid_stats)}") raise HTTPException(status_code=400, detail=f"Invalid stat. Must be one of: {', '.join(valid_stats)}")
# Check stat cap
if player[stat] >= STAT_CAP:
raise HTTPException(status_code=400, detail=f"{stat.capitalize()} is already at maximum ({STAT_CAP})")
# Update the stat and decrease unspent points # Update the stat and decrease unspent points
update_data = { update_data = {
stat: player[stat] + 1, stat: player[stat] + 1,
'unspent_points': player['unspent_points'] - 1 'unspent_points': player['unspent_points'] - 1
} }
# Endurance increases max HP # Endurance increases max HP and max stamina
if stat == 'endurance': if stat == 'endurance':
update_data['max_hp'] = player['max_hp'] + 5 update_data['max_hp'] = player['max_hp'] + 5
update_data['hp'] = min(player['hp'] + 5, update_data['max_hp']) # Also heal by 5 update_data['hp'] = min(player['hp'] + 5, update_data['max_hp']) # Also heal by 5
update_data['max_stamina'] = player['max_stamina'] + 2
update_data['stamina'] = min(player['stamina'] + 2, update_data['max_stamina']) # Also restore by 2
await db.update_character(current_user['id'], **update_data) await db.update_character(current_user['id'], **update_data)
# Invalidate cached derived stats
await invalidate_stats_cache(current_user['id'], redis_manager)
return { return {
"success": True, "success": True,
"message": f"Increased {stat} by 1!", "message": f"Increased {stat} by 1!",
@@ -952,6 +980,7 @@ async def move(
await db.update_player_statistics(current_user['id'], combats_initiated=1, increment=True) await db.update_player_statistics(current_user['id'], combats_initiated=1, increment=True)
encounter_triggered = True encounter_triggered = True
from ..services.helpers import get_resolved_player_effects
combat_data = { combat_data = {
"npc_id": enemy_id, "npc_id": enemy_id,
"npc_name": npc_def.name, "npc_name": npc_def.name,
@@ -962,6 +991,7 @@ async def move(
"round": 1, "round": 1,
"npc_intent": initial_intent['type'] "npc_intent": initial_intent['type']
} }
player_effects = await get_resolved_player_effects(current_user['id'], in_combat=True)
response = { response = {
"success": True, "success": True,
@@ -976,7 +1006,8 @@ async def move(
"triggered": True, "triggered": True,
"enemy_id": enemy_id, "enemy_id": enemy_id,
"message": get_game_message('enemy_ambush', locale), "message": get_game_message('enemy_ambush', locale),
"combat": combat_data "combat": combat_data,
"player_effects": player_effects
} }
# Broadcast movement to WebSocket clients # Broadcast movement to WebSocket clients
@@ -1550,4 +1581,144 @@ async def drop_item(
return { return {
"success": True, "success": True,
"message": get_game_message('dropped_item_success', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=quantity) "message": get_game_message('dropped_item_success', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=quantity)
} }
@router.get("/api/game/character-sheet")
async def get_character_sheet(current_user: dict = Depends(get_current_user)):
"""Get the full character sheet with base stats, derived stats, skills, and perks."""
from ..services.stats import calculate_derived_stats
from ..services.skills import skills_manager, perks_manager, get_total_perk_points
player = current_user
character_id = player['id']
# Get derived stats
derived = await calculate_derived_stats(character_id, redis_manager)
# Get available skills
available_skills = skills_manager.get_available_skills(player)
# Get owned perks
owned_perks_rows = await db.get_character_perks(character_id)
owned_perk_ids = [row['perk_id'] for row in owned_perks_rows]
# Get all perks with availability
all_perks = perks_manager.get_available_perks(player, owned_perk_ids)
# Get active status effects
from ..services.helpers import get_resolved_player_effects
status_effects = await get_resolved_player_effects(character_id)
# Calculate perk points
total_perk_points = get_total_perk_points(player['level'])
used_perk_points = len(owned_perk_ids)
available_perk_points = total_perk_points - used_perk_points
return {
"base_stats": {
"strength": player['strength'],
"agility": player['agility'],
"endurance": player['endurance'],
"intellect": player['intellect'],
"unspent_points": player['unspent_points'],
"stat_cap": STAT_CAP,
},
"derived_stats": derived,
"skills": available_skills,
"perks": {
"available_points": available_perk_points,
"total_points": total_perk_points,
"used_points": used_perk_points,
"all_perks": all_perks,
},
"status_effects": status_effects,
"character": {
"name": player['name'],
"level": player['level'],
"xp": player['xp'],
"hp": player['hp'],
"max_hp": player['max_hp'],
"stamina": player['stamina'],
"max_stamina": player['max_stamina'],
"avatar_data": player.get('avatar_data'),
}
}
@router.post("/api/game/select_perk")
async def select_perk(
perk_id: str,
current_user: dict = Depends(get_current_user)
):
"""Select a perk for the character."""
from ..services.skills import perks_manager, get_total_perk_points
player = current_user
character_id = player['id']
# Check perk exists
perk = perks_manager.get_perk(perk_id)
if not perk:
raise HTTPException(status_code=404, detail="Perk not found")
# Check perk points available
owned_perks = await db.get_character_perks(character_id)
owned_perk_ids = [row['perk_id'] for row in owned_perks]
total_points = get_total_perk_points(player['level'])
used_points = len(owned_perk_ids)
if used_points >= total_points:
raise HTTPException(status_code=400, detail="No perk points available")
# Check if already owned
if perk_id in owned_perk_ids:
raise HTTPException(status_code=400, detail="Perk already selected")
# Check requirements
if not perks_manager.check_requirements(perk, player):
raise HTTPException(status_code=400, detail="Requirements not met for this perk")
# Add perk
success = await db.add_character_perk(character_id, perk_id)
if not success:
raise HTTPException(status_code=400, detail="Failed to select perk")
# Invalidate stats cache (perks affect derived stats)
await invalidate_stats_cache(character_id, redis_manager)
return {
"success": True,
"message": f"Perk '{perk_id}' selected!",
"perk": {
"id": perk.id,
"name": perk.name,
"description": perk.description,
"icon": perk.icon,
}
}
@router.get("/api/game/available-skills")
async def get_available_skills(current_user: dict = Depends(get_current_user)):
"""Get available skills for the combat UI abilities dropdown."""
from ..services.skills import skills_manager
from .. import database as db
player = current_user
all_skills = skills_manager.get_available_skills(player)
# Check cooldowns
effects = await db.get_player_effects(player['id'])
cooldowns = {eff['source']: eff['ticks_remaining'] for eff in effects if eff.get('effect_type') == 'cooldown'}
# Only return unlocked skills for the combat dropdown
unlocked = []
for s in all_skills:
if s['unlocked']:
cd_source = f"cd:{s['id']}"
s['current_cooldown'] = cooldowns.get(cd_source, 0)
unlocked.append(s)
return {"skills": unlocked}

View File

@@ -25,13 +25,15 @@ logger = logging.getLogger(__name__)
LOCATIONS = None LOCATIONS = None
ITEMS_MANAGER = None ITEMS_MANAGER = None
WORLD = None WORLD = None
redis_manager = None
def init_router_dependencies(locations, items_manager, world): def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
"""Initialize router with game data dependencies""" """Initialize router with game data dependencies"""
global LOCATIONS, ITEMS_MANAGER, WORLD global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
LOCATIONS = locations LOCATIONS = locations
ITEMS_MANAGER = items_manager ITEMS_MANAGER = items_manager
WORLD = world WORLD = world
redis_manager = redis_mgr
router = APIRouter(tags=["loot"]) router = APIRouter(tags=["loot"])
@@ -195,9 +197,13 @@ async def loot_corpse(
# Parse corpse ID # Parse corpse ID
corpse_type, corpse_db_id = req.corpse_id.split('_', 1) corpse_type, corpse_db_id = req.corpse_id.split('_', 1)
corpse_db_id = int(corpse_db_id) corpse_db_id = int(corpse_db_id)
player = current_user # current_user is already the character dict player = current_user # current_user is already the character dict
# Get player derived stats for loot quality
from ..services.stats import calculate_derived_stats
stats = await calculate_derived_stats(player['id'], redis_manager)
loot_quality = stats.get('loot_quality', 1.0)
# Get player's current capacity # Get player's current capacity
inventory = await db.get_inventory(player['id']) inventory = await db.get_inventory(player['id'])
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER) current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
@@ -246,7 +252,6 @@ async def loot_corpse(
success, error_msg, tools_consumed = await consume_tool_durability(player['id'], tool_req, inventory) success, error_msg, tools_consumed = await consume_tool_durability(player['id'], tool_req, inventory)
if not success: if not success:
raise HTTPException(status_code=400, detail=error_msg) raise HTTPException(status_code=400, detail=error_msg)
# Determine quantity # Determine quantity
quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max']) quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max'])
@@ -254,6 +259,13 @@ async def loot_corpse(
# Check if item fits in inventory # Check if item fits in inventory
item_def = ITEMS_MANAGER.get_item(loot_item['item_id']) item_def = ITEMS_MANAGER.get_item(loot_item['item_id'])
if item_def: if item_def:
# Apply loot quality bonus for resources and consumables
if getattr(item_def, 'category', item_def.type) in ['resource', 'consumable'] and loot_quality > 1.0:
# e.g., loot_quality 1.15 = 15% chance for +1 extra
bonus_chance = loot_quality - 1.0
if random.random() < bonus_chance:
quantity += 1
item_weight = item_def.weight * quantity item_weight = item_def.weight * quantity
item_volume = item_def.volume * quantity item_volume = item_def.volume * quantity
@@ -305,11 +317,16 @@ async def loot_corpse(
if can_loot: if can_loot:
# Can loot this item # Can loot this item
quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max']) quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max'])
if quantity > 0: if quantity > 0:
# Check if item fits in inventory # Check if item fits in inventory
item_def = ITEMS_MANAGER.get_item(loot_item['item_id']) item_def = ITEMS_MANAGER.get_item(loot_item['item_id'])
if item_def: if item_def:
# Apply loot quality bonus for resources and consumables
if getattr(item_def, 'category', item_def.type) in ['resource', 'consumable'] and loot_quality > 1.0:
bonus_chance = loot_quality - 1.0
if random.random() < bonus_chance:
quantity += 1
item_weight = item_def.weight * quantity item_weight = item_def.weight * quantity
item_volume = item_def.volume * quantity item_volume = item_def.volume * quantity

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,27 @@ Helper utilities for game calculations and common operations.
Contains distance calculations, stamina costs, capacity calculations, etc. Contains distance calculations, stamina costs, capacity calculations, etc.
""" """
import math import math
from typing import Tuple, List, Dict, Any, Union import random
from typing import Tuple, List, Dict, Any, Union, Optional
from .. import database as db from .. import database as db
from ..items import ItemsManager from ..items import ItemsManager
def calculate_dynamic_status_damage(effects: dict, prefix: str, target: dict) -> Optional[int]:
"""Helper to calculate status damage based on percentage over max HP."""
if f'{prefix}_percent' in effects:
target_max_hp = target.get('max_hp') or target.get('npc_max_hp', 100)
pct = effects[f'{prefix}_percent']
base_dmg = target_max_hp * pct
# +/- 20% deviation
min_dmg = max(1, int(base_dmg * 0.8))
max_dmg = max(1, int(base_dmg * 1.2))
return random.randint(min_dmg, max_dmg)
elif f'{prefix}_damage' in effects:
return effects[f'{prefix}_damage']
return None
def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> str: def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> str:
"""Helper to safely get string from i18n object or string.""" """Helper to safely get string from i18n object or string."""
if isinstance(value, dict): if isinstance(value, dict):
@@ -54,6 +70,8 @@ GAME_MESSAGES = {
'pvp_combat_started_broadcast': {'en': "⚔️ PvP combat started between {attacker} and {defender}!", 'es': "⚔️ ¡Combate PvP iniciado entre {attacker} y {defender}!"}, 'pvp_combat_started_broadcast': {'en': "⚔️ PvP combat started between {attacker} and {defender}!", 'es': "⚔️ ¡Combate PvP iniciado entre {attacker} y {defender}!"},
'flee_success_text': {'en': "{name} fled from combat!", 'es': "¡{name} huyó del combate!"}, 'flee_success_text': {'en': "{name} fled from combat!", 'es': "¡{name} huyó del combate!"},
'flee_fail_text': {'en': "{name} tried to flee but failed!", 'es': "¡{name} intentó huir pero falló!"}, 'flee_fail_text': {'en': "{name} tried to flee but failed!", 'es': "¡{name} intentó huir pero falló!"},
'stunned_status': {'en': "💫 Stunned!", 'es': "💫 ¡Aturdido!"},
'npc_stunned_cannot_act': {'en': "💫 {npc_name} is stunned and cannot act!", 'es': "💫 ¡{npc_name} está aturdido y no puede actuar!"},
# Loot # Loot
'corpse_name_npc': {'en': "{name} Corpse", 'es': "Cadáver de {name}"}, 'corpse_name_npc': {'en': "{name} Corpse", 'es': "Cadáver de {name}"},
@@ -72,6 +90,8 @@ GAME_MESSAGES = {
'unequipped': {'en': "Unequipped {item}", 'es': "Desequipado {item}"}, 'unequipped': {'en': "Unequipped {item}", 'es': "Desequipado {item}"},
'unequip_dropped': {'en': "Unequipped {item} (dropped to ground - inventory full)", 'es': "Desequipado {item} (tirado al suelo - inventario lleno)"}, 'unequip_dropped': {'en': "Unequipped {item} (dropped to ground - inventory full)", 'es': "Desequipado {item} (tirado al suelo - inventario lleno)"},
'repaired_success': {'en': "Repaired {item}! Restored {amount} durability.", 'es': "¡Reparado {item}! Restaurados {amount} puntos de durabilidad."}, 'repaired_success': {'en': "Repaired {item}! Restored {amount} durability.", 'es': "¡Reparado {item}! Restaurados {amount} puntos de durabilidad."},
'equip_level_required': {'en': "Requires level {level} to equip", 'es': "Requiere nivel {level} para equipar"},
'equip_stat_required': {'en': "Requires {stat} {value} to equip", 'es': "Requiere {stat} {value} para equipar"},
# Characters/Auth # Characters/Auth
'character_created': {'en': "Character created successfully", 'es': "Personaje creado con éxito"}, 'character_created': {'en': "Character created successfully", 'es': "Personaje creado con éxito"},
@@ -99,12 +119,16 @@ GAME_MESSAGES = {
'item_used': {'en': "Used {name}", 'es': "Usado {name}"}, 'item_used': {'en': "Used {name}", 'es': "Usado {name}"},
'no_item': {'en': "You don't have this item", 'es': "No tienes este objeto"}, '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"},
'cannot_equip_combat': {'en': "Cannot change equipment during combat", 'es': "No puedes cambiar de equipamiento durante el combate"},
'cured': {'en': "Cured", 'es': "Curado"}, 'cured': {'en': "Cured", 'es': "Curado"},
# Status Effects # Status Effects
'statusDamage': {'en': "You took {damage} damage from status effects", 'es': "Has recibido {damage} de daño por efectos de estado"}, '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"}, '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"}, 'diedFromStatus': {'en': "You died from status effects", 'es': "Has muerto por efectos de estado"},
# Combat Warnings
'enemy_charging': {'en': "⚠️ {enemy} is gathering strength for a massive attack!", 'es': "⚠️ ¡{enemy} está reuniendo fuerzas para un ataque masivo!"},
} }
def get_game_message(key: str, lang: str = 'en', **kwargs) -> str: def get_game_message(key: str, lang: str = 'en', **kwargs) -> str:
@@ -138,6 +162,36 @@ def translate_travel_message(direction: str, location_name: str, lang: str = 'en
import json import json
from typing import List, Dict, Any, Tuple
from .. import database as db
async def get_resolved_player_effects(player_id: int, in_combat: bool = False) -> List[Dict]:
"""Helper to fetch and format active player effects for combat payloads."""
from ..services.skills import skills_manager
from ..services.status_effects import status_effects_manager
player_effects = []
all_effects = await db.get_player_effects(player_id)
for eff in all_effects:
if eff.get('effect_type') == 'cooldown':
continue
resolved = status_effects_manager.resolve_player_effect(
eff.get('effect_name', ''),
eff.get('effect_icon', ''),
eff.get('source', ''),
skills_manager,
in_combat=in_combat
)
player_effects.append({
'name': resolved['name'],
'effect_name': eff.get('effect_name', ''), # Needed for frontend state tracking
'icon': resolved['icon'],
'ticks_remaining': eff.get('ticks_remaining', 0),
'damage_per_tick': eff.get('damage_per_tick', 0), # Needed for logic
'type': eff.get('effect_type', 'buff'),
'description': resolved['description'],
})
return player_effects
def create_combat_message(message_type: str, origin: str = "neutral", **data) -> dict: def create_combat_message(message_type: str, origin: str = "neutral", **data) -> dict:
"""Create a structured combat message object. """Create a structured combat message object.
@@ -272,7 +326,7 @@ async def calculate_player_capacity(inventory: List[Dict[str, Any]], items_manag
return current_weight, max_weight, current_volume, max_volume return current_weight, max_weight, current_volume, max_volume
async def reduce_armor_durability(player_id: int, damage_taken: int, items_manager: ItemsManager) -> Tuple[int, List[Dict[str, Any]]]: async def reduce_armor_durability(player_id: int, damage_taken: int, items_manager: ItemsManager, is_defending: bool = False) -> Tuple[int, List[Dict[str, Any]]]:
""" """
Reduce durability of equipped armor pieces when taking damage. Reduce durability of equipped armor pieces when taking damage.
Returns: (armor_damage_absorbed, broken_armor_pieces) Returns: (armor_damage_absorbed, broken_armor_pieces)
@@ -309,7 +363,7 @@ async def reduce_armor_durability(player_id: int, damage_taken: int, items_manag
armor_absorbed = min(damage_taken // 2, total_armor) armor_absorbed = min(damage_taken // 2, total_armor)
# Calculate durability loss for each armor piece # Calculate durability loss for each armor piece
base_reduction_rate = 0.1 base_reduction_rate = 0.2 if is_defending else 0.1
broken_armor = [] broken_armor = []
for armor in equipped_armor: for armor in equipped_armor:

View File

@@ -79,8 +79,9 @@ class InitiateCombatRequest(BaseModel):
class CombatActionRequest(BaseModel): class CombatActionRequest(BaseModel):
action: str # 'attack', 'defend', 'flee', 'use_item' action: str # 'attack', 'skill', 'flee', 'use_item'
item_id: Optional[str] = None # For use_item action item_id: Optional[str] = None # For use_item action
skill_id: Optional[str] = None # For skill action
class PvPCombatInitiateRequest(BaseModel): class PvPCombatInitiateRequest(BaseModel):
@@ -88,12 +89,13 @@ class PvPCombatInitiateRequest(BaseModel):
class PvPAcknowledgeRequest(BaseModel): class PvPAcknowledgeRequest(BaseModel):
pass # No body needed combat_id: int
class PvPCombatActionRequest(BaseModel): class PvPCombatActionRequest(BaseModel):
action: str # 'attack', 'defend', 'flee', 'use_item' action: str # 'attack', 'skill', 'flee', 'use_item'
item_id: Optional[str] = None # For use_item action item_id: Optional[str] = None # For use_item action
skill_id: Optional[str] = None # For skill action
# ============================================================================ # ============================================================================

182
api/services/skills.py Normal file
View File

@@ -0,0 +1,182 @@
"""
Skills service - loads skill definitions and provides skill availability logic.
"""
import json
from typing import Dict, Any, List, Optional
from pathlib import Path
class Skill:
"""Represents a combat skill."""
def __init__(self, skill_id: str, data: Dict[str, Any]):
self.id = skill_id
self.name = data.get('name', skill_id)
self.description = data.get('description', '')
self.icon = data.get('icon', '⚔️')
self.stat_requirement = data.get('stat_requirement', 'strength')
self.stat_threshold = data.get('stat_threshold', 0)
self.level_requirement = data.get('level_requirement', 1)
self.cooldown = data.get('cooldown', 3)
self.stamina_cost = data.get('stamina_cost', 5)
self.effects = data.get('effects', {})
class SkillsManager:
"""Loads and manages skill definitions from JSON."""
def __init__(self, gamedata_path: str = "./gamedata"):
self.gamedata_path = Path(gamedata_path)
self.skills: Dict[str, Skill] = {}
self.load_skills()
def load_skills(self):
"""Load skills from skills.json."""
json_path = self.gamedata_path / 'skills.json'
try:
with open(json_path, 'r') as f:
data = json.load(f)
for skill_id, skill_data in data.get('skills', {}).items():
self.skills[skill_id] = Skill(skill_id, skill_data)
print(f"⚔️ Loaded {len(self.skills)} skills")
except FileNotFoundError:
print("⚠️ skills.json not found")
except Exception as e:
print(f"⚠️ Error loading skills.json: {e}")
def get_skill(self, skill_id: str) -> Optional[Skill]:
"""Get a skill by ID."""
return self.skills.get(skill_id)
def get_all_skills(self) -> Dict[str, Skill]:
"""Get all skills."""
return self.skills
def get_available_skills(self, character: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
Get all skills available to a character based on their stats and level.
Returns list of skill dicts with availability info.
"""
available = []
for skill_id, skill in self.skills.items():
# Skip NPC-only skills (assumed to be those with 0 stat threshold and level 1 requirement)
if (skill.stat_threshold <= 0 and skill.level_requirement <= 1) or getattr(skill, 'npc_only', False):
continue
stat_value = character.get(skill.stat_requirement, 0)
level = character.get('level', 1)
unlocked = (stat_value >= skill.stat_threshold and level >= skill.level_requirement)
skill_info = {
"id": skill.id,
"name": skill.name,
"description": skill.description,
"icon": skill.icon,
"stat_requirement": skill.stat_requirement,
"stat_threshold": skill.stat_threshold,
"level_requirement": skill.level_requirement,
"cooldown": skill.cooldown,
"stamina_cost": skill.stamina_cost,
"unlocked": unlocked,
"effects": skill.effects,
}
available.append(skill_info)
return available
class Perk:
"""Represents a passive perk."""
def __init__(self, perk_id: str, data: Dict[str, Any]):
self.id = perk_id
self.name = data.get('name', perk_id)
self.description = data.get('description', '')
self.icon = data.get('icon', '')
self.requirements = data.get('requirements', {})
self.effects = data.get('effects', {})
class PerksManager:
"""Loads and manages perk definitions from JSON."""
def __init__(self, gamedata_path: str = "./gamedata"):
self.gamedata_path = Path(gamedata_path)
self.perks: Dict[str, Perk] = {}
self.load_perks()
def load_perks(self):
"""Load perks from perks.json."""
json_path = self.gamedata_path / 'perks.json'
try:
with open(json_path, 'r') as f:
data = json.load(f)
for perk_id, perk_data in data.get('perks', {}).items():
self.perks[perk_id] = Perk(perk_id, perk_data)
print(f"⭐ Loaded {len(self.perks)} perks")
except FileNotFoundError:
print("⚠️ perks.json not found")
except Exception as e:
print(f"⚠️ Error loading perks.json: {e}")
def get_perk(self, perk_id: str) -> Optional[Perk]:
"""Get a perk by ID."""
return self.perks.get(perk_id)
def get_all_perks(self) -> Dict[str, Perk]:
"""Get all perks."""
return self.perks
def check_requirements(self, perk: Perk, character: Dict[str, Any]) -> bool:
"""Check if a character meets a perk's requirements."""
for req_key, req_value in perk.requirements.items():
if req_key.endswith('_max'):
# Max constraint (e.g., endurance_max: 8 means END must be ≤ 8)
stat_name = req_key.replace('_max', '')
if character.get(stat_name, 0) > req_value:
return False
else:
# Min constraint
if character.get(req_key, 0) < req_value:
return False
return True
def get_available_perks(self, character: Dict[str, Any], owned_perk_ids: List[str]) -> List[Dict[str, Any]]:
"""
Get all perks with availability status for a character.
"""
available = []
for perk_id, perk in self.perks.items():
meets_requirements = self.check_requirements(perk, character)
owned = perk_id in owned_perk_ids
perk_info = {
"id": perk.id,
"name": perk.name,
"description": perk.description,
"icon": perk.icon,
"requirements": perk.requirements,
"effects": perk.effects,
"meets_requirements": meets_requirements,
"owned": owned,
}
available.append(perk_info)
return available
# Perk points per level
PERK_POINT_INTERVAL = 5 # Every 5 levels
def get_total_perk_points(level: int) -> int:
"""Calculate total perk points available for a given level."""
return level // PERK_POINT_INTERVAL
# Global instances
skills_manager = SkillsManager()
perks_manager = PerksManager()

278
api/services/stats.py Normal file
View File

@@ -0,0 +1,278 @@
"""
Central stat calculation service.
All derived stats are computed here from base attributes + equipment + buffs.
Results are cached in Redis and invalidated on any change.
"""
import json
from typing import Dict, Any, Optional, List
from .. import database as db
from ..items import items_manager as ITEMS_MANAGER
# ─── Game Config (raise per expansion) ───
STAT_CAP = 50 # Max base stat points per attribute
MAX_LEVEL = 60 # Max character level
POINTS_PER_LEVEL = 1 # Stat points granted per level
async def calculate_derived_stats(character_id: int, redis_mgr=None) -> Dict[str, Any]:
"""
Calculate all derived stats for a character.
Checks Redis cache first; if miss, computes from DB and caches result.
Returns dict with all derived stat values.
"""
# 1. Check Redis cache
if redis_mgr and redis_mgr.redis_client:
try:
cached = await redis_mgr.redis_client.get(f"stats:{character_id}")
if cached:
return json.loads(cached)
except Exception:
pass # Graceful degradation — recalculate if Redis fails
# 2. Fetch data from DB
char = await db.get_player_by_id(character_id)
if not char:
return _empty_stats()
raw_equipment = await db.get_all_equipment(character_id)
enriched_equipment = {}
for slot, item_data in raw_equipment.items():
if not item_data or not item_data.get('item_id'):
continue
inv_item = await db.get_inventory_item_by_id(item_data['item_id'])
if not inv_item:
continue
enriched_item = {
'item_id': inv_item['item_id'], # String ID
'inventory_id': item_data['item_id']
}
unique_item_id = inv_item.get('unique_item_id')
if unique_item_id:
unique_item = await db.get_unique_item(unique_item_id)
if unique_item and unique_item.get('unique_stats'):
enriched_item['unique_stats'] = unique_item['unique_stats']
enriched_equipment[slot] = enriched_item
effects = await db.get_player_effects(character_id)
# 3. Fetch owned perks
owned_perks = await db.get_character_perks(character_id)
owned_perk_ids = [row['perk_id'] for row in owned_perks]
# 4. Compute derived stats
stats = _compute_stats(char, enriched_equipment, effects, owned_perk_ids)
# 5. Cache in Redis (5 min TTL)
if redis_mgr and redis_mgr.redis_client:
try:
await redis_mgr.redis_client.setex(
f"stats:{character_id}", 300, json.dumps(stats)
)
except Exception:
pass
return stats
def _compute_stats(char: Dict[str, Any], equipment: Dict[str, Any], effects: List[Dict], perk_ids: List[str] = None) -> Dict[str, Any]:
"""Pure computation of derived stats from base data."""
strength = char.get('strength', 0)
agility = char.get('agility', 0)
endurance = char.get('endurance', 0)
intellect = char.get('intellect', 0)
level = char.get('level', 1)
if perk_ids is None:
perk_ids = []
# ─── Base derived stats from attributes ───
attack_power = 5 + int(strength * 1.5) + level
crit_chance = 0.05 + (agility * 0.005)
crit_damage = 1.5 + (strength * 0.01)
dodge_chance = min(0.25, 0.02 + (agility * 0.005)) # Cap 25%
flee_chance_base = 0.4 + (agility * 0.01)
max_hp = 30 + (endurance * 5) + (level * 3)
max_stamina = 20 + (endurance * 2) + level
status_resistance = endurance * 0.01
block_chance = 0.0
item_effectiveness = 1.0 + (intellect * 0.02)
xp_bonus = 1.0 + (intellect * 0.01)
loot_quality = 1.0 + (intellect * 0.005)
crafting_bonus = intellect * 0.01
carry_weight = 10.0 + (strength * 0.5)
# ─── Equipment bonuses ───
total_armor = 0
weapon_crit = 0.0
weapon_damage_min = 0
weapon_damage_max = 0
has_shield = False
for slot, item_data in equipment.items():
if not item_data or not item_data.get('item_id'):
continue
item_id_str = item_data.get('item_id', '')
item_def = ITEMS_MANAGER.get_item(item_id_str)
if not item_def:
continue
# Merge base stats and unique stats
merged_stats = {}
if item_def.stats:
merged_stats.update(item_def.stats)
if item_data.get('unique_stats'):
merged_stats.update(item_data['unique_stats'])
if merged_stats:
total_armor += merged_stats.get('armor', 0)
weapon_crit += merged_stats.get('crit_chance', 0)
max_hp += merged_stats.get('max_hp', 0)
max_stamina += merged_stats.get('max_stamina', 0)
carry_weight += merged_stats.get('weight_capacity', 0)
if slot == 'weapon':
weapon_damage_min = merged_stats.get('damage_min', 0)
weapon_damage_max = merged_stats.get('damage_max', 0)
if slot == 'offhand':
has_shield = True
# Apply equipment to derived stats
crit_chance += weapon_crit
armor_reduction = total_armor / (total_armor + 50) if total_armor > 0 else 0.0
if has_shield:
block_chance = strength * 0.003
# ─── Buff effects ───
for effect in effects:
effect_name = effect.get('effect_name', '')
value = effect.get('value', 0)
# Future: apply buff modifiers here
# ─── Perk passive bonuses ───
if 'thick_skin' in perk_ids:
max_hp = int(max_hp * 1.10) # +10% max HP
if 'lucky_strike' in perk_ids:
crit_chance += 0.05 # +5% crit chance
if 'quick_learner' in perk_ids:
xp_bonus *= 1.15 # +15% XP
if 'glass_cannon' in perk_ids:
attack_power = int(attack_power * 1.30) # +30% attack
max_hp = int(max_hp * 0.80) # -20% HP
if 'survivor' in perk_ids:
max_hp = int(max_hp * 1.02) # Small HP boost from regen perk
if 'scavenger' in perk_ids:
loot_quality *= 1.10 # +10% loot quality
if 'fleet_footed' in perk_ids:
# Travel stamina reduction tracked for movement system
pass
stats = {
# Core combat
"attack_power": attack_power,
"crit_chance": round(crit_chance, 4),
"crit_damage": round(crit_damage, 2),
"dodge_chance": round(dodge_chance, 4),
"flee_chance_base": round(flee_chance_base, 2),
# Vitals
"max_hp": max_hp,
"max_stamina": max_stamina,
# Defense
"total_armor": total_armor,
"armor_reduction": round(armor_reduction, 4),
"block_chance": round(block_chance, 4),
"status_resistance": round(status_resistance, 4),
# Utility
"item_effectiveness": round(item_effectiveness, 2),
"xp_bonus": round(xp_bonus, 2),
"loot_quality": round(loot_quality, 3),
"crafting_bonus": round(crafting_bonus, 2),
"carry_weight": round(carry_weight, 1),
# Weapon info
"weapon_damage_min": weapon_damage_min,
"weapon_damage_max": weapon_damage_max,
"has_shield": has_shield,
# Perk flags
"has_last_stand": 'last_stand' in perk_ids,
"has_resilient": 'resilient' in perk_ids,
"has_iron_fist": 'iron_fist' in perk_ids,
"has_heavy_hitter": 'heavy_hitter' in perk_ids,
}
return stats
def _empty_stats() -> Dict[str, Any]:
"""Default stats for error cases."""
return {
"attack_power": 5,
"crit_chance": 0.05,
"crit_damage": 1.5,
"dodge_chance": 0.02,
"flee_chance_base": 0.4,
"max_hp": 30,
"max_stamina": 20,
"total_armor": 0,
"armor_reduction": 0.0,
"block_chance": 0.0,
"status_resistance": 0.0,
"item_effectiveness": 1.0,
"xp_bonus": 1.0,
"loot_quality": 1.0,
"crafting_bonus": 0.0,
"carry_weight": 10.0,
"weapon_damage_min": 0,
"weapon_damage_max": 0,
"has_shield": False,
}
async def invalidate_stats_cache(character_id: int, redis_mgr=None):
"""
Delete cached stats for a character. Call this whenever:
- Equipment changes (equip/unequip/break)
- Stat points allocated
- Level up
- Buff applied/expired
"""
if redis_mgr and redis_mgr.redis_client:
try:
await redis_mgr.redis_client.delete(f"stats:{character_id}")
except Exception:
pass
# Sync derived max_hp and max_stamina to the database characters table
try:
derived = await calculate_derived_stats(character_id, redis_mgr)
char = await db.get_player_by_id(character_id)
if char:
new_max_hp = derived.get('max_hp', char['max_hp'])
new_max_stamina = derived.get('max_stamina', char['max_stamina'])
if new_max_hp != char['max_hp'] or new_max_stamina != char['max_stamina']:
new_hp = min(char['hp'], new_max_hp)
new_stamina = min(char['stamina'], new_max_stamina)
await db.update_player(
character_id,
max_hp=new_max_hp,
max_stamina=new_max_stamina,
hp=new_hp,
stamina=new_stamina
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Failed to sync derived stats to DB for {character_id}: {e}")
def get_flee_chance(flee_chance_base: float, enemy_level: int) -> float:
"""Calculate actual flee chance against a specific enemy."""
return max(0.1, min(0.9, flee_chance_base - (enemy_level * 0.02)))

View File

@@ -0,0 +1,101 @@
"""
Status Effects Manager.
Loads status effect definitions from gamedata/status_effects.json.
"""
import json
import os
from typing import Dict, Any, Optional
class StatusEffect:
"""Represents a status effect definition."""
def __init__(self, effect_id: str, data: Dict[str, Any]):
self.id = effect_id
self.icon = data.get('icon', '')
self.name = data.get('name', effect_id.capitalize())
self.description = data.get('description', effect_id.capitalize())
self.type = data.get('type', 'debuff')
class StatusEffectsManager:
"""Manages status effect definitions loaded from JSON."""
def __init__(self, gamedata_path: str = "./gamedata"):
self.effects: Dict[str, StatusEffect] = {}
filepath = os.path.join(gamedata_path, 'status_effects.json')
self._load(filepath)
def _load(self, filepath: str):
"""Load status effects from a JSON file."""
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
for effect_id, effect_data in data.get('effects', {}).items():
self.effects[effect_id] = StatusEffect(effect_id, effect_data)
print(f"✨ Loaded {len(self.effects)} status effects")
except FileNotFoundError:
print("⚠️ status_effects.json not found")
except Exception as e:
print(f"⚠️ Error loading status_effects.json: {e}")
def get_effect(self, effect_id: str) -> Optional[StatusEffect]:
"""Get a status effect by its ID."""
return self.effects.get(effect_id)
def get_effect_info(self, effect_id: str) -> Dict[str, Any]:
"""Get effect info dict for API responses. Returns a fallback if not found."""
effect = self.effects.get(effect_id)
if effect:
return {
'name': effect.name,
'icon': effect.icon,
'description': effect.description,
'type': effect.type,
}
# Fallback for unknown effects
return {
'name': {'en': effect_id.capitalize(), 'es': effect_id.capitalize()},
'icon': '',
'description': {'en': effect_id.capitalize(), 'es': effect_id.capitalize()},
'type': 'debuff',
}
def resolve_player_effect(self, effect_name: str, effect_icon: str, source: str, skills_manager=None, in_combat: bool = True) -> Dict[str, Any]:
"""
Resolve translated name and description for a player effect.
Tries skill source first, then status_effects.json, then fallback.
"""
translated_name = effect_name
translated_desc = ''
# 1. Try to get from skill source (e.g., "skill:fortify")
if source.startswith('skill:') and skills_manager:
skill_id = source.split(':', 1)[1]
skill_def = skills_manager.get_skill(skill_id)
if skill_def:
translated_name = skill_def.name
translated_desc = skill_def.description
# 2. Try to get from status_effects.json by lowercased effect name
if not translated_desc:
effect_key = effect_name.lower()
effect = self.effects.get(effect_key)
if effect:
translated_name = effect.name
translated_desc = effect.description
# 3. Fallback: wrap the raw name as a translatable dict
if not translated_desc:
translated_desc = {'en': effect_name, 'es': effect_name}
if isinstance(translated_name, str):
translated_name = {'en': translated_name, 'es': translated_name}
return {
'name': translated_name,
'icon': effect_icon or '',
'description': translated_desc,
}
# Module-level singleton
status_effects_manager = StatusEffectsManager()

77
api/setup_test_env.py Normal file
View File

@@ -0,0 +1,77 @@
import asyncio
import json
import time
import os
from database import Database
async def main():
# 1. Update npcs.json to add a test boss
with open('../gamedata/npcs.json', 'r') as f:
data = json.load(f)
if 'test_boss' not in data['npcs']:
data['npcs']['test_boss'] = {
"name": {"en": "Level 50 Test Boss", "es": "Jefe de Prueba Nivel 50"},
"description": {"en": "A huge terrifying monster.", "es": "Un monstruo enorme y aterrador."},
"emoji": "👹",
"hp_min": 1000,
"hp_max": 1500,
"damage_min": 25,
"damage_max": 45,
"defense": 15,
"xp_reward": 500,
"loot_table": [],
"flee_chance": 0.0,
"status_inflict_chance": 0.5,
"death_message": {"en": "The boss is defeated.", "es": "El jefe ha sido derrotado."}
}
with open('../gamedata/npcs.json', 'w') as f:
json.dump(data, f, indent=2)
print("Added 'test_boss' to npcs.json")
db = Database()
await db.connect()
# 2. Get Jocaru
player = await db.fetch_one("SELECT * FROM characters WHERE name ILIKE 'Jocaru'")
if not player:
print("Player Jocaru not found!")
return
pid = player['id']
ploc = player['location_id']
# 3. Give items
items_to_give = [
('reinforced_pack', 1),
('reinforced_bat', 1),
('knife', 1),
('first_aid_kit', 10),
('mystery_pills', 5),
('energy_bar', 10),
('molotov', 5)
]
for item_id, qty in items_to_give:
await db.add_item_to_inventory(pid, item_id, qty)
print("Granted test items and backpack.")
# 4. Give XP to reach lvl 50 if needed
# Level 50 is base + (50 * multiplier) ... the logic is in check_and_apply_level_up
await db.execute("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid", {"pid": pid})
print("Buffed Jocaru to lvl 50 manually.")
# 5. Spawn enemies at player's location
now = time.time()
despawn = now + 86400 # 1 day
enemies_to_spawn = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2
for eid in enemies_to_spawn:
await db.execute(
"INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)",
{"nid": eid, "loc": ploc, "start": now, "end": despawn}
)
print(f"Spawned {len(enemies_to_spawn)} enemies at {ploc}")
await db.disconnect()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -897,7 +897,7 @@
"repair_percentage": 25, "repair_percentage": 25,
"stats": { "stats": {
"armor": 3, "armor": 3,
"hp_bonus": 10 "max_hp": 10
}, },
"emoji": "🦺", "emoji": "🦺",
"image_path": "images/items/leather_vest.webp", "image_path": "images/items/leather_vest.webp",
@@ -988,7 +988,7 @@
"repair_percentage": 25, "repair_percentage": 25,
"stats": { "stats": {
"armor": 2, "armor": 2,
"stamina_bonus": 5 "max_stamina": 5
}, },
"emoji": "🥾", "emoji": "🥾",
"image_path": "images/items/sturdy_boots.webp", "image_path": "images/items/sturdy_boots.webp",
@@ -1036,7 +1036,7 @@
"repair_percentage": 25, "repair_percentage": 25,
"stats": { "stats": {
"armor": 2, "armor": 2,
"hp_bonus": 5 "max_hp": 5
}, },
"emoji": "👖", "emoji": "👖",
"image_path": "images/items/padded_pants.webp", "image_path": "images/items/padded_pants.webp",
@@ -1261,7 +1261,7 @@
"status": { "status": {
"name": "burning", "name": "burning",
"icon": "🔥", "icon": "🔥",
"damage_per_tick": 3, "damage_percent": 0.08,
"ticks": 3, "ticks": 3,
"persist_after_combat": true "persist_after_combat": true
} }
@@ -1279,8 +1279,8 @@
"emoji": "💨", "emoji": "💨",
"image_path": "images/items/smoke_bomb.webp", "image_path": "images/items/smoke_bomb.webp",
"description": { "description": {
"en": "Creates a smoke screen. Greatly increases flee chance for 1 turn.", "en": "Creates a smoke screen. Greatly increases flee chance for 1 {{interval}}.",
"es": "Crea una cortina de humo. Aumenta la probabilidad de huir por 1 turno." "es": "Crea una cortina de humo. Aumenta la probabilidad de huir por 1 {{interval}}."
}, },
"stackable": true, "stackable": true,
"combat_usable": true, "combat_usable": true,
@@ -1328,8 +1328,8 @@
"emoji": "⚡", "emoji": "⚡",
"image_path": "images/items/adrenaline.webp", "image_path": "images/items/adrenaline.webp",
"description": { "description": {
"en": "Increases damage output for 2 turns. Only usable in combat.", "en": "Increases damage output for 2 {{intervals_plural}}. Only usable in combat.",
"es": "Aumenta el daño durante 2 turnos. Solo usable en combate." "es": "Aumenta el daño durante 2 {{intervals_plural}}. Solo usable en combate."
}, },
"stackable": true, "stackable": true,
"consumable": true, "consumable": true,

View File

@@ -47,6 +47,10 @@
], ],
"flee_chance": 0.3, "flee_chance": 0.3,
"status_inflict_chance": 0.15, "status_inflict_chance": 0.15,
"skills": [
"rabid_bite",
"howl"
],
"image_path": "images/npcs/feral_dog.webp", "image_path": "images/npcs/feral_dog.webp",
"death_message": { "death_message": {
"en": "The feral dog whimpers and collapses. Perhaps it was just hungry...", "en": "The feral dog whimpers and collapses. Perhaps it was just hungry...",
@@ -112,6 +116,10 @@
], ],
"flee_chance": 0.2, "flee_chance": 0.2,
"status_inflict_chance": 0.1, "status_inflict_chance": 0.1,
"skills": [
"bandage_self",
"quick_slash"
],
"image_path": "images/npcs/raider_scout.webp", "image_path": "images/npcs/raider_scout.webp",
"death_message": { "death_message": {
"en": "The raider scout falls with a final gasp. Their supplies are yours.", "en": "The raider scout falls with a final gasp. Their supplies are yours.",
@@ -159,6 +167,9 @@
], ],
"flee_chance": 0.5, "flee_chance": 0.5,
"status_inflict_chance": 0.25, "status_inflict_chance": 0.25,
"skills": [
"rabid_bite"
],
"image_path": "images/npcs/mutant_rat.webp", "image_path": "images/npcs/mutant_rat.webp",
"death_message": { "death_message": {
"en": "The mutant rat squeals its last and goes still.", "en": "The mutant rat squeals its last and goes still.",
@@ -212,6 +223,10 @@
], ],
"flee_chance": 0.1, "flee_chance": 0.1,
"status_inflict_chance": 0.3, "status_inflict_chance": 0.3,
"skills": [
"rabid_bite",
"power_strike"
],
"image_path": "images/npcs/infected_human.webp", "image_path": "images/npcs/infected_human.webp",
"death_message": { "death_message": {
"en": "The infected human finally finds peace in death.", "en": "The infected human finally finds peace in death.",
@@ -289,11 +304,46 @@
], ],
"flee_chance": 0.25, "flee_chance": 0.25,
"status_inflict_chance": 0.05, "status_inflict_chance": 0.05,
"skills": [
"bandage_self",
"power_strike"
],
"image_path": "images/npcs/scavenger.webp", "image_path": "images/npcs/scavenger.webp",
"death_message": { "death_message": {
"en": "The scavenger's struggle ends. Survival has no mercy.", "en": "The scavenger's struggle ends. Survival has no mercy.",
"es": "El deseo de supervivencia del escavador se agota. La supervivencia no tiene misericordia." "es": "El deseo de supervivencia del escavador se agota. La supervivencia no tiene misericordia."
} }
},
"test_boss": {
"npc_id": "test_boss",
"name": {
"en": "Level 50 Test Boss",
"es": "Jefe de Prueba Nivel 50"
},
"description": {
"en": "A huge terrifying monster.",
"es": "Un monstruo enorme y aterrador."
},
"image_path": "images/npcs/test_boss.webp",
"emoji": "👹",
"hp_min": 1000,
"hp_max": 2000,
"damage_min": 25,
"damage_max": 65,
"defense": 15,
"xp_reward": 500,
"loot_table": [],
"flee_chance": 0.0,
"status_inflict_chance": 0.5,
"skills": [
"howl",
"power_strike",
"crushing_blow"
],
"death_message": {
"en": "The boss is defeated.",
"es": "El jefe ha sido derrotado."
}
} }
}, },
"danger_levels": { "danger_levels": {

194
gamedata/perks.json Normal file
View File

@@ -0,0 +1,194 @@
{
"perks": {
"heavy_hitter": {
"name": {
"en": "Heavy Hitter",
"es": "Golpe Pesado"
},
"description": {
"en": "+10% damage with two-handed weapons",
"es": "+10% de daño con armas a dos manos"
},
"icon": "🔨",
"requirements": {
"strength": 10
},
"effects": {
"two_handed_damage_bonus": 0.1
}
},
"iron_fist": {
"name": {
"en": "Iron Fist",
"es": "Puño de Hierro"
},
"description": {
"en": "Unarmed attacks deal STR × 1 damage",
"es": "Los ataques sin armas hacen STR × 1 de daño"
},
"icon": "👊",
"requirements": {
"strength": 20
},
"effects": {
"unarmed_str_scaling": 1.0
}
},
"fleet_footed": {
"name": {
"en": "Fleet Footed",
"es": "Pies Ligeros"
},
"description": {
"en": "-20% stamina cost on travel",
"es": "-20% de coste de aguante al viajar"
},
"icon": "🏃",
"requirements": {
"agility": 10
},
"effects": {
"travel_stamina_reduction": 0.2
}
},
"lucky_strike": {
"name": {
"en": "Lucky Strike",
"es": "Golpe de Suerte"
},
"description": {
"en": "+5% crit chance",
"es": "+5% de probabilidad de crítico"
},
"icon": "🍀",
"requirements": {
"agility": 20
},
"effects": {
"crit_chance_bonus": 0.05
}
},
"thick_skin": {
"name": {
"en": "Thick Skin",
"es": "Piel Gruesa"
},
"description": {
"en": "+10% max HP",
"es": "+10% de vida máxima"
},
"icon": "🛡️",
"requirements": {
"endurance": 10
},
"effects": {
"max_hp_bonus_percent": 0.1
}
},
"resilient": {
"name": {
"en": "Resilient",
"es": "Resistente"
},
"description": {
"en": "Status effects last 1 fewer {{interval}} (min 1)",
"es": "Los efectos de estado duran 1 {{interval}} menos (mín 1)"
},
"icon": "💪",
"requirements": {
"endurance": 20
},
"effects": {
"status_duration_reduction": 1
}
},
"quick_learner": {
"name": {
"en": "Quick Learner",
"es": "Aprendiz Rápido"
},
"description": {
"en": "+15% XP gain",
"es": "+15% de experiencia ganada"
},
"icon": "📖",
"requirements": {
"intellect": 10
},
"effects": {
"xp_bonus": 0.15
}
},
"scavenger": {
"name": {
"en": "Scavenger",
"es": "Carroñero"
},
"description": {
"en": "+1 quantity on consumable/resource drops",
"es": "+1 cantidad en drops de consumibles/recursos"
},
"icon": "🦅",
"requirements": {
"intellect": 20
},
"effects": {
"consumable_loot_bonus": 1
}
},
"survivor": {
"name": {
"en": "Survivor",
"es": "Superviviente"
},
"description": {
"en": "Heal 2% max HP every combat turn",
"es": "Cura 2% de vida máxima cada turno de combate"
},
"icon": "❤️‍🩹",
"requirements": {
"endurance": 15,
"agility": 10
},
"effects": {
"combat_regen_percent": 0.02
}
},
"glass_cannon": {
"name": {
"en": "Glass Cannon",
"es": "Cañón de Cristal"
},
"description": {
"en": "+30% damage, -20% max HP",
"es": "+30% de daño, -20% de vida máxima"
},
"icon": "💣",
"requirements": {
"strength": 20,
"endurance_max": 8
},
"effects": {
"damage_bonus": 0.3,
"max_hp_penalty_percent": 0.2
}
},
"last_stand": {
"name": {
"en": "Last Stand",
"es": "Última Resistencia"
},
"description": {
"en": "Once per combat, survive lethal damage with 1 HP",
"es": "Una vez por combate, sobrevive daño letal con 1 de vida"
},
"icon": "💀",
"requirements": {
"endurance": 30
},
"effects": {
"cheat_death": true
}
}
}
}

393
gamedata/skills.json Normal file
View File

@@ -0,0 +1,393 @@
{
"skills": {
"power_strike": {
"name": {
"en": "Power Strike",
"es": "Golpe Poderoso"
},
"description": {
"en": "A devastating blow with 20% chance to stun",
"es": "Un golpe devastador con 20% de probabilidad de aturdir"
},
"icon": "💥",
"stat_requirement": "strength",
"stat_threshold": 8,
"level_requirement": 5,
"cooldown": 3,
"stamina_cost": 5,
"effects": {
"damage_multiplier": 1.8,
"stun_chance": 0.2,
"stun_duration": 1
}
},
"crushing_blow": {
"name": {
"en": "Crushing Blow",
"es": "Golpe Aplastante"
},
"description": {
"en": "Heavy strike that ignores 50% of enemy armor",
"es": "Golpe pesado que ignora el 50% de la armadura enemiga"
},
"icon": "🔨",
"stat_requirement": "strength",
"stat_threshold": 15,
"level_requirement": 12,
"cooldown": 4,
"stamina_cost": 7,
"effects": {
"damage_multiplier": 1.5,
"armor_penetration": 0.5
}
},
"berserker_rage": {
"name": {
"en": "Berserker Rage",
"es": "Furia Berserker"
},
"description": {
"en": "+50% damage for 3 {{intervals_plural}}, but +25% damage taken",
"es": "+50% de daño durante 3 {{intervals_plural}}, pero +25% de daño recibido"
},
"icon": "🔥",
"stat_requirement": "strength",
"stat_threshold": 25,
"level_requirement": 20,
"cooldown": 6,
"stamina_cost": 10,
"effects": {
"buff": "berserker_rage",
"buff_duration": 3,
"damage_bonus": 0.5,
"damage_taken_increase": 0.25
}
},
"execution": {
"name": {
"en": "Execution",
"es": "Ejecución"
},
"description": {
"en": "300% damage if target below 25% HP, else 100%",
"es": "300% de daño si el objetivo está por debajo del 25% de vida, si no 100%"
},
"icon": "⚰️",
"stat_requirement": "strength",
"stat_threshold": 40,
"level_requirement": 35,
"cooldown": 8,
"stamina_cost": 8,
"effects": {
"damage_multiplier": 1.0,
"execute_threshold": 0.25,
"execute_multiplier": 3.0
}
},
"quick_slash": {
"name": {
"en": "Quick Slash",
"es": "Tajo Rápido"
},
"description": {
"en": "Attack twice at 60% power each",
"es": "Ataca dos veces al 60% de poder cada una"
},
"icon": "🗡️",
"stat_requirement": "agility",
"stat_threshold": 8,
"level_requirement": 5,
"cooldown": 2,
"stamina_cost": 3,
"effects": {
"hits": 2,
"damage_multiplier": 0.6
}
},
"evade": {
"name": {
"en": "Evade",
"es": "Evadir"
},
"description": {
"en": "Guaranteed dodge on the next incoming attack",
"es": "Esquivar garantizado en el próximo ataque recibido"
},
"icon": "🏃",
"stat_requirement": "agility",
"stat_threshold": 15,
"level_requirement": 12,
"cooldown": 3,
"stamina_cost": 4,
"effects": {
"buff": "evade",
"buff_duration": 1,
"guaranteed_dodge": true
}
},
"poisoned_blade": {
"name": {
"en": "Poisoned Blade",
"es": "Hoja Envenenada"
},
"description": {
"en": "80% damage + poison (5% max HP/{{interval}} for 4 {{intervals_plural}})",
"es": "80% de daño + veneno (5% vida máx/{{interval}} durante 4 {{intervals_plural}})"
},
"icon": "🧪",
"stat_requirement": "agility",
"stat_threshold": 25,
"level_requirement": 20,
"cooldown": 5,
"stamina_cost": 6,
"effects": {
"damage_multiplier": 0.8,
"poison_percent": 0.05,
"poison_duration": 4
}
},
"shadow_strike": {
"name": {
"en": "Shadow Strike",
"es": "Golpe Sombrío"
},
"description": {
"en": "250% damage, guaranteed critical hit",
"es": "250% de daño, golpe crítico garantizado"
},
"icon": "🌑",
"stat_requirement": "agility",
"stat_threshold": 40,
"level_requirement": 35,
"cooldown": 7,
"stamina_cost": 8,
"effects": {
"damage_multiplier": 2.5,
"guaranteed_crit": true
}
},
"fortify": {
"name": {
"en": "Fortify",
"es": "Fortificar"
},
"description": {
"en": "Reduce incoming damage by 60% for 2 {{intervals_plural}}",
"es": "Reduce el daño recibido en un 60% durante 2 {{intervals_plural}}"
},
"icon": "🛡️",
"stat_requirement": "endurance",
"stat_threshold": 8,
"level_requirement": 5,
"cooldown": 3,
"stamina_cost": 4,
"effects": {
"buff": "fortify",
"buff_duration": 2,
"damage_reduction": 0.6
}
},
"second_wind": {
"name": {
"en": "Second Wind",
"es": "Segundo Aliento"
},
"description": {
"en": "Restore 20% of max HP",
"es": "Restaura el 20% de la vida máxima"
},
"icon": "💚",
"stat_requirement": "endurance",
"stat_threshold": 15,
"level_requirement": 12,
"cooldown": 5,
"stamina_cost": 6,
"effects": {
"heal_percent": 0.2
}
},
"iron_skin": {
"name": {
"en": "Iron Skin",
"es": "Piel de Hierro"
},
"description": {
"en": "Immune to status effects for 3 {{intervals_plural}}",
"es": "Inmune a efectos de estado durante 3 {{intervals_plural}}"
},
"icon": "🪨",
"stat_requirement": "endurance",
"stat_threshold": 25,
"level_requirement": 20,
"cooldown": 6,
"stamina_cost": 8,
"effects": {
"buff": "iron_skin",
"buff_duration": 3,
"status_immunity": true
}
},
"adrenaline_rush": {
"name": {
"en": "Adrenaline Rush",
"es": "Subida de Adrenalina"
},
"description": {
"en": "Restore 30% of max stamina (free to use)",
"es": "Restaura el 30% del aguante máximo (sin coste)"
},
"icon": "⚡",
"stat_requirement": "endurance",
"stat_threshold": 40,
"level_requirement": 35,
"cooldown": 8,
"stamina_cost": 0,
"effects": {
"stamina_restore_percent": 0.3
}
},
"analyze": {
"name": {
"en": "Analyze",
"es": "Analizar"
},
"description": {
"en": "Reveal enemy HP%, next attack, and weakness",
"es": "Revela el % de vida del enemigo, su próximo ataque y debilidad"
},
"icon": "🔍",
"stat_requirement": "intellect",
"stat_threshold": 8,
"level_requirement": 5,
"cooldown": 2,
"stamina_cost": 2,
"effects": {
"reveal_hp": true,
"reveal_intent": true,
"mark_analyzed": true
}
},
"exploit_weakness": {
"name": {
"en": "Exploit Weakness",
"es": "Explotar Debilidad"
},
"description": {
"en": "200% damage if Analyze was used this combat",
"es": "200% de daño si se usó Analizar en este combate"
},
"icon": "🎯",
"stat_requirement": "intellect",
"stat_threshold": 15,
"level_requirement": 12,
"cooldown": 4,
"stamina_cost": 5,
"effects": {
"damage_multiplier": 2.0,
"requires_analyzed": true
}
},
"drain_life": {
"name": {
"en": "Drain Life",
"es": "Drenar Vida"
},
"description": {
"en": "100% damage, heal for 50% of damage dealt",
"es": "100% de daño, cura el 50% del daño causado"
},
"icon": "🩸",
"stat_requirement": "intellect",
"stat_threshold": 25,
"level_requirement": 20,
"cooldown": 5,
"stamina_cost": 6,
"effects": {
"damage_multiplier": 1.0,
"lifesteal": 0.5
}
},
"foresight": {
"name": {
"en": "Foresight",
"es": "Premonición"
},
"description": {
"en": "Enemy's next 2 attacks automatically miss",
"es": "Los próximos 2 ataques del enemigo fallan automáticamente"
},
"icon": "👁️",
"stat_requirement": "intellect",
"stat_threshold": 40,
"level_requirement": 35,
"cooldown": 7,
"stamina_cost": 7,
"effects": {
"buff": "foresight",
"buff_duration": 2,
"enemy_miss": true
}
},
"rabid_bite": {
"name": {
"en": "Rabid Bite",
"es": "Mordedura Rabiosa"
},
"description": {
"en": "A vicious bite that can infect the target with poison",
"es": "Una mordedura feroz que puede infectar al objetivo con veneno"
},
"icon": "🦷",
"stat_requirement": "agility",
"stat_threshold": 0,
"level_requirement": 1,
"cooldown": 4,
"stamina_cost": 0,
"effects": {
"damage_multiplier": 1.2,
"poison_percent": 0.04,
"poison_duration": 3
}
},
"howl": {
"name": {
"en": "Howl",
"es": "Aullido"
},
"description": {
"en": "Increases damage by 50% for 3 {{intervals_plural}}",
"es": "Aumenta el daño en un 50% durante 3 {{intervals_plural}}"
},
"icon": "🐺",
"stat_requirement": "strength",
"stat_threshold": 0,
"level_requirement": 1,
"cooldown": 8,
"stamina_cost": 0,
"effects": {
"buff": "berserker_rage",
"buff_duration": 3,
"damage_bonus": 0.5
}
},
"bandage_self": {
"name": {
"en": "Bandage Self",
"es": "Vendarse"
},
"description": {
"en": "Restores 25% of maximum HP",
"es": "Restaura el 25% de la vida máxima"
},
"icon": "🩹",
"stat_requirement": "intellect",
"stat_threshold": 0,
"level_requirement": 1,
"cooldown": 6,
"stamina_cost": 0,
"effects": {
"heal_percent": 0.25
}
}
}
}

View File

@@ -0,0 +1,76 @@
{
"effects": {
"poison": {
"icon": "🧪",
"name": {
"en": "Poison",
"es": "Veneno"
},
"description": {
"en": "Deals damage each {{interval}}",
"es": "Inflige daño cada {{interval}}"
},
"type": "damage"
},
"stun": {
"icon": "💫",
"name": {
"en": "Stunned",
"es": "Aturdido"
},
"description": {
"en": "Cannot act this {{interval}}",
"es": "No puede actuar este {{interval}}"
},
"type": "debuff"
},
"analyzed": {
"icon": "🔍",
"name": {
"en": "Analyzed",
"es": "Analizado"
},
"description": {
"en": "Weakness exposed, vulnerable to Exploit Weakness",
"es": "Debilidad expuesta, vulnerable a Explotar Debilidad"
},
"type": "debuff"
},
"bleeding": {
"icon": "🩸",
"name": {
"en": "Bleeding",
"es": "Sangrado"
},
"description": {
"en": "Losing blood each {{interval}}",
"es": "Pierde sangre cada {{interval}}"
},
"type": "damage"
},
"burning": {
"icon": "🔥",
"name": {
"en": "Burning",
"es": "Ardiendo"
},
"description": {
"en": "Takes fire damage each {{interval}}",
"es": "Recibe daño de fuego cada {{interval}}"
},
"type": "damage"
},
"regeneration": {
"icon": "💚",
"name": {
"en": "Regeneration",
"es": "Regeneración"
},
"description": {
"en": "Recovers HP every {{interval}}",
"es": "Recupera PS cada {{interval}}"
},
"type": "buff"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

BIN
images/npcs/test_boss.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
pwa/public/landing-bg.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

BIN
pwa/public/landing-bg.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View File

@@ -0,0 +1,13 @@
import { Outlet } from 'react-router-dom'
import LandingHeader from './LandingHeader'
export default function AuthenticatedLayout() {
return (
<div className="authenticated-layout" style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
<LandingHeader />
<div className="authenticated-content" style={{ flex: 1, paddingTop: '80px' }}>
<Outlet />
</div>
</div>
)
}

View File

@@ -10,6 +10,7 @@ import PlayerSidebar from './game/PlayerSidebar'
import { GameProvider } from '../contexts/GameContext' import { GameProvider } from '../contexts/GameContext'
import { QuestJournal } from './game/QuestJournal' import { QuestJournal } from './game/QuestJournal'
import { CharacterSheet } from './game/CharacterSheet'
import GameHeader from './GameHeader' import GameHeader from './GameHeader'
import './Game.css' import './Game.css'
@@ -18,6 +19,7 @@ function Game() {
const [token] = useState(() => localStorage.getItem('token')) const [token] = useState(() => localStorage.getItem('token'))
const [showQuestJournal, setShowQuestJournal] = useState(false) const [showQuestJournal, setShowQuestJournal] = useState(false)
const [showCharacterSheet, setShowCharacterSheet] = useState(false)
// Handle WebSocket messages // Handle WebSocket messages
const handleWebSocketMessage = async (message: any) => { const handleWebSocketMessage = async (message: any) => {
@@ -422,6 +424,9 @@ function Game() {
try { try {
const response = await api.post('/api/game/pvp/action', { action }) const response = await api.post('/api/game/pvp/action', { action })
actions.setMessage(response.data.message || 'Action performed!') actions.setMessage(response.data.message || 'Action performed!')
if (response.data.equipment) {
actions.updateEquipment(response.data.equipment)
}
// We don't need to fetchGameData here because the websocket update will handle it? // We don't need to fetchGameData here because the websocket update will handle it?
// The user said: "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call." // The user said: "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call."
// So we should probably update state from response if possible, OR fetch. // So we should probably update state from response if possible, OR fetch.
@@ -502,6 +507,8 @@ function Game() {
onUncraft={(uniqueItemId: string, inventoryId: number, quantity?: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId, quantity)} onUncraft={(uniqueItemId: string, inventoryId: number, quantity?: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId, quantity)}
failedActionItemId={state.failedActionItemId} failedActionItemId={state.failedActionItemId}
quests={state.quests} quests={state.quests}
craftedItemResult={state.craftedItemResult}
onCloseCraftedItemResult={() => actions.setCraftedItemResult(null)}
/> />
)} )}
</div> </div>
@@ -527,6 +534,7 @@ function Game() {
}} }}
onSpendPoint={actions.handleSpendPoint} onSpendPoint={actions.handleSpendPoint}
onOpenQuestJournal={() => setShowQuestJournal(true)} onOpenQuestJournal={() => setShowQuestJournal(true)}
onOpenCharacterSheet={() => setShowCharacterSheet(true)}
/> />
)} )}
</div> </div>
@@ -595,6 +603,13 @@ function Game() {
{showQuestJournal && ( {showQuestJournal && (
<QuestJournal onClose={() => setShowQuestJournal(false)} /> <QuestJournal onClose={() => setShowQuestJournal(false)} />
)} )}
{showCharacterSheet && (
<CharacterSheet
onClose={() => setShowCharacterSheet(false)}
onSpendPoint={actions.handleSpendPoint}
/>
)}
</div> </div>
</GameProvider> </GameProvider>
) )

View File

@@ -0,0 +1,189 @@
/* LandingHeader.css */
.landing-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 2rem;
height: 70px;
/* Slightly taller for landing */
background-color: rgba(5, 5, 8, 0.85);
border-bottom: 2px solid rgba(225, 29, 72, 0.3);
backdrop-filter: blur(12px);
position: fixed;
/* Always fixed on landing */
top: 0;
left: 0;
right: 0;
z-index: 1000;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
/* Tech grid background pattern */
background-image: linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 30px 30px;
}
.landing-header-left {
display: flex;
align-items: center;
}
.landing-header-title {
display: flex;
align-items: baseline;
padding: 0.5rem 1.5rem;
background: linear-gradient(135deg, rgba(225, 29, 72, 0.1) 0%, transparent 100%);
border: 1px solid rgba(225, 29, 72, 0.3);
/* Angled Cut */
clip-path: polygon(0 0, 100% 0, 95% 100%, 0% 100%);
border-left: 3px solid #e11d48;
}
.landing-header-title h1 {
margin: 0;
font-size: 1.4rem;
font-weight: 800;
color: #fff;
letter-spacing: 1.5px;
text-transform: uppercase;
font-family: 'Orbitron', sans-serif;
text-shadow: 0 0 10px rgba(225, 29, 72, 0.4);
line-height: 1;
}
.landing-header-version {
font-size: 0.7rem;
color: #e11d48;
margin-left: 0.6rem;
font-family: monospace;
opacity: 0.8;
}
.landing-header-right {
display: flex;
align-items: center;
gap: 1.5rem;
}
/* Auth Buttons */
.landing-nav-btn {
height: 40px;
padding: 0 1.5rem;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Saira Condensed', sans-serif;
font-weight: 700;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
}
/* Login Button - Secondary Style */
.landing-nav-btn.login {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #cbd5e1;
}
.landing-nav-btn.login:hover {
background: rgba(255, 255, 255, 0.1);
border-color: #fff;
color: #fff;
transform: translateY(-1px);
box-shadow: 0 0 10px rgba(255, 255, 255, 0.1);
}
/* Register Button - Primary Style */
.landing-nav-btn.register {
background: linear-gradient(135deg, rgba(225, 29, 72, 0.8) 0%, rgba(190, 18, 60, 0.9) 100%);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
box-shadow: 0 4px 15px rgba(225, 29, 72, 0.4);
}
.landing-nav-btn.register:hover {
background: linear-gradient(135deg, rgba(244, 63, 94, 0.9) 0%, rgba(225, 29, 72, 1) 100%);
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(225, 29, 72, 0.6);
}
/* Play Button (Authenticated) - Green/Success Style */
.landing-nav-btn.play {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.8) 0%, rgba(5, 150, 105, 0.9) 100%);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4);
}
.landing-nav-btn.play:hover {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.9) 0%, rgba(16, 185, 129, 1) 100%);
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.6);
}
/* Logout Button */
.landing-nav-btn.logout {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #94a3b8;
}
.landing-nav-btn.logout:hover {
background: rgba(225, 29, 72, 0.1);
border-color: rgba(225, 29, 72, 0.4);
color: #fff;
}
/* Language Selector Overrides in Landing Header */
/* We need to ensure specific overrides to match Game Header exactly */
.landing-header .language-selector {
margin-right: 0.5rem;
}
.landing-header .language-btn {
height: 40px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.15);
/* Exact clip-path from GameHeader (var(--game-clip-path-sm)) which is: */
clip-path: polygon(4px 0, 100% 0, 100% calc(100% - 4px), calc(100% - 4px) 100%, 0 100%, 0 4px);
border-radius: 0;
}
.landing-header .language-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
}
.landing-header .language-dropdown {
border-radius: 0;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(10, 10, 15, 0.95);
/* Tech clip path */
clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.landing-header {
padding: 0 1rem;
height: 60px;
}
.landing-header-title h1 {
font-size: 1.1rem;
}
.landing-header-version {
display: none;
}
.landing-nav-btn {
padding: 0 1rem;
height: 36px;
font-size: 0.9rem;
}
}

View File

@@ -0,0 +1,68 @@
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../hooks/useAuth'
import LanguageSelector from './LanguageSelector'
import './LandingHeader.css'
export default function LandingHeader() {
const navigate = useNavigate()
const { t } = useTranslation()
const { isAuthenticated, logout } = useAuth()
const handleLogout = () => {
logout()
// Force a full reload to clear any in-memory state/cache and ensure clean redirection
window.location.href = '/'
}
return (
<header className="landing-header">
<div className="landing-header-left">
<div
className="landing-header-title"
onClick={() => navigate('/')}
style={{ cursor: 'pointer' }}
>
<h1>Echoes of the Ash</h1>
<span className="landing-header-version">Official</span>
</div>
</div>
<div className="landing-header-right">
<LanguageSelector />
{isAuthenticated ? (
<>
<button
className="landing-nav-btn account"
onClick={() => navigate('/account')}
>
{t('common.account', 'Account')}
</button>
<button
className="landing-nav-btn logout"
onClick={handleLogout}
>
{t('auth.logout', 'Logout')}
</button>
</>
) : (
<>
<button
className="landing-nav-btn login"
onClick={() => navigate('/login')}
>
{t('landing.login', 'Login')}
</button>
<button
className="landing-nav-btn register"
onClick={() => navigate('/register')}
>
{t('landing.playNow', 'Register')}
</button>
</>
)}
</div>
</header>
)
}

View File

@@ -0,0 +1,45 @@
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { GameButton } from './common/GameButton'
import './LandingPage.css'
export default function PrivacyPolicy() {
const navigate = useNavigate()
const { t } = useTranslation()
return (
<div className="landing-page" style={{ paddingTop: '80px', paddingBottom: '40px' }}>
<div className="about-content" style={{ textAlign: 'left', maxWidth: '800px', margin: '0 auto' }}>
<h1 className="section-title">{t('legal.privacy.title')}</h1>
<div style={{ color: '#cbd5e1', lineHeight: '1.6' }}>
<p>{t('legal.privacy.lastUpdated')}</p>
<h3>{t('legal.privacy.sections.1.title')}</h3>
<p>{t('legal.privacy.sections.1.content')}</p>
<h3>{t('legal.privacy.sections.2.title')}</h3>
<p>{t('legal.privacy.sections.2.content')}</p>
<h3>{t('legal.privacy.sections.3.title')}</h3>
<p>{t('legal.privacy.sections.3.content')}</p>
<h3>{t('legal.privacy.sections.4.title')}</h3>
<p>{t('legal.privacy.sections.4.content')}</p>
<h3>{t('legal.privacy.sections.5.title')}</h3>
<p>{t('legal.privacy.sections.5.content')}</p>
</div>
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
<GameButton
variant="primary"
onClick={() => navigate('/')}
>
{t('legal.privacy.back')}
</GameButton>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,14 @@
import { Outlet } from 'react-router-dom'
import LandingHeader from './LandingHeader'
import './LandingPage.css' // Reuse landing styles for the wrapper if needed
export default function PublicLayout() {
return (
<div className="public-layout">
<LandingHeader />
<div className="public-content">
<Outlet />
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { GameButton } from './common/GameButton'
import './LandingPage.css'
export default function TermsOfService() {
const navigate = useNavigate()
const { t } = useTranslation()
return (
<div className="landing-page" style={{ paddingTop: '80px', paddingBottom: '40px' }}>
<div className="about-content" style={{ textAlign: 'left', maxWidth: '800px', margin: '0 auto' }}>
<h1 className="section-title">{t('legal.terms.title')}</h1>
<div style={{ color: '#cbd5e1', lineHeight: '1.6' }}>
<p>{t('legal.terms.lastUpdated')}</p>
<h3>{t('legal.terms.sections.1.title')}</h3>
<p>{t('legal.terms.sections.1.content')}</p>
<h3>{t('legal.terms.sections.2.title')}</h3>
<p>{t('legal.terms.sections.2.content')}</p>
<h3>{t('legal.terms.sections.3.title')}</h3>
<p>{t('legal.terms.sections.3.content')}</p>
<h3>{t('legal.terms.sections.4.title')}</h3>
<p>{t('legal.terms.sections.4.content')}</p>
<h3>{t('legal.terms.sections.5.title')}</h3>
<p>{t('legal.terms.sections.5.content')}</p>
</div>
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
<GameButton
variant="primary"
onClick={() => navigate('/')}
>
{t('legal.terms.back')}
</GameButton>
</div>
</div>
</div>
)
}

View File

@@ -56,8 +56,11 @@ export const GameDropdown: React.FC<GameDropdownProps> = ({
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
// Handle scroll to close the dropdown (prevents detached menu and layout shifts) // Handle scroll to close the dropdown (prevents detached menu and layout shifts)
const handleScroll = () => { const handleScroll = (event: Event) => {
onClose(); // Only close if scrolling the main document/window, not a sub-container like combat log
if (event.target === document || event.target === window || event.target === document.documentElement || event.target === document.body) {
onClose();
}
}; };
window.addEventListener('scroll', handleScroll, true); window.addEventListener('scroll', handleScroll, true);

View File

@@ -0,0 +1,154 @@
import React from 'react';
import { GameTooltip } from './GameTooltip';
import { ItemTooltipContent } from './ItemTooltipContent';
import { getAssetPath } from '../../utils/assetPath';
import { getTranslatedText } from '../../utils/i18nUtils';
import { GameProgressBar } from './GameProgressBar';
export interface GameItemCardProps {
item: any;
onClick?: (e: React.MouseEvent) => void;
// Display Flags
showTooltip?: boolean;
showQuantity?: boolean;
showEquipped?: boolean;
showValue?: boolean;
valueDisplayType?: 'unit' | 'total';
showDurability?: boolean;
actionHint?: string;
// Trade Data
tradeMarkup?: number;
// Drag & Drop
draggable?: boolean;
onDragStart?: (e: React.DragEvent) => void;
// Styling Overrides
className?: string;
style?: React.CSSProperties;
isActive?: boolean;
}
export const GameItemCard: React.FC<GameItemCardProps> = ({
item,
onClick,
showTooltip = true,
showQuantity = true,
showEquipped = false,
showValue = false,
valueDisplayType = 'total',
showDurability = true,
tradeMarkup = 1,
draggable = false,
onDragStart,
className = '',
style = {},
isActive = false,
actionHint
}) => {
if (!item) return null;
// Resolve tooltip content
const tooltipContent = showTooltip ? (
<ItemTooltipContent
item={item}
showValue={showValue}
valueDisplayType={valueDisplayType}
tradeMarkup={tradeMarkup}
actionHint={actionHint}
/>
) : null;
// Use a unified class name 'game-item-card'
const cardClasses = [
'game-item-card',
`text-tier-${item.tier || 0}`,
isActive ? 'active' : '',
showEquipped && item.is_equipped ? 'equipped' : '',
className
].filter(Boolean).join(' ');
const preventDragHandler = (e: React.DragEvent) => {
e.preventDefault();
};
const cardContent = (
<div
className={cardClasses}
style={style}
onClick={onClick}
draggable={draggable}
onDragStart={onDragStart}
>
<div className="game-item-image-wrapper">
{item.image_path ? (
<img
src={getAssetPath(item.image_path)}
alt={getTranslatedText(item.name)}
className="game-item-img"
onDragStart={preventDragHandler}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
}}
/>
) : null}
<div
className={`game-item-emoji tier-${item.tier || 0} ${item.image_path ? 'hidden' : ''}`}
style={{ fontSize: '2.5rem' }}
>
{item.emoji || '📦'}
</div>
</div>
{/* Equipped Badge */}
{showEquipped && item.is_equipped && (
<div className="item-equipped-indicator">E</div>
)}
{/* Quantity Badge */}
{showQuantity && (item.is_infinite || (item._displayQuantity || item.quantity) > 1) && (
<div className="item-quantity-badge">
{item.is_infinite ? '∞' : `x${item._displayQuantity || item.quantity}`}
</div>
)}
{/* Value Overlay (usually for Trade) */}
{showValue && item.value !== undefined && (() => {
const qty = item.is_infinite ? 1 : (item._displayQuantity !== undefined ? item._displayQuantity : item.quantity) || 1;
const multiplier = valueDisplayType === 'total' ? qty : 1;
return (
<div className="game-item-value-badge">
{Math.round(item.value * tradeMarkup * multiplier)}
</div>
);
})()}
{/* Durability Bar */}
{showDurability && item.max_durability && item.max_durability > 0 && (
<div className="game-item-durability-wrapper" style={{ width: '85%', position: 'absolute', bottom: '4px', left: '50%', transform: 'translateX(-50%)' }}>
<GameProgressBar
value={item.durability}
max={item.max_durability}
type="durability"
height="4px"
showText={false}
/>
</div>
)}
</div>
);
// If tooltip is enabled, wrap it. Otherwise return bare card.
if (showTooltip && tooltipContent) {
return (
<GameTooltip content={tooltipContent}>
{cardContent}
</GameTooltip>
);
}
return cardContent;
};

View File

@@ -11,6 +11,7 @@ interface GameProgressBarProps {
height?: string; height?: string;
align?: 'left' | 'right'; align?: 'left' | 'right';
labelAlignment?: 'left' | 'right'; labelAlignment?: 'left' | 'right';
customColor?: string;
} }
export const GameProgressBar: React.FC<GameProgressBarProps> = ({ export const GameProgressBar: React.FC<GameProgressBarProps> = ({
@@ -22,7 +23,8 @@ export const GameProgressBar: React.FC<GameProgressBarProps> = ({
unit = '', unit = '',
height = '8px', height = '8px',
align = 'left', align = 'left',
labelAlignment labelAlignment,
customColor
}) => { }) => {
const percentage = Math.min(100, Math.max(0, (value / (max || 1)) * 100)); const percentage = Math.min(100, Math.max(0, (value / (max || 1)) * 100));
@@ -42,6 +44,7 @@ export const GameProgressBar: React.FC<GameProgressBarProps> = ({
// Custom coloring for health/stamina if not using classes matching InventoryModal exactly // Custom coloring for health/stamina if not using classes matching InventoryModal exactly
const getGradient = () => { const getGradient = () => {
if (customColor) return customColor;
switch (type) { switch (type) {
// InventoryModal.css defines .weight and .volume gradients // InventoryModal.css defines .weight and .volume gradients
// We can rely on classes if we import the CSS in parent or here // We can rely on classes if we import the CSS in parent or here

View File

@@ -0,0 +1,120 @@
import { useTranslation } from 'react-i18next';
import { EffectBadge } from '../game/EffectBadge';
interface ItemStatBadgesProps {
item: any;
}
/**
* Reusable component that renders all stat badges for an item.
* Used in tooltips, inventory cards, combat inventory, etc.
*/
export const ItemStatBadges = ({ item }: ItemStatBadgesProps) => {
const { t } = useTranslation();
const stats = item.unique_stats || item.stats || {};
const effects = item.effects || {};
return (
<div className="stat-badges-container">
{/* Capacity */}
{(stats.weight_capacity) && (
<span className="stat-badge capacity">
+{stats.weight_capacity}kg
</span>
)}
{(stats.volume_capacity) && (
<span className="stat-badge capacity">
📦 +{stats.volume_capacity}L
</span>
)}
{/* Combat */}
{(stats.damage_min) && (
<span className="stat-badge damage">
{stats.damage_min}-{stats.damage_max}
</span>
)}
{(stats.armor) && (
<span className="stat-badge armor">
🛡 +{stats.armor}
</span>
)}
{(stats.armor_penetration) && (
<span className="stat-badge penetration">
💔 +{stats.armor_penetration} {t('stats.pen')}
</span>
)}
{(stats.crit_chance) && (
<span className="stat-badge crit">
🎯 +{Math.round(stats.crit_chance * 100)}% {t('stats.crit')}
</span>
)}
{(stats.accuracy) && (
<span className="stat-badge accuracy">
👁 +{Math.round(stats.accuracy * 100)}% {t('stats.acc')}
</span>
)}
{(stats.dodge_chance) && (
<span className="stat-badge dodge">
💨 +{Math.round(stats.dodge_chance * 100)}% Dodge
</span>
)}
{(stats.lifesteal) && (
<span className="stat-badge lifesteal">
🧛 +{Math.round(stats.lifesteal * 100)}% {t('stats.life')}
</span>
)}
{/* Attributes */}
{(stats.strength_bonus) && (
<span className="stat-badge strength">
💪 +{stats.strength_bonus} {t('stats.str')}
</span>
)}
{(stats.agility_bonus) && (
<span className="stat-badge agility">
🏃 +{stats.agility_bonus} {t('stats.agi')}
</span>
)}
{(stats.endurance_bonus) && (
<span className="stat-badge endurance">
🏋 +{stats.endurance_bonus} {t('stats.end')}
</span>
)}
{(stats.max_hp) && (
<span className="stat-badge health">
+{stats.max_hp} {t('stats.hpMax')}
</span>
)}
{(stats.max_stamina) && (
<span className="stat-badge stamina">
+{stats.max_stamina} {t('stats.stmMax')}
</span>
)}
{/* Consumables */}
{(item.hp_restore || effects.hp_restore) && (
<span className="stat-badge health">
+{item.hp_restore || effects.hp_restore} HP
</span>
)}
{(item.stamina_restore || effects.stamina_restore) && (
<span className="stat-badge stamina">
+{item.stamina_restore || effects.stamina_restore} Stm
</span>
)}
{/* Status Effects */}
{effects.status_effect && (
<EffectBadge effect={effects.status_effect} />
)}
{effects.cures && effects.cures.length > 0 && (
<span className="stat-badge cure">
💊 {t('game.cures')}: {effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')}
</span>
)}
</div>
);
};

View File

@@ -0,0 +1,21 @@
.language-selector {
display: inline-flex;
align-items: center;
}
.lang-btn {
min-width: 80px;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.lang-icon {
font-size: 1.2em;
}
.lang-text {
font-weight: bold;
text-transform: uppercase;
}

View File

@@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next'
import { GameButton } from './GameButton'
import './LanguageSelector.css'
export function LanguageSelector() {
const { i18n } = useTranslation()
const toggleLanguage = () => {
const newLang = i18n.language.startsWith('es') ? 'en' : 'es'
i18n.changeLanguage(newLang)
}
return (
<div className="language-selector">
<GameButton
variant="secondary"
size="sm"
onClick={toggleLanguage}
className="lang-btn"
title={i18n.language.startsWith('es') ? "Switch to English" : "Cambiar a Español"}
>
<span className="lang-icon">🌐</span>
<span className="lang-text">{i18n.language.startsWith('es') ? 'ES' : 'EN'}</span>
</GameButton>
</div>
)
}

View File

@@ -0,0 +1,89 @@
.notification-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
/* Allow clicking through container */
}
.notification-toast {
background-color: rgba(0, 0, 0, 0.85);
color: #fff;
padding: 12px 20px;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
min-width: 250px;
max-width: 400px;
animation: slideIn 0.3s ease-out;
border-left: 4px solid #fff;
pointer-events: auto;
/* Allow clicking toast to dismiss */
cursor: pointer;
font-size: 0.95rem;
line-height: 1.4;
display: flex;
align-items: center;
}
/* Types */
/* Types */
.notification-toast.success {
border-left-color: #4caf50;
background: rgba(20, 30, 20, 0.95);
}
.notification-toast.error {
border-left-color: #f44336;
background: rgba(40, 20, 20, 0.95);
}
.notification-toast.warning {
border-left-color: #ff9800;
background: rgba(40, 30, 20, 0.95);
}
.notification-toast.info {
border-left-color: #2196f3;
background: rgba(20, 30, 40, 0.95);
}
.notification-toast.quest {
border-left-color: #ffd700;
/* Gold */
background: rgba(40, 35, 10, 0.95);
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.notification-toast.exiting {
animation: slideOut 0.4s ease-in forwards;
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { useNotification } from '../../contexts/NotificationContext';
import './Notification.css';
export const NotificationContainer: React.FC = () => {
const { notifications, removeNotification } = useNotification();
if (notifications.length === 0) return null;
return (
<div className="notification-container">
{notifications.map((notification) => (
<div
key={notification.id}
className={`notification-toast ${notification.type} ${notification.isExiting ? 'exiting' : ''}`}
onClick={() => removeNotification(notification.id)}
>
{notification.message}
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,554 @@
/* ═══════════════════════════════════════════════
CHARACTER SHEET MODAL
Follows VISUALS_GUIDE: dark post-apocalyptic,
chamfered corners, glassmorphism, condensed font
═══════════════════════════════════════════════ */
.game-modal-container.character-sheet-modal {
display: flex;
flex-direction: column;
width: 95vw;
max-width: 1400px;
height: 90%;
max-height: 90%;
overflow: hidden;
}
.character-sheet-modal .game-modal-content {
padding: 0;
overflow-y: auto;
height: 100%;
display: flex;
flex-direction: column;
}
/* ─── Loading ─── */
.cs-loading {
text-align: center;
padding: 3rem;
color: #a0a0a0;
font-size: 1.1rem;
}
/* ─── Header Vitals ─── */
.cs-header-content {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.5rem;
width: 100%;
}
.cs-header-title {
font-size: 1.25rem;
font-weight: 600;
white-space: nowrap;
}
.cs-vitals.cs-header-vitals {
display: flex;
flex: 1;
gap: 1rem;
min-width: 300px;
}
.cs-vital-bar {
flex: 1;
min-width: 80px;
}
/* ─── Tabs ─── */
.cs-tabs {
display: flex;
gap: 10px;
padding: 10px 20px;
background: var(--game-bg-panel);
border-bottom: 1px solid var(--game-border-color);
}
.cs-tab {
flex: 1;
background: transparent;
border: 1px solid transparent;
color: #a0aec0;
padding: 10px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
clip-path: var(--game-clip-path);
text-align: center;
font-family: 'Inter', 'Segoe UI', sans-serif;
font-size: 0.95rem;
position: relative;
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
}
.cs-tab:hover {
color: #fff;
background: rgba(255, 255, 255, 0.05);
}
.cs-tab.active {
background: #3182ce;
color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
border-color: transparent;
}
.cs-tab-badge {
position: absolute;
top: 4px;
right: 10px;
background: #c0392b;
color: white;
font-size: 0.65rem;
padding: 1px 5px;
clip-path: var(--game-clip-path-sm);
font-weight: 700;
min-width: 16px;
text-align: center;
}
/* ─── Tab Content ─── */
.cs-tab-content {
padding: 1rem;
}
/* ─── Sections ─── */
.cs-stats-tab {
display: flex;
gap: 2rem;
}
.cs-stats-base-col {
flex: 0 0 35%;
}
.cs-stats-derived-col {
flex: 1;
}
.cs-section {
margin-bottom: 1.25rem;
}
.cs-section-title {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8a8a9a;
margin: 0 0 0.6rem 0;
padding-bottom: 0.3rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
/* ─── Base Stats Grid ─── */
.cs-unspent-badge {
background: linear-gradient(135deg, rgba(241, 196, 15, 0.15), rgba(241, 196, 15, 0.05));
border: 1px solid rgba(241, 196, 15, 0.3);
color: #f1c40f;
padding: 0.35rem 0.75rem;
clip-path: var(--game-clip-path);
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 0.6rem;
text-align: center;
}
.cs-base-stats-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.cs-stat-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(30, 35, 45, 0.6);
border: 1px solid rgba(255, 255, 255, 0.05);
clip-path: var(--game-clip-path);
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.2);
transition: background 0.15s ease, transform 0.15s ease;
}
.cs-stat-row:hover {
background: rgba(40, 45, 55, 0.8);
transform: translateY(-1px);
}
.cs-stat-icon {
font-size: 1.25rem;
width: 2rem;
text-align: center;
}
.cs-stat-name {
min-width: 80px;
font-size: 0.95rem;
font-weight: 700;
color: #c0c0d0;
text-transform: capitalize;
}
.cs-stat-bar-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
}
.cs-stat-bar {
flex: 1;
height: 10px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.08);
clip-path: var(--game-clip-path);
overflow: hidden;
position: relative;
}
.cs-stat-bar-fill {
height: 100%;
clip-path: var(--game-clip-path);
transition: width 0.3s ease;
}
.cs-stat-val {
font-size: 0.85rem;
font-weight: 600;
color: #e2e8f0;
min-width: 3.5rem;
text-align: right;
font-variant-numeric: tabular-nums;
}
.cs-plus-btn {
width: 26px;
height: 26px;
border: 1px solid rgba(241, 196, 15, 0.5);
background: rgba(241, 196, 15, 0.1);
color: #f1c40f;
font-size: 1.1rem;
font-weight: 700;
clip-path: var(--game-clip-path);
cursor: pointer;
line-height: 1;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.cs-plus-btn:hover {
background: rgba(241, 196, 15, 0.25);
border-color: #f1c40f;
}
/* ─── Derived Stats Grid ─── */
.cs-derived-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.cs-derived-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.85rem;
background: rgba(20, 25, 35, 0.5);
border: 1px solid rgba(255, 255, 255, 0.03);
clip-path: var(--game-clip-path);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
transition: background 0.15s ease;
}
.cs-derived-row:hover {
background: rgba(35, 40, 50, 0.6);
}
.cs-derived-icon {
font-size: 1rem;
width: 1.5rem;
text-align: center;
}
.cs-derived-label {
flex: 1;
font-size: 0.8rem;
font-weight: 500;
color: #a0aec0;
}
.cs-derived-value {
font-size: 0.9rem;
font-weight: 700;
color: #e2e8f0;
font-variant-numeric: tabular-nums;
}
/* ─── Skills Tab ─── */
.cs-skills-tab {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.cs-skill-group-title {
font-size: 0.85rem;
font-weight: 700;
margin: 0 0 0.4rem 0;
}
.cs-skill-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.cs-skill-card {
padding: 0.5rem 0.65rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
clip-path: var(--game-clip-path);
transition: all 0.15s;
}
.cs-skill-card.locked {
opacity: 0.5;
}
.cs-skill-card.unlocked {
border-color: rgba(46, 204, 113, 0.2);
background: rgba(46, 204, 113, 0.03);
}
.cs-skill-header {
display: flex;
align-items: center;
gap: 0.4rem;
}
.cs-skill-icon {
font-size: 1.1rem;
}
.cs-skill-name {
flex: 1;
font-size: 0.82rem;
font-weight: 600;
color: #d0d0e0;
}
.cs-skill-badge {
font-size: 0.7rem;
padding: 1px 6px;
clip-path: var(--game-clip-path-sm);
}
.cs-skill-badge.unlocked {
background: rgba(46, 204, 113, 0.15);
color: #2ecc71;
}
.cs-skill-badge.locked {
color: #8a8a9a;
}
.cs-skill-desc {
font-size: 0.72rem;
color: #8a8a9a;
margin: 0.2rem 0;
line-height: 1.35;
}
.cs-skill-meta {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.2rem;
}
.cs-skill-tag {
font-size: 0.65rem;
padding: 1px 5px;
background: rgba(255, 255, 255, 0.06);
color: #a0a0b0;
clip-path: var(--game-clip-path-sm);
}
.cs-skill-tag.req {
color: #e07a5f;
}
/* ─── Perks Tab ─── */
.cs-perks-tab {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.cs-perk-points {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(241, 196, 15, 0.05);
border: 1px solid rgba(241, 196, 15, 0.15);
clip-path: var(--game-clip-path);
}
.cs-perk-points-label {
font-size: 0.82rem;
font-weight: 600;
color: #c0c0d0;
}
.cs-perk-points-value {
font-size: 0.9rem;
font-weight: 700;
color: #f1c40f;
}
.cs-perk-points-hint {
font-size: 0.7rem;
color: #8a8a9a;
}
.cs-perk-list {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.5rem;
}
.cs-perk-card {
padding: 0.5rem 0.65rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
clip-path: var(--game-clip-path);
transition: all 0.15s;
}
.cs-perk-card.owned {
border-color: rgba(46, 204, 113, 0.25);
background: rgba(46, 204, 113, 0.04);
}
.cs-perk-card.locked {
opacity: 0.5;
}
.cs-perk-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cs-perk-icon {
font-size: 1.2rem;
}
.cs-perk-title-block {
flex: 1;
}
.cs-perk-name {
font-size: 0.82rem;
font-weight: 600;
color: #d0d0e0;
display: block;
}
.cs-perk-desc {
font-size: 0.7rem;
color: #8a8a9a;
margin: 0.1rem 0 0 0;
line-height: 1.3;
}
.cs-perk-status {
font-size: 0.7rem;
padding: 2px 8px;
clip-path: var(--game-clip-path-sm);
white-space: nowrap;
}
.cs-perk-status.owned {
background: rgba(46, 204, 113, 0.15);
color: #2ecc71;
font-weight: 600;
}
.cs-perk-status.locked {
color: #8a8a9a;
}
.cs-perk-reqs {
display: flex;
gap: 0.4rem;
margin-top: 0.3rem;
flex-wrap: wrap;
}
.cs-perk-req {
font-size: 0.65rem;
padding: 1px 5px;
clip-path: var(--game-clip-path-sm);
text-transform: capitalize;
}
.cs-perk-req.met {
background: rgba(46, 204, 113, 0.1);
color: #2ecc71;
}
.cs-perk-req.unmet {
background: rgba(231, 76, 60, 0.1);
color: #e74c3c;
}
/* ─── Mobile Responsive ─── */
@media (max-width: 900px) {
.cs-stats-tab {
flex-direction: column;
}
.cs-skills-tab {
grid-template-columns: 1fr;
}
.cs-perk-list {
grid-template-columns: 1fr 1fr;
}
.cs-derived-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 600px) {
.cs-derived-grid {
grid-template-columns: 1fr;
}
.cs-perk-list {
grid-template-columns: 1fr;
}
.cs-vitals {
flex-direction: column;
gap: 0.3rem;
}
.game-modal-container.character-sheet-modal {
max-width: 100vw;
max-height: 90vh;
height: 90vh;
margin: 0.5rem;
}
}

View File

@@ -0,0 +1,436 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getTranslatedText } from '../../utils/i18nUtils';
import api from '../../services/api';
import { GameModal } from './GameModal';
import { GameProgressBar } from '../common/GameProgressBar';
import { GameButton } from '../common/GameButton';
import { GameTooltip } from '../common/GameTooltip';
import { EffectBadge } from './EffectBadge';
import './CharacterSheet.css';
interface CharacterSheetProps {
onClose: () => void;
onSpendPoint: (stat: string) => void;
}
interface DerivedStats {
attack_power: number;
crit_chance: number;
crit_damage: number;
dodge_chance: number;
flee_chance_base: number;
max_hp: number;
max_stamina: number;
total_armor: number;
armor_reduction: number;
block_chance: number;
status_resistance: number;
item_effectiveness: number;
xp_bonus: number;
loot_quality: number;
crafting_bonus: number;
carry_weight: number;
weapon_damage_min: number;
weapon_damage_max: number;
has_shield: boolean;
}
interface SkillData {
id: string;
name: any;
description: any;
icon: string;
stat_requirement: string;
stat_threshold: number;
level_requirement: number;
cooldown: number;
stamina_cost: number;
unlocked: boolean;
}
interface PerkData {
id: string;
name: any;
description: any;
icon: string;
requirements: Record<string, number>;
effects: Record<string, any>;
meets_requirements: boolean;
owned: boolean;
}
interface CharacterSheetData {
base_stats: {
strength: number;
agility: number;
endurance: number;
intellect: number;
unspent_points: number;
stat_cap: number;
};
derived_stats: DerivedStats;
skills: SkillData[];
perks: {
available_points: number;
total_points: number;
used_points: number;
all_perks: PerkData[];
};
status_effects: any[];
character: {
name: string;
level: number;
xp: number;
hp: number;
max_hp: number;
stamina: number;
max_stamina: number;
avatar_data?: string;
};
}
const STAT_ICONS: Record<string, string> = {
strength: '💪',
agility: '🏃',
endurance: '🫀',
intellect: '🧠',
};
const STAT_COLORS: Record<string, string> = {
strength: '#e74c3c',
agility: '#2ecc71',
endurance: '#f39c12',
intellect: '#3498db',
};
export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) {
const { t } = useTranslation();
const [data, setData] = useState<CharacterSheetData | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'stats' | 'skills' | 'perks'>('stats');
const [selectingPerk, setSelectingPerk] = useState(false);
const fetchSheet = async () => {
try {
const res = await api.get('/api/game/character-sheet');
setData(res.data);
} catch (err) {
console.error('Failed to fetch character sheet:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSheet();
}, []);
const handleSpendPoint = async (stat: string) => {
onSpendPoint(stat);
// Refetch after a short delay to get updated derived stats
setTimeout(fetchSheet, 500);
};
const handleSelectPerk = async (perkId: string) => {
setSelectingPerk(true);
try {
await api.post(`/api/game/select_perk?perk_id=${perkId}`);
await fetchSheet();
} catch (err: any) {
console.error('Failed to select perk:', err.response?.data?.detail || err.message);
} finally {
setSelectingPerk(false);
}
};
if (loading || !data) {
return (
<GameModal title={t('characterSheet.title', 'Character Sheet')} onClose={onClose} className="character-sheet-modal">
<div className="cs-loading"><span></span> {t('common.loading', 'Loading...')}</div>
</GameModal>
);
}
const { base_stats, derived_stats, skills, perks, character, status_effects } = data;
const renderStatsTab = () => (
<div className="cs-stats-tab">
{/* Base Stats Section */}
<div className="cs-stats-base-col">
<div className="cs-section">
<h4 className="cs-section-title">{t('characterSheet.baseStats', 'Base Stats')}</h4>
{/* Vitals Moved Here */}
<div className="cs-vitals" style={{ marginBottom: '1.5rem', display: 'flex', flexDirection: 'column', gap: '0.8rem' }}>
<GameProgressBar
value={character.hp}
max={character.max_hp}
type="health"
showText={true}
height="12px"
label={t('stats.hpMax')}
/>
<GameProgressBar
value={character.stamina}
max={character.max_stamina}
type="stamina"
showText={true}
height="12px"
label={t('stats.stmMax')}
/>
<GameProgressBar
value={character.xp}
max={character.level * 100}
type="xp"
showText={true}
height="12px"
label={t('stats.xp')}
/>
</div>
{status_effects && status_effects.length > 0 && (
<div className="cs-status-effects" style={{ marginBottom: '1.5rem' }}>
<h5 style={{ margin: '0 0 0.5rem 0', color: '#ffb94a' }}>{t('characterSheet.activeEffects', 'Active Effects')}</h5>
<div style={{ display: 'flex', gap: '5px', flexWrap: 'wrap' }}>
{status_effects.map((e: any) => (
<GameTooltip key={e.effect_name || e.id} content={`${getTranslatedText(e.description, { interval: t('stats.interval_minute'), intervals_plural: t('stats.intervals_minute') })} (${e.ticks_remaining} ${t('game.ticksRemaining', 'ticks left')})`}>
<div style={{ display: 'inline-block' }}>
<EffectBadge effect={{
name: e.name || e.effect_name,
icon: e.icon,
type: e.type || (e.damage_per_tick > 0 ? 'damage' : 'buff'),
damage_per_tick: e.damage_per_tick,
ticks: e.ticks_remaining
}} />
</div>
</GameTooltip>
))}
</div>
</div>
)}
{base_stats.unspent_points > 0 && (
<div className="cs-unspent-badge">
<span></span> {base_stats.unspent_points} {t('characterSheet.pointsAvailable', 'points available')}
</div>
)}
<div className="cs-base-stats-grid">
{(['strength', 'agility', 'endurance', 'intellect'] as const).map(stat => (
<div key={stat} className="cs-stat-row">
<span className="cs-stat-icon"><span>{STAT_ICONS[stat]}</span></span>
<span className="cs-stat-name">{t(`stats.${stat}Full`)}</span>
<div className="cs-stat-bar-wrap">
<GameProgressBar
value={base_stats[stat]}
max={base_stats.stat_cap}
type="durability"
customColor={STAT_COLORS[stat]}
showText={true}
height="10px"
/>
</div>
{base_stats.unspent_points > 0 && base_stats[stat] < base_stats.stat_cap && (
<button className="cs-plus-btn" onClick={() => handleSpendPoint(stat)}>+</button>
)}
</div>
))}
</div>
</div>
</div>
{/* Derived Stats Section */}
<div className="cs-stats-derived-col">
<div className="cs-section">
<h4 className="cs-section-title">{t('characterSheet.derivedStats', 'Derived Stats')}</h4>
<div className="cs-derived-grid">
<DerivedStatRow icon="⚔️" label={t('characterSheet.attackPower', 'Attack Power')} value={derived_stats.attack_power} />
<DerivedStatRow icon="🎯" label={t('characterSheet.critChance', 'Crit Chance')} value={`${(derived_stats.crit_chance * 100).toFixed(1)}%`} />
<DerivedStatRow icon="💥" label={t('characterSheet.critDamage', 'Crit Damage')} value={`${derived_stats.crit_damage}x`} />
<DerivedStatRow icon="🏃" label={t('characterSheet.dodgeChance', 'Dodge Chance')} value={`${(derived_stats.dodge_chance * 100).toFixed(1)}%`} />
<DerivedStatRow icon="💨" label={t('characterSheet.fleeChance', 'Flee Chance')} value={`${(derived_stats.flee_chance_base * 100).toFixed(0)}%`} />
<DerivedStatRow icon="❤️" label={t('characterSheet.maxHp', 'Max HP')} value={derived_stats.max_hp} />
<DerivedStatRow icon="⚡" label={t('characterSheet.maxStamina', 'Max Stamina')} value={derived_stats.max_stamina} />
<DerivedStatRow icon="🛡️" label={t('characterSheet.armor', 'Armor')} value={`${derived_stats.total_armor} (${(derived_stats.armor_reduction * 100).toFixed(1)}%)`} />
<DerivedStatRow icon="🧱" label={t('characterSheet.blockChance', 'Block Chance')} value={`${(derived_stats.block_chance * 100).toFixed(1)}%`} />
<DerivedStatRow icon="🧬" label={t('characterSheet.statusResist', 'Status Resist')} value={`${(derived_stats.status_resistance * 100).toFixed(0)}%`} />
<DerivedStatRow icon="💊" label={t('characterSheet.itemEffect', 'Item Effectiveness')} value={`${(derived_stats.item_effectiveness * 100).toFixed(0)}%`} />
<DerivedStatRow icon="📈" label={t('characterSheet.xpBonus', 'XP Bonus')} value={`${(derived_stats.xp_bonus * 100).toFixed(0)}%`} />
<DerivedStatRow icon="🎲" label={t('characterSheet.lootQuality', 'Loot Quality')} value={`${(derived_stats.loot_quality * 100).toFixed(1)}%`} />
<DerivedStatRow icon="🔨" label={t('characterSheet.craftBonus', 'Craft Bonus')} value={`${(derived_stats.crafting_bonus * 100).toFixed(0)}%`} />
<DerivedStatRow icon="🎒" label={t('characterSheet.carryWeight', 'Carry Weight')} value={`${derived_stats.carry_weight} kg`} />
</div>
</div>
</div>
</div>
);
const renderSkillsTab = () => {
const grouped: Record<string, SkillData[]> = {
strength: [],
agility: [],
endurance: [],
intellect: [],
};
skills.forEach(s => {
if (grouped[s.stat_requirement]) {
grouped[s.stat_requirement].push(s);
}
});
return (
<div className="cs-skills-tab">
{Object.entries(grouped).map(([stat, statSkills]) => (
<div key={stat} className="cs-skill-group">
<h4 className="cs-skill-group-title" style={{ color: STAT_COLORS[stat] }}>
<span>{STAT_ICONS[stat]}</span> {t(`stats.${stat}Full`)}
</h4>
<div className="cs-skill-list">
{statSkills.map(skill => (
<div key={skill.id} className={`cs-skill-card ${skill.unlocked ? 'unlocked' : 'locked'}`}>
<div className="cs-skill-header">
<span className="cs-skill-icon">{skill.icon}</span>
<span className="cs-skill-name">{getTranslatedText(skill.name)}</span>
{skill.unlocked ? (
<span className="cs-skill-badge unlocked"><span></span></span>
) : (
<span className="cs-skill-badge locked"><span>🔒</span></span>
)}
</div>
<p className="cs-skill-desc">{getTranslatedText(skill.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</p>
<div className="cs-skill-meta">
<span className="cs-skill-tag"><span></span> {skill.stamina_cost}</span>
<span className="cs-skill-tag"><span>🔄</span> {skill.cooldown}t</span>
<span className="cs-skill-tag req">
{t(`stats.${skill.stat_requirement}`)}: {skill.stat_threshold}
</span>
<span className="cs-skill-tag req">
{t('stats.level')}: {skill.level_requirement}
</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
);
};
const renderPerksTab = () => (
<div className="cs-perks-tab">
<div className="cs-perk-points">
<span className="cs-perk-points-label"><span></span> {t('characterSheet.perkPoints', 'Perk Points')}:</span>
<span className="cs-perk-points-value">
{perks.available_points} / {perks.total_points}
</span>
<span className="cs-perk-points-hint">
({t('characterSheet.nextPerkAt', 'Next at Lv')} {((perks.used_points + perks.available_points + 1) * 5)})
</span>
</div>
<div className="cs-perk-list">
{perks.all_perks.map(perk => {
const canSelect = perk.meets_requirements && !perk.owned && perks.available_points > 0;
return (
<div key={perk.id} className={`cs-perk-card ${perk.owned ? 'owned' : ''} ${!perk.meets_requirements ? 'locked' : ''}`}>
<div className="cs-perk-header">
<span className="cs-perk-icon">{perk.icon}</span>
<div className="cs-perk-title-block">
<span className="cs-perk-name">{getTranslatedText(perk.name)}</span>
<p className="cs-perk-desc">{getTranslatedText(perk.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</p>
</div>
{perk.owned ? (
<span className="cs-perk-status owned"><span></span> {t('characterSheet.owned', 'Owned')}</span>
) : canSelect ? (
<GameButton
variant="primary"
size="sm"
onClick={() => handleSelectPerk(perk.id)}
disabled={selectingPerk}
>
{t('characterSheet.select', 'Select')}
</GameButton>
) : (
<span className="cs-perk-status locked"><span>🔒</span></span>
)}
</div>
<div className="cs-perk-reqs">
{Object.entries(perk.requirements).map(([key, val]) => {
const isMax = key.endsWith('_max');
const baseKey = isMax ? key.replace('_max', '') : key;
const displayKey = ['strength', 'agility', 'endurance', 'intellect', 'level'].includes(baseKey)
? t(`stats.${baseKey}`)
: baseKey;
const currentVal = baseKey === 'level' ? character.level : ((data.base_stats as any)[baseKey] || 0);
const isMet = isMax ? currentVal <= val : currentVal >= val;
return (
<span key={key} className={`cs-perk-req ${isMet ? 'met' : 'unmet'}`}>
{displayKey} {isMax ? '≤' : '≥'} {val}
</span>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
const modalTitle = (
<div className="cs-header-content">
<span className="cs-header-title">{character.name} Lv. {character.level}</span>
</div>
);
return (
<GameModal
title={modalTitle}
onClose={onClose}
className="character-sheet-modal"
>
{/* Tab Navigation */}
<div className="cs-tabs">
<button
className={`cs-tab ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')}
>
<span>📊</span> {t('characterSheet.statsTab', 'Stats')}
{base_stats.unspent_points > 0 && <span className="cs-tab-badge">{base_stats.unspent_points}</span>}
</button>
<button
className={`cs-tab ${activeTab === 'skills' ? 'active' : ''}`}
onClick={() => setActiveTab('skills')}
>
<span></span> {t('characterSheet.skillsTab', 'Skills')}
</button>
<button
className={`cs-tab ${activeTab === 'perks' ? 'active' : ''}`}
onClick={() => setActiveTab('perks')}
>
<span></span> {t('characterSheet.perksTab', 'Perks')}
{perks.available_points > 0 && <span className="cs-tab-badge">{perks.available_points}</span>}
</button>
</div>
{/* Tab Content */}
<div className="cs-tab-content">
{activeTab === 'stats' && renderStatsTab()}
{activeTab === 'skills' && renderSkillsTab()}
{activeTab === 'perks' && renderPerksTab()}
</div>
</GameModal>
);
}
function DerivedStatRow({ icon, label, value }: { icon: string; label: string; value: any }) {
return (
<div className="cs-derived-row">
<span className="cs-derived-icon"><span>{icon}</span></span>
<span className="cs-derived-label">{label}</span>
<span className="cs-derived-value">{value}</span>
</div>
);
}

View File

@@ -132,7 +132,10 @@ export const Combat: React.FC<CombatProps> = ({
opponentName: isPvP opponentName: isPvP
? (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username) ? (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username)
: undefined, : undefined,
turnTimeRemaining: initialCombatData?.pvp_combat?.time_remaining ?? initialCombatData?.turn_time_remaining turnTimeRemaining: initialCombatData?.pvp_combat?.time_remaining ?? initialCombatData?.turn_time_remaining,
npcEffects: initialCombatData?.combat?.npc_effects || [],
playerEffects: initialCombatData?.player_effects || [],
npcIntent: initialCombatData?.combat?.npc_intent
}); });
const [animState, setAnimState] = useState<AnimationState>({ const [animState, setAnimState] = useState<AnimationState>({
@@ -158,6 +161,8 @@ export const Combat: React.FC<CombatProps> = ({
const pendingPlayerHpRef = useRef<{ hp: number; max_hp: number } | null>(null); const pendingPlayerHpRef = useRef<{ hp: number; max_hp: number } | null>(null);
// Store server player XP to apply when XP floating text appears // Store server player XP to apply when XP floating text appears
const pendingPlayerXpRef = useRef<{ xp: number; level: number } | null>(null); const pendingPlayerXpRef = useRef<{ xp: number; level: number } | null>(null);
// Store server equipment to apply when attack/hit animations occur
const pendingEquipmentRef = useRef<any>(null);
// Update queueRef // Update queueRef
useEffect(() => { useEffect(() => {
@@ -284,6 +289,7 @@ export const Combat: React.FC<CombatProps> = ({
yourTurn: newYourTurn !== undefined ? newYourTurn : prev.yourTurn, yourTurn: newYourTurn !== undefined ? newYourTurn : prev.yourTurn,
round: initialCombatData?.combat?.round ?? prev.round, round: initialCombatData?.combat?.round ?? prev.round,
turnTimeRemaining: newTimeRemaining !== undefined ? newTimeRemaining : prev.turnTimeRemaining, turnTimeRemaining: newTimeRemaining !== undefined ? newTimeRemaining : prev.turnTimeRemaining,
npcIntent: initialCombatData?.combat?.npc_intent ?? prev.npcIntent,
// Sync HP for PVP from WebSocket updates // Sync HP for PVP from WebSocket updates
...(isPvP && newPlayerHp !== undefined ? { playerHp: newPlayerHp } : {}), ...(isPvP && newPlayerHp !== undefined ? { playerHp: newPlayerHp } : {}),
...(isPvP && newNpcHp !== undefined ? { npcHp: newNpcHp } : {}) ...(isPvP && newNpcHp !== undefined ? { npcHp: newNpcHp } : {})
@@ -411,14 +417,17 @@ export const Combat: React.FC<CombatProps> = ({
// Apply server player HP when floating text appears // Apply server player HP when floating text appears
if (pendingPlayerHpRef.current) { if (pendingPlayerHpRef.current) {
const { hp, max_hp } = pendingPlayerHpRef.current; const { hp, max_hp } = pendingPlayerHpRef.current;
setLocalCombatState(prev => ({ setLocalCombatState(prev => ({ ...prev, playerHp: hp, playerMaxHp: max_hp }));
...prev,
playerHp: hp,
playerMaxHp: max_hp
}));
updatePlayerState({ hp, max_hp }); updatePlayerState({ hp, max_hp });
pendingPlayerHpRef.current = null; pendingPlayerHpRef.current = null;
} }
// Apply pending equipment update (durability loss from being hit)
if (pendingEquipmentRef.current) {
updatePlayerState({ equipment: pendingEquipmentRef.current });
pendingEquipmentRef.current = null;
}
} }
break; break;
@@ -436,9 +445,25 @@ export const Combat: React.FC<CombatProps> = ({
triggerAnim('shaking', 500); triggerAnim('shaking', 500);
if (data.damage) { if (data.damage) {
addFloatingText(`-${data.damage}!`, 'crit', 'player'); addFloatingText(`-${data.damage}!`, 'crit', 'player');
if (pendingPlayerHpRef.current) {
const { hp, max_hp } = pendingPlayerHpRef.current;
setLocalCombatState(prev => ({ ...prev, playerHp: hp, playerMaxHp: max_hp }));
updatePlayerState({ hp, max_hp });
pendingPlayerHpRef.current = null;
}
if (pendingEquipmentRef.current) {
updatePlayerState({ equipment: pendingEquipmentRef.current });
pendingEquipmentRef.current = null;
}
} }
break; break;
case 'effect_damage':
addFloatingText(`-${data.damage}`, 'damage', origin === 'enemy' ? 'enemy' : 'player');
break;
case 'effect_bleeding': case 'effect_bleeding':
addFloatingText(`-${data.damage}`, 'damage', origin === 'player' ? 'enemy' : 'player'); addFloatingText(`-${data.damage}`, 'damage', origin === 'player' ? 'enemy' : 'player');
break; break;
@@ -495,6 +520,61 @@ export const Combat: React.FC<CombatProps> = ({
case 'quest_update': case 'quest_update':
addNotification(data.message || 'Quest Progress', 'quest'); addNotification(data.message || 'Quest Progress', 'quest');
break; break;
// ── Skill messages ──
case 'skill_attack':
const target_origin = origin === 'enemy' ? 'player' : 'enemy';
triggerAnim(origin === 'enemy' ? 'enemyAttacking' : 'playerAttacking');
triggerAnim(origin === 'enemy' ? 'playerHit' : 'npcHit', 300);
if (data.damage) {
const label = data.hits > 1
? `${data.skill_icon || '⚔️'} -${data.damage} (x${data.hits})`
: `${data.skill_icon || '⚔️'} -${data.damage}`;
addFloatingText(label, 'damage', target_origin);
if (target_origin === 'player') {
if (pendingPlayerHpRef.current) {
const { hp, max_hp } = pendingPlayerHpRef.current;
setLocalCombatState(prev => ({ ...prev, playerHp: hp, playerMaxHp: max_hp }));
updatePlayerState({ hp, max_hp });
pendingPlayerHpRef.current = null;
}
if (pendingEquipmentRef.current) {
updatePlayerState({ equipment: pendingEquipmentRef.current });
pendingEquipmentRef.current = null;
}
}
}
break;
case 'skill_heal':
if (data.heal) {
addFloatingText(`${data.skill_icon || '💚'} +${data.heal}`, 'heal', 'player');
if (pendingPlayerHpRef.current) {
const { hp, max_hp } = pendingPlayerHpRef.current;
setLocalCombatState(prev => ({
...prev, playerHp: hp, playerMaxHp: max_hp
}));
updatePlayerState({ hp, max_hp });
pendingPlayerHpRef.current = null;
}
}
break;
case 'skill_buff':
addFloatingText(`${data.skill_icon || '🛡️'} ${data.skill_name ? (typeof data.skill_name === 'object' ? (data.skill_name[(i18n as any).language] || data.skill_name.en) : data.skill_name) : 'Buff'}`, 'info', 'player');
break;
case 'skill_effect':
if (data.message) {
addFloatingText(data.message, 'info', 'enemy');
}
break;
case 'skill_analyze':
addFloatingText(`${data.skill_icon || '🔍'} Analyzed!`, 'info', 'enemy');
break;
} }
}, [t]); }, [t]);
@@ -528,6 +608,12 @@ export const Combat: React.FC<CombatProps> = ({
} else if (messageQueue.length === 0 && isProcessingQueue) { } else if (messageQueue.length === 0 && isProcessingQueue) {
// Queue just finished processing // Queue just finished processing
setIsProcessingQueue(false); setIsProcessingQueue(false);
// Apply pending equipment updates (durability loss etc.) after ALL animations finish
if (pendingEquipmentRef.current) {
updatePlayerState({ equipment: pendingEquipmentRef.current });
pendingEquipmentRef.current = null;
}
} }
}, [messageQueue, processQueue, isProcessingQueue]); }, [messageQueue, processQueue, isProcessingQueue]);
@@ -552,13 +638,23 @@ export const Combat: React.FC<CombatProps> = ({
npcMaxHp: data.combat.npc_max_hp, npcMaxHp: data.combat.npc_max_hp,
turn: data.combat.turn, turn: data.combat.turn,
round: data.combat.round, round: data.combat.round,
npcName: resolveName(data.combat.npc_name) || prev.npcName npcName: resolveName(data.combat.npc_name) || prev.npcName,
npcEffects: data.combat.npc_effects || [],
playerEffects: (data as any).player_effects || [],
npcIntent: data.combat.npc_intent
})); }));
} else if (data.combat_over && data.player_won) { } else if (data.combat_over && data.player_won === true && action !== 'flee') {
// Apply any remaining pending data on victory
if (pendingEquipmentRef.current) {
updatePlayerState({ equipment: pendingEquipmentRef.current });
pendingEquipmentRef.current = null;
}
// Combat ended with victory but data.combat is null - set enemy HP to 0 // Combat ended with victory but data.combat is null - set enemy HP to 0
setLocalCombatState(prev => ({ setLocalCombatState(prev => ({
...prev, ...prev,
npcHp: 0 npcHp: 0,
npcEffects: [],
playerEffects: []
})); }));
} }
@@ -567,8 +663,13 @@ export const Combat: React.FC<CombatProps> = ({
pendingPlayerHpRef.current = { hp: data.player.hp, max_hp: data.player.max_hp }; pendingPlayerHpRef.current = { hp: data.player.hp, max_hp: data.player.max_hp };
// Store player XP to apply when xp_gain message is processed // Store player XP to apply when xp_gain message is processed
pendingPlayerXpRef.current = { xp: data.player.xp, level: data.player.level }; pendingPlayerXpRef.current = { xp: data.player.xp, level: data.player.level };
refreshCharacters();
} }
if (data.equipment) {
pendingEquipmentRef.current = data.equipment;
}
refreshCharacters();
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -709,12 +810,17 @@ export const Combat: React.FC<CombatProps> = ({
npcMaxHp: data.combat.npc_max_hp, npcMaxHp: data.combat.npc_max_hp,
turn: data.combat.turn, turn: data.combat.turn,
round: data.combat.round, round: data.combat.round,
npcName: resolveName(data.combat.npc_name) || prev.npcName npcName: resolveName(data.combat.npc_name) || prev.npcName,
npcEffects: data.combat.npc_effects || [],
playerEffects: (data as any).player_effects || [],
npcIntent: data.combat.npc_intent
})); }));
} else if (data.combat_over && data.player_won) { } else if (data.combat_over && data.player_won === true) {
setLocalCombatState(prev => ({ setLocalCombatState(prev => ({
...prev, ...prev,
npcHp: 0 npcHp: 0,
npcEffects: [],
playerEffects: []
})); }));
} }
@@ -740,6 +846,7 @@ export const Combat: React.FC<CombatProps> = ({
onClose={handleCloseWrapper} onClose={handleCloseWrapper}
onShowSupplies={() => setShowSuppliesModal(true)} onShowSupplies={() => setShowSuppliesModal(true)}
isProcessing={isProcessingQueue} isProcessing={isProcessingQueue}
playerStamina={playerState?.stamina || 0}
combatResult={combatResult} combatResult={combatResult}
equipment={_equipment} equipment={_equipment}
playerName={profile?.name} playerName={profile?.name}

View File

@@ -33,7 +33,7 @@
clip-path: var(--game-clip-path); clip-path: var(--game-clip-path);
border: 1px solid rgba(255, 107, 107, 0.3); border: 1px solid rgba(255, 107, 107, 0.3);
flex-shrink: 0; flex-shrink: 0;
} }
.combat-location-bg { .combat-location-bg {
width: 100%; width: 100%;
@@ -557,4 +557,36 @@
.progress-fill { .progress-fill {
height: 100%; height: 100%;
transition: width 0.3s ease-out; transition: width 0.3s ease-out;
}
/* Combat Status Effect Badges */
.combat-effects-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.combat-effect-badge {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 6px;
font-size: 0.7rem;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
clip-path: var(--game-clip-path-sm);
}
.combat-effect-badge.effect-buff {
background: rgba(76, 175, 80, 0.3);
border: 1px solid rgba(76, 175, 80, 0.5);
color: #81c784;
}
.combat-effect-badge.effect-debuff {
background: rgba(220, 53, 69, 0.3);
border: 1px solid rgba(220, 53, 69, 0.5);
color: #ef9a9a;
} }

View File

@@ -15,6 +15,14 @@ export interface FloatingText {
timestamp: number; timestamp: number;
} }
export interface CombatEffect {
name: string | Record<string, string>;
icon: string;
ticks_remaining: number;
type?: string; // 'buff', 'debuff', 'damage'
description?: string | Record<string, string>;
}
export interface CombatState { export interface CombatState {
inCombat: boolean; inCombat: boolean;
turn: 'player' | 'enemy' | 'attacker' | 'defender'; turn: 'player' | 'enemy' | 'attacker' | 'defender';
@@ -31,6 +39,9 @@ export interface CombatState {
round: number; round: number;
isPvP?: boolean; isPvP?: boolean;
opponentName?: string; opponentName?: string;
npcEffects?: CombatEffect[];
playerEffects?: CombatEffect[];
npcIntent?: string;
} }
export interface CombatActionResponse { export interface CombatActionResponse {
@@ -47,6 +58,7 @@ export interface CombatActionResponse {
level: number; level: number;
}; };
winner_id?: string; winner_id?: string;
equipment?: any;
} }
export interface AnimationState { export interface AnimationState {

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getTranslatedText } from '../../utils/i18nUtils'; import { getTranslatedText } from '../../utils/i18nUtils';
import { useAudio } from '../../contexts/AudioContext'; import { useAudio } from '../../contexts/AudioContext';
@@ -7,6 +7,9 @@ import { Equipment } from './types';
import './CombatEffects.css'; import './CombatEffects.css';
import { GameProgressBar } from '../common/GameProgressBar'; import { GameProgressBar } from '../common/GameProgressBar';
import { GameButton } from '../common/GameButton'; import { GameButton } from '../common/GameButton';
import { GameDropdown } from '../common/GameDropdown';
import { GameTooltip } from '../common/GameTooltip';
import api from '../../services/api';
interface CombatViewProps { interface CombatViewProps {
state: CombatState; state: CombatState;
@@ -16,6 +19,7 @@ interface CombatViewProps {
onClose: () => void; onClose: () => void;
onShowSupplies: () => void; onShowSupplies: () => void;
isProcessing: boolean; isProcessing: boolean;
playerStamina: number;
combatResult: 'victory' | 'defeat' | 'fled' | null; combatResult: 'victory' | 'defeat' | 'fled' | null;
equipment?: Equipment | any; equipment?: Equipment | any;
playerName?: string; playerName?: string;
@@ -30,6 +34,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
onClose, onClose,
onShowSupplies, onShowSupplies,
isProcessing, isProcessing,
playerStamina,
combatResult, combatResult,
equipment, equipment,
playerName, playerName,
@@ -118,6 +123,20 @@ export const CombatView: React.FC<CombatViewProps> = ({
} }
}, [state.messages]); }, [state.messages]);
const getIntentDisplay = (intent: string) => {
switch (intent) {
case 'defend': return { icon: '🛡️', text: t('combat.intents.defend', 'Defending') };
case 'flee': return { icon: '🏃', text: t('combat.intents.flee', 'Fleeing') };
case 'buff': return { icon: '✨', text: t('combat.intents.buff', 'Buffing') };
case 'attack': return { icon: '⚔️', text: t('combat.intents.attack', 'Attacking') };
case 'charging_attack': return { icon: '⚠️', text: t('combat.intents.charging', 'Charging Attack!') };
default:
// For skills like bandage_self etc.
const skillName = intent.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
return { icon: '🌀', text: t(`combat.intents.${intent}`, skillName) };
}
};
return ( return (
<div className="combat-container"> <div className="combat-container">
@@ -227,6 +246,31 @@ export const CombatView: React.FC<CombatViewProps> = ({
height="10px" height="10px"
labelAlignment="right" labelAlignment="right"
/> />
{/* Enemy Intent */}
{!state.isPvP && state.npcIntent && !combatResult && (
<div style={{ marginTop: '4px', fontSize: '0.85rem', color: '#ffcc00', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: '4px', fontStyle: 'italic' }}>
<span>{getIntentDisplay(state.npcIntent).icon}</span>
<span>{t('combat.intents.label', 'Next move:')} {getIntentDisplay(state.npcIntent).text}</span>
</div>
)}
{/* Enemy Status Effects */}
{state.npcEffects && state.npcEffects.length > 0 && (
<div className="combat-effects-row">
{state.npcEffects.map((eff, i) => (
<GameTooltip key={i} content={
<div>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>{eff.icon} {getTranslatedText(eff.name)}</div>
<div style={{ fontSize: '0.8rem', color: '#aaa' }}>{getTranslatedText(eff.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</div>
<div style={{ fontSize: '0.75rem', color: '#888', marginTop: '2px' }}>{t('combat.log.turns_remaining', { turns: eff.ticks_remaining })}</div>
</div>
}>
<span className="combat-effect-badge effect-debuff">
{eff.icon} {eff.ticks_remaining}
</span>
</GameTooltip>
))}
</div>
)}
</div> </div>
{/* Player HP (Right) */} {/* Player HP (Right) */}
@@ -241,6 +285,24 @@ export const CombatView: React.FC<CombatViewProps> = ({
align="right" align="right"
labelAlignment="left" labelAlignment="left"
/> />
{/* Player Active Buffs/Effects */}
{state.playerEffects && state.playerEffects.length > 0 && (
<div className="combat-effects-row" style={{ justifyContent: 'flex-end' }}>
{state.playerEffects.map((eff, i) => (
<GameTooltip key={i} content={
<div>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>{eff.icon} {getTranslatedText(eff.name)}</div>
<div style={{ fontSize: '0.8rem', color: '#aaa' }}>{getTranslatedText(eff.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</div>
<div style={{ fontSize: '0.75rem', color: '#888', marginTop: '2px' }}>{t('combat.log.turns_remaining', { turns: eff.ticks_remaining })}</div>
</div>
}>
<span className={`combat-effect-badge ${eff.type === 'damage' ? 'effect-debuff' : 'effect-buff'}`}>
{eff.icon} {eff.ticks_remaining}
</span>
</GameTooltip>
))}
</div>
)}
</div> </div>
</div> </div>
@@ -257,9 +319,10 @@ export const CombatView: React.FC<CombatViewProps> = ({
)} )}
{!combatResult && ( {!combatResult && (
<div className="combat-actions-group" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem', width: '100%', maxWidth: '400px', margin: '0 auto' }}> <div className="combat-actions-group" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.5rem', width: '100%', maxWidth: '500px', margin: '0 auto' }}>
<GameButton <GameButton
variant="danger" variant="danger"
onClick={() => onAction('attack')} onClick={() => onAction('attack')}
disabled={isProcessing || !state.yourTurn} disabled={isProcessing || !state.yourTurn}
> >
@@ -267,13 +330,20 @@ export const CombatView: React.FC<CombatViewProps> = ({
</GameButton> </GameButton>
<GameButton <GameButton
variant="primary" variant="secondary"
onClick={() => onAction('defend')} onClick={() => onAction('defend')}
disabled={isProcessing || !state.yourTurn} disabled={isProcessing || !state.yourTurn}
> >
🛡 {t('combat.actions.defend')} 🛡 {t('combat.actions.defend')}
</GameButton> </GameButton>
<AbilitiesDropdown
onAction={onAction}
disabled={isProcessing || !state.yourTurn}
playerStamina={playerStamina}
/>
<GameButton <GameButton
variant="secondary" variant="secondary"
onClick={onShowSupplies} onClick={onShowSupplies}
@@ -323,10 +393,13 @@ export const CombatView: React.FC<CombatViewProps> = ({
case 'enemy_miss': text = t('combat.log.enemy_miss'); break; case 'enemy_miss': text = t('combat.log.enemy_miss'); break;
case 'victory': text = t('combat.victory'); className += " text-success bold"; break; case 'victory': text = t('combat.victory'); className += " text-success bold"; break;
case 'player_defeated': text = t('combat.defeat'); className += " text-danger bold"; break; case 'player_defeated': text = t('combat.defeat'); className += " text-danger bold"; break;
case 'flee_success': text = t('combat.flee.success'); break; case 'flee_success': text = t('combat.log.flee_success'); break;
case 'flee_fail': text = t('combat.flee.fail'); break; case 'flee_fail':
case 'item_broken': text = t('combat.item_broken', { item: getTranslatedText(msg.data?.item_name) }); break; text = t('combat.log.flee_fail');
case 'xp_gain': text = t('combat.log.xp_gain', { xp: msg.data?.xp }); className += " text-warning"; break; className += " text-danger";
break;
case 'item_broken': text = t('combat.log.item_broken', { item: getTranslatedText(msg.data?.item_name), emoji: msg.data?.emoji || '' }); break;
case 'xp_gain': text = t('combat.log.xp_gain', { amount: msg.data?.amount }); className += " text-warning"; break;
case 'damage': case 'damage':
if (msg.origin === 'enemy') { if (msg.origin === 'enemy') {
text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 }); text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 });
@@ -338,7 +411,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
case 'text': text = getTranslatedText(msg.data?.text) || ""; break; case 'text': text = getTranslatedText(msg.data?.text) || ""; break;
case 'item_used': case 'item_used':
text = t('combat.log.item_used', { item: getTranslatedText(msg.data?.item_name) || '' }); text = t('combat.log.item_used', { item: getTranslatedText(msg.data?.item_name) || '' });
if (msg.data?.effects) text += getTranslatedText(msg.data.effects); // Append effects string if backend still sends it if (msg.data?.effects) text += getTranslatedText(msg.data.effects);
className += " text-info"; className += " text-info";
break; break;
case 'effect_applied': case 'effect_applied':
@@ -348,7 +421,121 @@ export const CombatView: React.FC<CombatViewProps> = ({
}); });
className += " text-warning"; className += " text-warning";
break; break;
default: text = msg.type; // ── Skill messages ──
case 'skill_attack': {
const hitsText = msg.data?.hits > 1 ? ` (x${msg.data.hits})` : '';
text = t('combat.log.skill_attack', {
skill_icon: msg.data?.skill_icon || '⚔️',
skill_name: getTranslatedText(msg.data?.skill_name) || '',
damage: msg.data?.damage || 0,
hits_text: hitsText
});
break;
}
case 'skill_heal':
text = t('combat.log.skill_heal', {
skill_icon: msg.data?.skill_icon || '💚',
skill_name: getTranslatedText(msg.data?.skill_name) || '',
heal: msg.data?.heal || 0
});
className += " text-success";
break;
case 'skill_buff':
text = t('combat.log.skill_buff', {
skill_icon: msg.data?.skill_icon || '🛡️',
skill_name: getTranslatedText(msg.data?.skill_name) || ''
});
className += " text-info";
break;
case 'skill_effect':
text = msg.data?.message || '';
className += " text-info";
break;
case 'skill_analyze':
text = t('combat.log.skill_analyze', { skill_icon: msg.data?.skill_icon || '🔍' });
className += " text-info";
break;
// ── Combat reactions ──
case 'combat_crit':
text = t('combat.log.combat_crit');
className += " text-warning bold";
break;
case 'combat_dodge':
text = t('combat.log.combat_dodge');
className += " text-success";
break;
case 'combat_block':
text = t('combat.log.combat_block');
className += " text-success";
break;
case 'damage_reduced':
text = t('combat.log.damage_reduced', { reduction: msg.data?.reduction || 0 });
className += " text-info";
break;
case 'player_defend':
text = t('combat.log.defend');
className += " text-info bold";
break;
// ── Enemy actions ──
case 'enemy_enraged':
text = t('combat.log.enemy_enraged', { npc_name: getTranslatedText(msg.data?.npc_name) || t('common.enemy') });
className += " text-danger bold";
break;
case 'enemy_defend':
text = t('combat.log.enemy_defend', { heal: msg.data?.heal || 0 });
className += " text-danger";
break;
case 'enemy_special':
text = t('combat.log.enemy_special', { damage: msg.data?.damage || 0 });
className += " text-danger bold";
break;
// ── Status effects ──
case 'effect_damage':
if (msg.origin === 'enemy') {
text = t('combat.log.effect_damage_npc', { damage: msg.data?.damage || 0 });
} else {
text = t('combat.log.effect_damage', { damage: msg.data?.damage || 0 });
}
className += " text-danger";
break;
case 'effect_bleeding':
text = t('combat.log.effect_bleeding', { damage: msg.data?.damage || 0 });
className += " text-danger";
break;
case 'effect_heal':
text = t('combat.log.effect_heal', { heal: msg.data?.heal || 0 });
className += " text-success";
break;
// ── Items ──
case 'weapon_broke':
text = t('combat.log.weapon_broke', { item_name: getTranslatedText(msg.data?.item_name) || '' });
className += " text-danger";
break;
case 'item_heal':
text = t('combat.log.item_heal', { heal: msg.data?.heal || 0 });
className += " text-success";
break;
case 'item_restore':
text = t('combat.log.item_restore', { amount: msg.data?.amount || 0, stat: msg.data?.stat || '' });
className += " text-info";
break;
case 'item_damage':
text = t('combat.log.item_damage', { item: getTranslatedText(msg.data?.item_name) || '', damage: msg.data?.damage || 0 });
break;
// ── Outcomes ──
case 'level_up':
text = t('combat.log.level_up', { new_level: msg.data?.new_level || 0 });
className += " text-warning bold";
break;
case 'died':
text = t('combat.log.died');
className += " text-danger bold";
break;
case 'quest_update':
text = msg.data?.message || '';
className += " text-info";
break;
default: text = msg.data?.message || msg.type;
} }
} }
const time = msg.timestamp || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const time = msg.timestamp || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
@@ -377,3 +564,119 @@ export const CombatView: React.FC<CombatViewProps> = ({
</div > </div >
); );
}; };
// ─── Abilities Dropdown ───
interface SkillInfo {
id: string;
name: any;
description: any;
icon: string;
stamina_cost: number;
cooldown: number;
current_cooldown?: number;
}
const AbilitiesDropdown: React.FC<{
onAction: (action: string) => void;
disabled: boolean;
playerStamina?: number;
}> = ({ onAction, disabled, playerStamina }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [loaded, setLoaded] = useState(false);
const loadSkills = async () => {
if (open) {
setOpen(false);
return;
}
try {
const res = await api.get('/api/game/available-skills');
setSkills(res.data.skills || []);
setLoaded(true);
setOpen(true);
} catch (err) {
console.error('Failed to load skills:', err);
}
};
const handleUse = (skillId: string) => {
setOpen(false);
onAction(`skill:${skillId}`);
};
return (
<div style={{ position: 'relative' }}>
<GameButton
variant="info"
onClick={loadSkills}
disabled={disabled}
style={{ width: '100%' }}
>
{t('combat.actions.abilities')}
</GameButton>
{open && skills.length > 0 && (
<GameDropdown
isOpen={open}
onClose={() => setOpen(false)}
width="250px"
>
{skills.map(s => {
const onCooldown = (s.current_cooldown || 0) > 0;
const notEnoughStamina = playerStamina !== undefined && playerStamina < s.stamina_cost;
const isSkillDisabled = disabled || onCooldown || notEnoughStamina;
return (
<GameTooltip key={s.id} content={
<div>
<div style={{ fontWeight: 600, marginBottom: '3px' }}>{s.icon} {getTranslatedText(s.name)}</div>
<div style={{ fontSize: '0.8rem', color: '#ccc', marginBottom: '4px' }}>{getTranslatedText(s.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</div>
<div style={{ fontSize: '0.75rem', color: '#f0c040', display: 'flex', gap: '8px' }}>
<span> {s.stamina_cost} {t('combat.stamina', 'Stamina')}</span>
<span> {t('combat.cooldown_turns', { turns: s.cooldown })}</span>
</div>
</div>
}>
<GameButton
variant="secondary"
size="sm"
onClick={() => handleUse(s.id)}
disabled={isSkillDisabled}
style={{ width: '100%', justifyContent: 'flex-start', marginBottom: '4px' }}
>
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: '0.4rem', filter: isSkillDisabled ? 'grayscale(100%)' : 'none' }}>
<span style={{ fontSize: '1rem' }}>{s.icon}</span>
<span style={{ flex: 1, textAlign: 'left', color: isSkillDisabled ? '#808090' : '#d0d0e0' }}>{getTranslatedText(s.name)}</span>
{onCooldown ? (
<span style={{ color: '#ff9f43', fontSize: '0.7rem', fontWeight: 'bold' }}> {s.current_cooldown}</span>
) : (
<span style={{ color: notEnoughStamina ? '#e53e3e' : '#f0c040', fontSize: '0.7rem', fontWeight: 600 }}>{s.stamina_cost}</span>
)}
</div>
</GameButton>
</GameTooltip>
);
})}
</GameDropdown>
)}
{open && skills.length === 0 && loaded && (
<GameDropdown
isOpen={open}
onClose={() => setOpen(false)}
width="200px"
>
<div style={{
padding: '0.5rem',
color: '#8a8a9a',
fontSize: '0.75rem',
textAlign: 'center',
}}>
{t('combat.noSkills', 'No skills available')}
</div>
</GameDropdown>
)}
</div>
);
};

View File

@@ -24,7 +24,7 @@ export const EffectBadge: React.FC<EffectBadgeProps> = ({ effect }) => {
: getTranslatedText(effect.name); : getTranslatedText(effect.name);
return ( return (
<span className={`stat-badge ${badgeClass}`}> <span className={`stat-badge ${badgeClass}`} style={{ padding: '2px 6px', fontSize: '0.75rem', lineHeight: '1' }}>
{effect.icon} {effect.icon}
{effect.damage_per_tick ? ( {effect.damage_per_tick ? (
<> <>

View File

@@ -41,6 +41,7 @@
} }
.game-modal-title { .game-modal-title {
flex: 1;
margin: 0; margin: 0;
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 600; font-weight: 600;
@@ -104,4 +105,22 @@
/* Entity "Show All" modal - wider like inventory */ /* Entity "Show All" modal - wider like inventory */
.game-modal-container.entity-show-all-modal { .game-modal-container.entity-show-all-modal {
max-width: 900px; max-width: 900px;
}
/*
* Globally standard wide modals
* Character Sheet, Quest Journal, Workbench, Trade, Inventory
*/
.game-modal-container.character-sheet-modal,
.game-modal-container.quest-journal-modal,
.game-modal-container.workbench-modal,
.game-modal-container.inventory-modal-redesign,
.game-modal-container.trade-modal {
width: 95vw !important;
max-width: 1400px !important;
height: 90% !important;
max-height: 90% !important;
display: flex;
flex-direction: column;
overflow: hidden;
} }

View File

@@ -2,7 +2,7 @@ import React, { ReactNode } from 'react';
import './GameModal.css'; import './GameModal.css';
interface GameModalProps { interface GameModalProps {
title?: string; title?: ReactNode;
onClose: () => void; onClose: () => void;
children: ReactNode; children: ReactNode;
className?: string; // For specific styling overrides className?: string; // For specific styling overrides
@@ -16,7 +16,7 @@ export const GameModal: React.FC<GameModalProps> = ({ title, onClose, children,
}}> }}>
<div className={`game-modal-container ${className}`}> <div className={`game-modal-container ${className}`}>
<div className="game-modal-header"> <div className="game-modal-header">
<h2 className="game-modal-title">{title}</h2> <div className="game-modal-title">{title}</div>
<button className="game-modal-close-btn" onClick={onClose}>&times;</button> <button className="game-modal-close-btn" onClick={onClose}>&times;</button>
</div> </div>

View File

@@ -68,6 +68,8 @@ interface LocationViewProps {
onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void
failedActionItemId: string | number | null failedActionItemId: string | number | null
quests: { active: any[], available: any[] } quests: { active: any[], available: any[] }
craftedItemResult: any | null
onCloseCraftedItemResult: () => void
} }
function LocationView({ function LocationView({
@@ -90,6 +92,8 @@ function LocationView({
craftCategoryFilter, craftCategoryFilter,
profile, profile,
quests, quests,
craftedItemResult,
onCloseCraftedItemResult,
onInitiateCombat, onInitiateCombat,
onInitiatePvP, onInitiatePvP,
@@ -810,6 +814,8 @@ function LocationView({
onCraft={onCraft} onCraft={onCraft}
onRepair={onRepair} onRepair={onRepair}
onUncraft={onUncraft} onUncraft={onUncraft}
craftedItemResult={craftedItemResult}
onCloseCraftedItemResult={onCloseCraftedItemResult}
/> />
) )
} }

View File

@@ -11,6 +11,7 @@ import { GameButton } from '../common/GameButton'
import { GameItemCard } from '../common/GameItemCard' import { GameItemCard } from '../common/GameItemCard'
import { GameDropdown } from '../common/GameDropdown' import { GameDropdown } from '../common/GameDropdown'
import { useAudio } from '../../contexts/AudioContext' import { useAudio } from '../../contexts/AudioContext'
import { EffectBadge } from './EffectBadge'
interface PlayerSidebarProps { interface PlayerSidebarProps {
playerState: PlayerState playerState: PlayerState
@@ -27,6 +28,7 @@ interface PlayerSidebarProps {
onDropItem: (itemId: number, invId: number, quantity: number) => void onDropItem: (itemId: number, invId: number, quantity: number) => void
onSpendPoint: (stat: string) => void onSpendPoint: (stat: string) => void
onOpenQuestJournal: () => void onOpenQuestJournal: () => void
onOpenCharacterSheet: () => void
} }
function PlayerSidebar({ function PlayerSidebar({
@@ -43,7 +45,8 @@ function PlayerSidebar({
onUnequipItem, onUnequipItem,
onDropItem, onDropItem,
onSpendPoint, onSpendPoint,
onOpenQuestJournal onOpenQuestJournal,
onOpenCharacterSheet
}: PlayerSidebarProps) { }: PlayerSidebarProps) {
const [showInventory, setShowInventory] = useState(false) const [showInventory, setShowInventory] = useState(false)
const [activeSlot, setActiveSlot] = useState<string | null>(null) const [activeSlot, setActiveSlot] = useState<string | null>(null)
@@ -138,14 +141,18 @@ function PlayerSidebar({
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{t('stats.hp')} {t('stats.hp')}
<div className="status-indicators" style={{ display: 'flex', gap: '5px' }}> <div className="status-indicators" style={{ display: 'flex', gap: '5px' }}>
{playerState.status_effects?.filter((e: any) => e.damage_per_tick !== 0).map((e: any) => ( {playerState.status_effects?.map((e: any) => (
<span key={e.id} className={`stat-indicator ${e.damage_per_tick > 0 ? 'negative' : 'positive'}`} style={{ <GameTooltip key={e.effect_name || e.id} content={`${getTranslatedText(e.description, { interval: state?.combatState?.inCombat ? t('stats.interval_turn') : t('stats.interval_minute'), intervals_plural: state?.combatState?.inCombat ? t('stats.intervals_turn') : t('stats.intervals_minute') })} (${e.ticks_remaining} ${t('game.ticksRemaining', 'ticks left')})`}>
color: e.damage_per_tick > 0 ? '#ff6b6b' : '#4caf50', <div style={{ display: 'inline-block' }}>
fontSize: '0.85rem', <EffectBadge effect={{
fontWeight: 'bold' name: e.name || e.effect_name,
}}> icon: e.icon,
{e.damage_per_tick > 0 ? `-${e.damage_per_tick}` : `+${Math.abs(e.damage_per_tick)}`}/t ({e.ticks_remaining}) type: e.type || (e.damage_per_tick > 0 ? 'damage' : 'buff'),
</span> damage_per_tick: e.damage_per_tick,
ticks: e.ticks_remaining
}} />
</div>
</GameTooltip>
))} ))}
</div> </div>
</div> </div>
@@ -263,6 +270,16 @@ function PlayerSidebar({
{t('game.inventory')} {t('game.inventory')}
</GameButton> </GameButton>
<GameButton
className="quest-journal-btn"
variant="info"
size="sm"
onClick={onOpenCharacterSheet}
style={{ flex: 1, justifyContent: 'center' }}
>
<span>📊</span> {t('common.characterSheet', 'Stats')}
</GameButton>
<GameButton <GameButton
className={`quest-journal-btn ${hasReadyQuests ? 'pulse-gold' : ''}`} className={`quest-journal-btn ${hasReadyQuests ? 'pulse-gold' : ''}`}
variant={hasReadyQuests ? 'warning' : 'secondary'} variant={hasReadyQuests ? 'warning' : 'secondary'}
@@ -270,7 +287,7 @@ function PlayerSidebar({
onClick={onOpenQuestJournal} onClick={onOpenQuestJournal}
style={{ flex: 1, justifyContent: 'center' }} style={{ flex: 1, justifyContent: 'center' }}
> >
{hasReadyQuests ? '❗ ' : '📜 '}{t('common.quests')} <span>{hasReadyQuests ? '❗' : '📜'}</span> {t('common.quests')}
</GameButton> </GameButton>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,11 @@
.quest-journal-modal { .quest-journal-modal {
width: 90vw; display: flex;
max-width: 1200px; flex-direction: column;
height: 95%; width: 95vw;
max-width: 1400px;
height: 90%;
max-height: 90%;
overflow: hidden;
} }
.quest-journal-modal .game-modal-content { .quest-journal-modal .game-modal-content {

View File

@@ -222,7 +222,7 @@ export const QuestJournal: React.FC<QuestJournalProps> = ({ onClose }) => {
> >
<div className="game-modal-content" style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 0 }}> <div className="game-modal-content" style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 0 }}>
{/* Header / Tabs */} {/* Header / Tabs */}
<div style={{ padding: '10px 20px', background: 'rgba(0,0,0,0.5)', borderBottom: '1px solid #333' }}> <div style={{ padding: '10px 20px', background: 'var(--game-bg-panel)', borderBottom: '1px solid var(--game-border-color)' }}>
<div className="tab-container"> <div className="tab-container">
<button <button
className={`journal-tab ${activeTab === 'active' ? 'active' : ''}`} className={`journal-tab ${activeTab === 'active' ? 'active' : ''}`}

View File

@@ -28,6 +28,8 @@ interface WorkbenchProps {
onCraft: (itemId: number) => void onCraft: (itemId: number) => void
onRepair: (uniqueItemId: string, inventoryId: number) => void onRepair: (uniqueItemId: string, inventoryId: number) => void
onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void
craftedItemResult: any | null
onCloseCraftedItemResult: () => void
} }
function Workbench({ function Workbench({
@@ -50,12 +52,15 @@ function Workbench({
onSetCraftCategoryFilter, onSetCraftCategoryFilter,
onCraft, onCraft,
onRepair, onRepair,
onUncraft onUncraft,
craftedItemResult,
onCloseCraftedItemResult
}: WorkbenchProps) { }: WorkbenchProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [selectedItem, setSelectedItem] = useState<any>(null) const [selectedItem, setSelectedItem] = useState<any>(null)
const [salvageQuantity, setSalvageQuantity] = useState<number>(1) const [salvageQuantity, setSalvageQuantity] = useState<number>(1)
const [showSalvageModal, setShowSalvageModal] = useState<boolean>(false)
// Reset selection when tab changes // Reset selection when tab changes
useEffect(() => { useEffect(() => {
@@ -448,10 +453,7 @@ function Workbench({
variant="danger" variant="danger"
disabled={(profile?.stamina || 0) < ((item.stamina_cost || 1) * salvageQuantity)} disabled={(profile?.stamina || 0) < ((item.stamina_cost || 1) * salvageQuantity)}
onClick={() => { onClick={() => {
const confirmMsg = t('crafting.confirmSalvage', { name: getTranslatedText(item.name) }) setShowSalvageModal(true)
if (window.confirm(`${confirmMsg} (x${salvageQuantity})`)) {
onUncraft(item.unique_item_id, item.inventory_id, salvageQuantity)
}
}} }}
style={{ width: '100%' }} style={{ width: '100%' }}
> >
@@ -677,6 +679,99 @@ function Workbench({
</div> </div>
</div> </div>
</div> </div>
{showSalvageModal && selectedItem && (
<GameModal
title={`♻️ ${t('game.salvage')}`}
onClose={() => setShowSalvageModal(false)}
className="salvage-confirm-modal"
>
<div style={{ padding: '1rem', textAlign: 'center' }}>
<p>{t('crafting.confirmSalvage', { name: getTranslatedText(selectedItem.name) })} (x{salvageQuantity})</p>
<div style={{ display: 'flex', gap: '1rem', marginTop: '2rem', justifyContent: 'center' }}>
<GameButton variant="secondary" onClick={() => setShowSalvageModal(false)}>
{t('common.cancel', 'Cancel')}
</GameButton>
<GameButton variant="danger" onClick={() => {
onUncraft(selectedItem.unique_item_id, selectedItem.inventory_id, salvageQuantity)
setShowSalvageModal(false)
}}>
{t('common.confirm', 'Confirm')}
</GameButton>
</div>
</div>
</GameModal>
)}
{/* Crafted Item Feedback Modal */}
{craftedItemResult && (
<GameModal
title={`${t('crafting.successTitle', 'Crafting Successful!')}`}
onClose={onCloseCraftedItemResult}
className="crafted-item-modal"
>
<div style={{ padding: '1rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div className="item-image-thumb" style={{ width: '80px', height: '80px', marginBottom: '1rem' }}>
{craftedItemResult.image_path ? (
<img
src={getAssetPath(craftedItemResult.image_path)}
alt={getTranslatedText(craftedItemResult.name)}
className="item-thumb-img"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
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-thumb-emoji ${craftedItemResult.image_path ? 'hidden' : ''}`} style={{ fontSize: '3rem' }}>
{craftedItemResult.emoji || '📦'}
</div>
</div>
<h3 style={{ margin: '0 0 0.5rem 0', color: '#ecc94b' }}>
{getTranslatedText(craftedItemResult.name)}
</h3>
{craftedItemResult.tier && (
<span className={`text-tier-${craftedItemResult.tier}`} style={{ marginBottom: '1rem', fontWeight: 'bold' }}>
Tier {craftedItemResult.tier}
</span>
)}
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap', width: '100%' }}>
{Object.entries(craftedItemResult.unique_item_data?.unique_stats ?? craftedItemResult.unique_item_data ?? craftedItemResult.base_stats ?? craftedItemResult.stats ?? {})
.filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k))
.map(([key, value]) => {
const icons: Record<string, string> = {
weight_capacity: `⚖️ ${t('game.weight')}`,
volume_capacity: `📦 ${t('game.volume')}`,
armor: `🛡️ ${t('stats.armor')}`,
hp_max: `❤️ ${t('stats.maxHp')}`,
stamina_max: `${t('stats.maxStamina')}`,
damage_min: `⚔️ ${t('stats.damage')} Min`,
damage_max: `⚔️ ${t('stats.damage')} Max`
}
const label = icons[key] || key.replace('_', ' ')
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
return (
<div key={key} className="stat-badge" style={{ background: 'rgba(0,0,0,0.3)', padding: '0.5rem 1rem', borderRadius: '4px', fontSize: '1rem', color: '#ccc' }}>
<span style={{ color: '#aaa' }}>{label}:</span> <span style={{ color: '#fff', fontWeight: 'bold' }}>+{Math.round(Number(value))}{unit}</span>
</div>
)
})}
</div>
<div style={{ marginTop: '2rem' }}>
<GameButton variant="primary" onClick={onCloseCraftedItemResult}>
{t('common.continue', 'Continue')}
</GameButton>
</div>
</div>
</GameModal>
)}
</GameModal> </GameModal>
) )
} }

View File

@@ -54,6 +54,7 @@ export interface GameEngineState {
uncraftFilter: string uncraftFilter: string
inventoryFilter: string inventoryFilter: string
inventoryCategoryFilter: string inventoryCategoryFilter: string
craftedItemResult: any | null
// PvP state // PvP state
lastSeenPvPAction: string | null lastSeenPvPAction: string | null
@@ -130,6 +131,7 @@ export interface GameEngineActions {
setInventoryFilter: (filter: string) => void setInventoryFilter: (filter: string) => void
setInventoryCategoryFilter: (filter: string) => void setInventoryCategoryFilter: (filter: string) => void
toggleCategoryCollapse: (category: string) => void toggleCategoryCollapse: (category: string) => void
setCraftedItemResult: (result: any) => void
// WebSocket helpers // WebSocket helpers
refreshLocation: () => Promise<void> refreshLocation: () => Promise<void>
@@ -142,6 +144,7 @@ export interface GameEngineActions {
addNPCToLocation: (npc: any) => void addNPCToLocation: (npc: any) => void
removeNPCFromLocation: (enemyId: string) => void removeNPCFromLocation: (enemyId: string) => void
updateStatusEffect: (effectName: string | any, remainingTicks: number) => void updateStatusEffect: (effectName: string | any, remainingTicks: number) => void
updateEquipment: (equipmentData: any) => void
// Quests // Quests
updateQuests: (active: any[], available: any[]) => void updateQuests: (active: any[], available: any[]) => void
@@ -186,6 +189,7 @@ export function useGameEngine(
const [uncraftableItems, setUncraftableItems] = useState<any[]>([]) const [uncraftableItems, setUncraftableItems] = useState<any[]>([])
const [inventoryFilter, setInventoryFilter] = useState<string>('') const [inventoryFilter, setInventoryFilter] = useState<string>('')
const [inventoryCategoryFilter, setInventoryCategoryFilter] = useState<string>('all') const [inventoryCategoryFilter, setInventoryCategoryFilter] = useState<string>('all')
const [craftedItemResult, setCraftedItemResult] = useState<any | null>(null)
const [lastSeenPvPAction, setLastSeenPvPAction] = useState<string | null>(null) const [lastSeenPvPAction, setLastSeenPvPAction] = useState<string | null>(null)
const [_pvpTimeRemaining, _setPvpTimeRemaining] = useState<number | null>(null) const [_pvpTimeRemaining, _setPvpTimeRemaining] = useState<number | null>(null)
const [mobileMenuOpen, setMobileMenuOpen] = useState<MobileMenuState>('none') const [mobileMenuOpen, setMobileMenuOpen] = useState<MobileMenuState>('none')
@@ -483,7 +487,8 @@ export function useGameEngine(
in_combat: true, in_combat: true,
combat_over: false, combat_over: false,
player_won: false, player_won: false,
combat: encounter.combat combat: encounter.combat,
player_effects: encounter.player_effects || []
}) })
setCombatLog([]) setCombatLog([])
@@ -663,7 +668,8 @@ export function useGameEngine(
mobileHeaderOpen, mobileHeaderOpen,
locationMessages, locationMessages,
interactableCooldowns, interactableCooldowns,
forceUpdate: _forceUpdate forceUpdate: _forceUpdate,
craftedItemResult
} }
const handleUseItem = async (itemId: string) => { const handleUseItem = async (itemId: string) => {
@@ -779,6 +785,9 @@ export function useGameEngine(
// setMessage('Crafting...') // Loading state ok to keep specific or remove? Let's remove to avoid spam // setMessage('Crafting...') // Loading state ok to keep specific or remove? Let's remove to avoid spam
const response = await api.post('/api/game/craft_item', { item_id: itemId }) const response = await api.post('/api/game/craft_item', { item_id: itemId })
addLocationMessage(response.data.message || 'Item crafted!') addLocationMessage(response.data.message || 'Item crafted!')
if (response.data.item) {
setCraftedItemResult(response.data.item)
}
await refreshWorkbenchData() await refreshWorkbenchData()
} catch (error: any) { } catch (error: any) {
addLocationMessage(error.response?.data?.detail || 'Failed to craft item') addLocationMessage(error.response?.data?.detail || 'Failed to craft item')
@@ -870,7 +879,8 @@ export function useGameEngine(
in_combat: true, in_combat: true,
combat_over: false, combat_over: false,
player_won: false, player_won: false,
combat: response.data.combat combat: response.data.combat,
player_effects: response.data.player_effects || []
}) })
setEnemyName(response.data.combat.npc_name) setEnemyName(response.data.combat.npc_name)
@@ -895,7 +905,9 @@ export function useGameEngine(
const handleCombatAction = async (action: string) => { const handleCombatAction = async (action: string) => {
try { try {
let payload: any = { action } let payload: any = { action }
if (action.includes(':')) { if (action.startsWith('skill:')) {
payload = { action: 'skill', skill_id: action.substring(6) }
} else if (action.includes(':')) {
const [act, itemId] = action.split(':') const [act, itemId] = action.split(':')
payload = { action: act, item_id: itemId } payload = { action: act, item_id: itemId }
} }
@@ -906,6 +918,10 @@ export function useGameEngine(
response.data.quest_updates.forEach((q: any) => handleQuestUpdate(q)) response.data.quest_updates.forEach((q: any) => handleQuestUpdate(q))
} }
// if (response.data.equipment) {
// setEquipment(response.data.equipment)
// }
return response.data return response.data
} catch (error: any) { } catch (error: any) {
setMessage(error.response?.data?.detail || 'Combat action failed') setMessage(error.response?.data?.detail || 'Combat action failed')
@@ -939,7 +955,9 @@ export function useGameEngine(
const handlePvPAction = async (action: string, _targetId: number) => { const handlePvPAction = async (action: string, _targetId: number) => {
try { try {
let payload: any = { action } let payload: any = { action }
if (action.includes(':')) { if (action.startsWith('skill:')) {
payload = { action: 'skill', skill_id: action.substring(6) }
} else if (action.includes(':')) {
const [act, itemId] = action.split(':') const [act, itemId] = action.split(':')
payload = { action: act, item_id: itemId } payload = { action: act, item_id: itemId }
} }
@@ -1079,7 +1097,8 @@ export function useGameEngine(
setCombatState({ setCombatState({
in_combat: true, in_combat: true,
combat_over: false, combat_over: false,
combat: combatRes.data.combat combat: combatRes.data.combat,
player_effects: combatRes.data.player_effects || []
}) })
// Update enemy name/image state // Update enemy name/image state
@@ -1116,6 +1135,12 @@ export function useGameEngine(
if (playerData.max_stamina !== undefined) { if (playerData.max_stamina !== undefined) {
mappedData.max_stamina = playerData.max_stamina mappedData.max_stamina = playerData.max_stamina
} }
if (playerData.status_effects !== undefined) {
mappedData.status_effects = playerData.status_effects
}
if (playerData.equipment !== undefined) {
setEquipment(playerData.equipment)
}
// Update playerState with mapped fields // Update playerState with mapped fields
if (Object.keys(mappedData).length > 0) { if (Object.keys(mappedData).length > 0) {
@@ -1257,6 +1282,8 @@ export function useGameEngine(
setUncraftFilter, setUncraftFilter,
setInventoryFilter, setInventoryFilter,
setInventoryCategoryFilter, setInventoryCategoryFilter,
setCraftedItemResult,
updateEquipment: (data: any) => setEquipment(data),
// WebSocket helper functions // WebSocket helper functions
refreshLocation, refreshLocation,
refreshCombat, refreshCombat,

View File

@@ -0,0 +1,59 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
export type NotificationType = 'success' | 'error' | 'info' | 'warning' | 'quest';
export interface Notification {
id: string;
message: string;
type: NotificationType;
duration?: number;
isExiting?: boolean;
}
interface NotificationContextType {
notifications: Notification[];
addNotification: (message: string, type: NotificationType, duration?: number) => void;
removeNotification: (id: string) => void;
}
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
export const useNotification = () => {
const context = useContext(NotificationContext);
if (!context) {
throw new Error('useNotification must be used within a NotificationProvider');
}
return context;
};
interface NotificationProviderProps {
children: ReactNode;
}
export const NotificationProvider: React.FC<NotificationProviderProps> = ({ children }) => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const addNotification = useCallback((message: string, type: NotificationType, duration = 3000) => {
const id = Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
setNotifications((prev) => [...prev, { id, message, type, duration }]);
if (duration > 0) {
setTimeout(() => {
removeNotification(id);
}, duration);
}
}, []);
const removeNotification = useCallback((id: string) => {
setNotifications((prev) => prev.map(n => n.id === id ? { ...n, isExiting: true } : n));
setTimeout(() => {
setNotifications((prev) => prev.filter((notification) => notification.id !== id));
}, 400); // 400ms match CSS animation
}, []);
return (
<NotificationContext.Provider value={{ notifications, addNotification, removeNotification }}>
{children}
</NotificationContext.Provider>
);
};

View File

@@ -257,7 +257,11 @@
"agi": "AGI", "agi": "AGI",
"end": "END", "end": "END",
"hpMax": "HP max", "hpMax": "HP max",
"stmMax": "Stm max" "stmMax": "Stm max",
"interval_turn": "turn",
"intervals_turn": "turns",
"interval_minute": "minute",
"intervals_minute": "minutes"
}, },
"combat": { "combat": {
"title": "Combat", "title": "Combat",
@@ -285,6 +289,14 @@
"yourTurnTimer": "Your Turn ({{time}})", "yourTurnTimer": "Your Turn ({{time}})",
"enemyTurnTimer": "Enemy Turn", "enemyTurnTimer": "Enemy Turn",
"waiting": "Waiting for opponent...", "waiting": "Waiting for opponent...",
"intents": {
"label": "Next move:",
"defend": "Defending",
"flee": "Fleeing",
"buff": "Buffing",
"attack": "Attacking",
"charging": "Charging Attack!"
},
"messages": { "messages": {
"combat_start": "Combat started with {{enemy}}!", "combat_start": "Combat started with {{enemy}}!",
"player_attack": "You attack for {{damage}} damage!", "player_attack": "You attack for {{damage}} damage!",
@@ -298,8 +310,11 @@
"defend": "Defend", "defend": "Defend",
"flee": "Flee", "flee": "Flee",
"supplies": "Supplies", "supplies": "Supplies",
"useItem": "Use Item" "useItem": "Use Item",
"abilities": "Abilities"
}, },
"stamina": "Stamina",
"cooldown_turns": "{{turns}} turn cooldown",
"status": { "status": {
"attacking": "Attacking...", "attacking": "Attacking...",
"defending": "Bracing for impact...", "defending": "Bracing for impact...",
@@ -329,14 +344,36 @@
"enemy_attack": "Enemy hits for {{damage}} damage", "enemy_attack": "Enemy hits for {{damage}} damage",
"player_miss": "You missed!", "player_miss": "You missed!",
"enemy_miss": "Enemy missed!", "enemy_miss": "Enemy missed!",
"item_broken": "Your {{item}} broke!", "weapon_broke": "Your {{item_name}} broke!",
"xp_gain": "You gained {{xp}} XP!", "item_broken": "Your {{emoji}} {{item_name}} broke!",
"combat_crit": "CRITICAL HIT!",
"combat_dodge": "You dodged the attack!",
"combat_block": "You blocked the attack!",
"xp_gain": "Gained {{amount}} XP",
"flee_success": "You managed to escape!", "flee_success": "You managed to escape!",
"flee_fail": "Failed to escape!",
"defend": "You brace for impact!", "defend": "You brace for impact!",
"item_used": "Used {{item}}", "item_used": "Used {{item}}",
"effect_applied": "Applied {{effect}} to {{target}}", "effect_applied": "Applied {{effect}} to {{target}}",
"item_damage": "{{item}} deals {{damage}} damage!", "item_damage": "{{item}} deals {{damage}} damage!",
"damage_reduced": "Damage reduced by {{reduction}}%" "damage_reduced": "Damage reduced by {{reduction}}%",
"skill_attack": "{{skill_icon}} {{skill_name}} hits for {{damage}} damage{{hits_text}}",
"skill_heal": "{{skill_icon}} {{skill_name}} heals for {{heal}} HP",
"skill_buff": "{{skill_icon}} {{skill_name}} activated",
"skill_effect": "{{message}}",
"skill_analyze": "{{skill_icon}} Target analyzed!",
"enemy_enraged": "{{npc_name}} is enraged!",
"enemy_defend": "Enemy recovers {{heal}} HP",
"enemy_special": "Enemy uses a special attack for {{damage}} damage!",
"effect_bleeding": "Bleeding for {{damage}} damage",
"effect_heal": "Recovered {{heal}} HP",
"effect_damage": "Took {{damage}} damage from status effects",
"effect_damage_npc": "The enemy took {{damage}} damage from status effects",
"level_up": "Level up! You are now level {{new_level}}!",
"item_heal": "Healed for {{heal}} HP",
"item_restore": "Restored {{amount}} {{stat}}",
"died": "You have been defeated!",
"turns_remaining": "{{turns}} turns remaining"
}, },
"modal": { "modal": {
"supplies_title": "Combat Supplies", "supplies_title": "Combat Supplies",
@@ -377,6 +414,30 @@
"potentialBaseStats": "Potential base stats. Actual stats may vary.", "potentialBaseStats": "Potential base stats. Actual stats may vary.",
"confirmSalvage": "Are you sure you want to salvage {{name}}? This cannot be undone." "confirmSalvage": "Are you sure you want to salvage {{name}}? This cannot be undone."
}, },
"characterSheet": {
"title": "Character Sheet",
"statsTab": "Stats",
"skillsTab": "Skills",
"perksTab": "Perks",
"pointsAvailable": "points available",
"baseStats": "Base Stats",
"derivedStats": "Derived Stats",
"attackPower": "Attack Power",
"critChance": "Crit Chance",
"critDamage": "Crit Damage",
"dodgeChance": "Dodge Chance",
"fleeChance": "Flee Chance",
"maxHp": "Max HP",
"maxStamina": "Max Stamina",
"armor": "Armor",
"blockChance": "Block Chance",
"statusResist": "Status Resist",
"itemEffect": "Item Effectiveness",
"xpBonus": "XP Bonus",
"lootQuality": "Loot Quality",
"craftBonus": "Craft Bonus",
"carryWeight": "Base Weight Capacity"
},
"categories": { "categories": {
"all": "All Items", "all": "All Items",
"weapon": "Weapons", "weapon": "Weapons",

View File

@@ -254,8 +254,12 @@
"str": "FUE", "str": "FUE",
"agi": "AGI", "agi": "AGI",
"end": "RES", "end": "RES",
"hpMax": "Vida máx", "hpMax": "PS máx",
"stmMax": "Agua. máx" "stmMax": "Ag máx",
"interval_turn": "turno",
"intervals_turn": "turnos",
"interval_minute": "minuto",
"intervals_minute": "minutos"
}, },
"combat": { "combat": {
"title": "Combate", "title": "Combate",
@@ -284,6 +288,14 @@
"yourTurnTimer": "Tu Turno ({{time}})", "yourTurnTimer": "Tu Turno ({{time}})",
"enemyTurnTimer": "Turno del Enemigo", "enemyTurnTimer": "Turno del Enemigo",
"waiting": "Esperando al oponente...", "waiting": "Esperando al oponente...",
"intents": {
"label": "Próximo movimiento:",
"defend": "Defendiendo",
"flee": "Huyendo",
"buff": "Potenciándose",
"attack": "Atacando",
"charging": "¡Ataque Cargado!"
},
"messages": { "messages": {
"combat_start": "¡Combate iniciado con {{enemy}}!", "combat_start": "¡Combate iniciado con {{enemy}}!",
"player_attack": "¡Atacas por {{damage}} de daño!", "player_attack": "¡Atacas por {{damage}} de daño!",
@@ -296,8 +308,11 @@
"defend": "Defender", "defend": "Defender",
"flee": "Huir", "flee": "Huir",
"supplies": "Suministros", "supplies": "Suministros",
"useItem": "Usar Objeto" "useItem": "Usar Objeto",
"abilities": "Habilidades"
}, },
"stamina": "Aguante",
"cooldown_turns": "{{turns}} turnos de espera",
"status": { "status": {
"attacking": "Atacando...", "attacking": "Atacando...",
"defending": "Preparándose...", "defending": "Preparándose...",
@@ -327,14 +342,36 @@
"enemy_attack": "El enemigo golpea por {{damage}} de daño", "enemy_attack": "El enemigo golpea por {{damage}} de daño",
"player_miss": "¡Fallaste!", "player_miss": "¡Fallaste!",
"enemy_miss": "¡El enemigo falló!", "enemy_miss": "¡El enemigo falló!",
"item_broken": "¡Tu {{item}} se rompió!", "weapon_broke": "¡Tu {{item_name}} se ha roto!",
"item_broken": "¡Tu {{emoji}} {{item_name}} se rompió!",
"combat_crit": "¡GOLPE CRÍTICO!",
"combat_dodge": "¡Esquivaste el ataque!",
"combat_block": "¡Bloqueaste el ataque!",
"xp_gain": "Ganaste {{amount}} XP",
"flee_success": "¡Lograste escapar!", "flee_success": "¡Lograste escapar!",
"flee_fail": "¡No pudiste escapar!", "flee_fail": "¡No pudiste escapar!",
"defend": "¡Te preparas para el impacto!", "defend": "¡Te preparas para el impacto!",
"item_used": "Usaste {{item}}", "item_used": "Usaste {{item}}",
"effect_applied": "Aplicado {{effect}} a {{target}}", "effect_applied": "Aplicado {{effect}} a {{target}}",
"item_damage": "{{item}} inflige {{damage}} de daño!", "item_damage": "{{item}} inflige {{damage}} de daño!",
"damage_reduced": "Daño reducido en {{reduction}}%" "damage_reduced": "Daño reducido en {{reduction}}%",
"skill_attack": "{{skill_icon}} {{skill_name}} golpea por {{damage}} de daño{{hits_text}}",
"skill_heal": "{{skill_icon}} {{skill_name}} cura {{heal}} PS",
"skill_buff": "{{skill_icon}} {{skill_name}} activado",
"skill_effect": "{{message}}",
"skill_analyze": "{{skill_icon}} ¡Objetivo analizado!",
"enemy_enraged": "¡{{npc_name}} está enfurecido!",
"enemy_defend": "El enemigo recupera {{heal}} PS",
"enemy_special": "¡El enemigo usa un ataque especial por {{damage}} de daño!",
"effect_bleeding": "Sangrado por {{damage}} de daño",
"effect_heal": "Recuperaste {{heal}} PS",
"effect_damage": "Recibiste {{damage}} de daño por efectos de estado",
"effect_damage_npc": "El enemigo recibió {{damage}} de daño por efectos de estado",
"level_up": "¡Subiste de nivel! ¡Ahora eres nivel {{new_level}}!",
"item_heal": "Curaste {{heal}} PS",
"item_restore": "Restauraste {{amount}} de {{stat}}",
"died": "¡Has sido derrotado!",
"turns_remaining": "{{turns}} turnos restantes"
}, },
"modal": { "modal": {
"supplies_title": "Suministros de Combate", "supplies_title": "Suministros de Combate",
@@ -375,6 +412,30 @@
"potentialBaseStats": "Estadísticas base potenciales. Las estadísticas reales pueden variar.", "potentialBaseStats": "Estadísticas base potenciales. Las estadísticas reales pueden variar.",
"confirmSalvage": "¿Estás seguro de que quieres desguazar {{name}}? Esto no se puede deshacer." "confirmSalvage": "¿Estás seguro de que quieres desguazar {{name}}? Esto no se puede deshacer."
}, },
"characterSheet": {
"title": "Hoja de Personaje",
"statsTab": "Atributos",
"skillsTab": "Habilidades",
"perksTab": "Talentos",
"pointsAvailable": "puntos disponibles",
"baseStats": "Atributos Base",
"derivedStats": "Estadísticas Derivadas",
"attackPower": "Poder de Ataque",
"critChance": "Prob. Crítico",
"critDamage": "Daño Crítico",
"dodgeChance": "Prob. Esquivar",
"fleeChance": "Prob. Huir",
"maxHp": "Vida Máx.",
"maxStamina": "Aguante Máx.",
"armor": "Armadura",
"blockChance": "Prob. Bloqueo",
"statusResist": "Resistencia a Estados",
"itemEffect": "Eficacia de Objetos",
"xpBonus": "Bonus XP",
"lootQuality": "Calidad de Botín",
"craftBonus": "Bonus Fabricación",
"carryWeight": "Capacidad Base de Carga"
},
"categories": { "categories": {
"all": "Todos los Objetos", "all": "Todos los Objetos",
"weapon": "Armas", "weapon": "Armas",

View File

@@ -21,24 +21,50 @@ if (!isElectron) {
}) })
} }
// Initialize Twemoji after React renders const twemojiOpts = {
const initTwemoji = () => { folder: 'svg',
twemoji.parse(document.body, { ext: '.svg',
folder: 'svg', base: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/'
ext: '.svg', };
base: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/'
}); const initTwemoji = () => {
twemoji.parse(document.body, twemojiOpts);
}; };
// Create a wrapper component that initializes Twemoji
const TwemojiWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { const TwemojiWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
useEffect(() => { useEffect(() => {
// Initial parse // Initial parse of entire body
initTwemoji(); initTwemoji();
// Set up MutationObserver to re-parse when DOM changes // Collect added nodes and parse them in batches AFTER React finishes
const observer = new MutationObserver(() => { // its synchronous render cycle. Without deferral, Twemoji replaces
initTwemoji(); // emoji text nodes with <img> elements while React is still
// reconciling the DOM, causing NotFoundError on removeChild.
let pendingNodes = new Set<Node>();
let rafId: number | null = null;
const processPending = () => {
for (const node of pendingNodes) {
// Only parse nodes still in the document (React may have removed them)
if (node.nodeType === Node.ELEMENT_NODE && document.body.contains(node)) {
twemoji.parse(node as HTMLElement, twemojiOpts);
}
}
pendingNodes.clear();
rafId = null;
};
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
pendingNodes.add(node);
}
}
}
if (pendingNodes.size > 0 && rafId === null) {
rafId = requestAnimationFrame(processPending);
}
}); });
observer.observe(document.body, { observer.observe(document.body, {
@@ -46,7 +72,10 @@ const TwemojiWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) =
subtree: true, subtree: true,
}); });
return () => observer.disconnect(); return () => {
observer.disconnect();
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, []); }, []);
return <>{children}</>; return <>{children}</>;

View File

@@ -7,25 +7,37 @@ export type I18nString = string | { [key: string]: string }
* @param value The value to translate (string or object with language keys) * @param value The value to translate (string or object with language keys)
* @returns The translated string for the current language, or fallback to English/first available * @returns The translated string for the current language, or fallback to English/first available
*/ */
export const getTranslatedText = (value: I18nString | undefined | null): string => { export const getTranslatedText = (value: I18nString | undefined | null, vars?: Record<string, string | number>): string => {
if (!value) return '' if (!value) return ''
// If it's already a string, return it let text = typeof value === 'string' ? value : '';
if (typeof value === 'string') return value
// If it's an object, try to get the current language if (!text && typeof value === 'object') {
const currentLang = i18n.language || 'en' const objValue = value as Record<string, string>;
const currentLang = i18n.language || 'en';
// 1. Try current language // 1. Try current language
if (value[currentLang]) return value[currentLang] if (objValue[currentLang]) {
text = objValue[currentLang];
}
// 2. Try English fallback
else if (objValue['en']) {
text = objValue['en'];
}
// 3. Return the first available key
else {
const firstKey = Object.keys(objValue)[0];
if (firstKey) text = objValue[firstKey];
}
}
// 2. Try English fallback if (!text) return '';
if (value['en']) return value['en']
// 3. Return the first available key if (vars) {
const firstKey = Object.keys(value)[0] Object.entries(vars).forEach(([k, v]) => {
if (firstKey) return value[firstKey] text = text.replace(new RegExp(`{{${k}}}`, 'g'), String(v));
});
}
// 4. Fallback empty return text;
return ''
} }

60
setup_boss.py Normal file
View File

@@ -0,0 +1,60 @@
import json
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
import time
async def main():
# Connect to DB using sqlalchemy
url = "postgresql+asyncpg://admin:password@echoes_of_the_ashes_db:5432/echoesoftheashes"
engine = create_async_engine(url)
try:
async with engine.begin() as conn:
# Get Jocaru ID
res = await conn.execute(text("SELECT id, location_id FROM characters WHERE name ILIKE 'Jocaru'"))
row = res.first()
if not row:
print("Jocaru not found.")
return
pid, loc = row[0], row[1]
print(f"Player Jocaru found (ID {pid}) at {loc}")
# Buff to level 50
await conn.execute(text("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid"), {"pid": pid})
print("Set Jocaru to level 50 metrics.")
# Give items directly via SQL
items = [
('reinforced_pack', 1),
('reinforced_bat', 1),
('combat_knife', 1),
('first_aid_kit', 10),
('mystery_pills', 5),
('energy_bar', 10)
]
for iid, qty in items:
await conn.execute(
text("INSERT INTO inventory (character_id, item_id, quantity) VALUES (:pid, :iid, :qty)"),
{"pid": pid, "iid": iid, "qty": qty}
)
print("Gave items to Jocaru.")
# Spawn enemies
now = time.time()
despawn = now + 86400
enemies = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2
for eid in enemies:
await conn.execute(
text("INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)"),
{"nid": eid, "loc": loc, "start": now, "end": despawn}
)
print(f"Spawned {len(enemies)} enemies at {loc}.")
except Exception as e:
print(f"Error accessing DB natively: {e}")
asyncio.run(main())

45
setup_boss.sql Normal file
View File

@@ -0,0 +1,45 @@
-- Buff player and get location into temporary variable
DO $$
DECLARE
player_id INT;
loc_id VARCHAR;
start_ts FLOAT;
end_ts FLOAT;
BEGIN
SELECT id, location_id INTO player_id, loc_id FROM characters WHERE name ILIKE 'Jocaru';
IF NOT FOUND THEN
RAISE NOTICE 'Player Jocaru not found';
RETURN;
END IF;
UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = player_id;
-- Give items
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'reinforced_pack', 1);
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'reinforced_bat', 1);
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'combat_knife', 1);
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'first_aid_kit', 10);
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'mystery_pills', 5);
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'energy_bar', 10);
-- Spawn enemies
start_ts := extract(epoch from now());
end_ts := start_ts + 86400;
-- 5 Raider Scouts
FOR i IN 1..5 LOOP
INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('raider_scout', loc_id, start_ts, end_ts);
END LOOP;
-- 5 Feral Dogs
FOR i IN 1..5 LOOP
INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('feral_dog', loc_id, start_ts, end_ts);
END LOOP;
-- 5 Mutant Rats
FOR i IN 1..5 LOOP
INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('mutant_rat', loc_id, start_ts, end_ts);
END LOOP;
-- 2 Test Bosses
FOR i IN 1..2 LOOP
INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('test_boss', loc_id, start_ts, end_ts);
END LOOP;
END $$;

42
setup_boss_host.py Normal file
View File

@@ -0,0 +1,42 @@
import asyncio
import time
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def main():
url = "postgresql+asyncpg://admin:password@localhost:5432/echoesoftheashes"
engine = create_async_engine(url)
try:
async with engine.begin() as conn:
res = await conn.execute(text("SELECT id, location_id FROM characters WHERE name ILIKE 'Jocaru'"))
row = res.first()
if not row:
print("Jocaru not found.")
return
pid, loc = row[0], row[1]
print(f"Player Jocaru found (ID {pid}) at {loc}")
await conn.execute(text("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid"), {"pid": pid})
items = [
('reinforced_pack', 1),
('reinforced_bat', 1),
('combat_knife', 1),
('first_aid_kit', 10),
('mystery_pills', 5),
('energy_bar', 10)
]
for iid, qty in items:
await conn.execute(text("INSERT INTO inventory (character_id, item_id, quantity) VALUES (:pid, :iid, :qty)"), {"pid": pid, "iid": iid, "qty": qty})
now = time.time()
despawn = now + 86400
enemies = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2
for eid in enemies:
await conn.execute(text("INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)"),
{"nid": eid, "loc": loc, "start": now, "end": despawn})
print(f"Spawned {len(enemies)} enemies at {loc}.")
except Exception as e:
print(f"DB Error: {e}")
asyncio.run(main())

100
setup_test_env.py Normal file
View File

@@ -0,0 +1,100 @@
import asyncio
import json
import time
import os
import random
from api.database import Database
async def main():
# 1. Update npcs.json to add a test boss
with open('gamedata/npcs.json', 'r') as f:
data = json.load(f)
if 'test_boss' not in data['npcs']:
data['npcs']['test_boss'] = {
"name": {"en": "Level 50 Test Boss", "es": "Jefe de Prueba Nivel 50"},
"description": {"en": "A huge terrifying monster.", "es": "Un monstruo enorme y aterrador."},
"emoji": "👹",
"hp_min": 1000,
"hp_max": 1500,
"damage_min": 25,
"damage_max": 45,
"defense": 15,
"xp_reward": 500,
"loot_table": [],
"flee_chance": 0.0,
"status_inflict_chance": 0.5,
"death_message": {"en": "The boss is defeated.", "es": "El jefe ha sido derrotado."}
}
with open('gamedata/npcs.json', 'w') as f:
json.dump(data, f, indent=2)
print("Added 'test_boss' to npcs.json")
db = Database()
await db.connect()
# 2. Get Jocaru
player = await db.fetch_one("SELECT * FROM characters WHERE name ILIKE 'Jocaru'")
if not player:
print("Player Jocaru not found!")
await db.disconnect()
return
pid = player['id']
ploc = player['location_id']
# 3. Give items
items_to_give = [
('reinforced_pack', 1),
('reinforced_bat', 1),
('knife', 1),
('first_aid_kit', 10),
('mystery_pills', 5),
('energy_bar', 10),
('molotov', 5)
]
for item_id, qty in items_to_give:
for _ in range(qty):
from utils.game_helpers import generate_unique_item_stats
from api.items import ITEMS_MANAGER
item_def = ITEMS_MANAGER.get_item(item_id)
if hasattr(item_def, 'durability') and item_def.durability:
tier = item_def.tier if hasattr(item_def, 'tier') else 1
stats = generate_unique_item_stats(item_id, item_def.durability, tier)
uid = await db.create_unique_item(
item_id=item_id,
tier=tier,
durability=stats['durability'],
max_durability=stats['durability'],
stats=json.dumps(stats.get('stats', {}))
)
await db.execute(
"INSERT INTO inventory (character_id, item_id, quantity, unique_item_id) VALUES (:cid, :iid, 1, :uid)",
{"cid": pid, "iid": item_id, "uid": uid}
)
else:
await db.execute(
"INSERT INTO inventory (character_id, item_id, quantity) VALUES (:cid, :iid, 1)",
{"cid": pid, "iid": item_id}
)
print("Granted test items and backpack.")
# 4. Give XP to reach lvl 50 if needed
await db.execute("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid", {"pid": pid})
print("Buffed Jocaru to lvl 50 manually.")
# 5. Spawn enemies at player's location
now = time.time()
despawn = now + 86400 # 1 day
enemies_to_spawn = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2
for eid in enemies_to_spawn:
await db.execute(
"INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)",
{"nid": eid, "loc": ploc, "start": now, "end": despawn}
)
print(f"Spawned {len(enemies_to_spawn)} enemies at {ploc}")
await db.disconnect()
if __name__ == "__main__":
asyncio.run(main())

521
update_pvp.py Normal file
View File

@@ -0,0 +1,521 @@
import sys
import re
with open('/opt/dockers/echoes_of_the_ashes/api/routers/combat.py', 'r', encoding='utf-8') as f:
content = f.read()
# 1. Tick Player Effects for PvP
effect_tick_code = """
# Track the last action string for DB history
last_action_text = ""
# Process status effects (bleeding, poison, etc.) before action
active_effects = await db.tick_player_effects(current_player['id'])
if active_effects:
from ..game_logic import calculate_status_impact
total_impact = calculate_status_impact(active_effects)
if total_impact > 0:
damage = total_impact
new_hp = max(0, current_player['hp'] - damage)
await db.update_player(current_player['id'], hp=new_hp)
current_player['hp'] = new_hp
messages.append(create_combat_message(
"effect_damage",
origin="player",
damage=damage,
effect_name="status effects"
))
if new_hp <= 0:
messages.append(create_combat_message("died", origin="player", message="You died from status effects!"))
combat_over = True
winner_id = opponent['id']
# Update current player to dead state
await db.update_player(current_player['id'], hp=0, is_dead=True)
# Create corpse
import json
import time as time_module
inventory = await db.get_inventory(current_player['id'])
inventory_items = []
for inv_item in inventory:
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
inventory_items.append({
'item_id': inv_item['item_id'],
'name': item_def.name if item_def else inv_item['item_id'],
'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '📦',
'quantity': inv_item['quantity'],
'durability': inv_item.get('durability'),
'max_durability': inv_item.get('max_durability'),
'tier': inv_item.get('tier')
})
corpse_data = None
if inventory_items:
corpse_id = await db.create_player_corpse(
player_name=current_player['name'],
location_id=current_player['location_id'],
items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
)
await db.clear_inventory(current_player['id'])
corpse_data = {
"id": f"player_{corpse_id}",
"type": "player",
"name": f"{current_player['name']}'s Corpse",
"emoji": "⚰️",
"player_name": current_player['name'],
"loot_count": len(inventory_items),
"items": inventory_items,
"timestamp": time_module.time()
}
# Update PvP statistics for both players
await db.update_player_statistics(current_player['id'], pvp_deaths=1, pvp_combats_lost=1, increment=True)
await db.update_player_statistics(opponent['id'], players_killed=1, pvp_combats_won=1, increment=True)
# Broadcast corpse
broadcast_data = {
"message": get_game_message('pvp_defeat_broadcast', locale, opponent=current_player['name'], winner=opponent['name']),
"action": "player_died",
"player_id": current_player['id']
}
if corpse_data:
broadcast_data["corpse"] = corpse_data
await manager.send_to_location(
location_id=current_player['location_id'],
message={
"type": "location_update",
"data": broadcast_data,
"timestamp": datetime.utcnow().isoformat()
}
)
await db.end_pvp_combat(pvp_combat['id'])
elif total_impact < 0:
heal = abs(total_impact)
new_hp = min(current_player_stats.get('max_hp', current_player['max_hp']), current_player['hp'] + heal)
actual_heal = new_hp - current_player['hp']
if actual_heal > 0:
await db.update_player(current_player['id'], hp=new_hp)
current_player['hp'] = new_hp
messages.append(create_combat_message(
"effect_heal",
origin="player",
heal=actual_heal,
effect_name="status effects"
))
# Stop processing action if player died from status effects
if not combat_over:
"""
content = content.replace(
' # Track the last action string for DB history\n last_action_text = ""',
effect_tick_code
)
# Fix indentation of the actions block since it's now wrapped in `if not combat_over:`
import textwrap
# 2. Add Skill and Use Item logic for PvP
skill_and_item_code = """
elif req.action == 'skill':
if not req.item_id:
raise HTTPException(status_code=400, detail="skill_id (passed as item_id) required")
from ..services.skills import skills_manager
skill_id = req.item_id
skill = skills_manager.get_skill(skill_id)
if not skill:
raise HTTPException(status_code=404, detail="Skill not found")
# Check unlocked
stat_val = current_player.get(skill.stat_requirement, 0)
if stat_val < skill.stat_threshold or current_player['level'] < skill.level_requirement:
raise HTTPException(status_code=400, detail="Skill not unlocked")
# Check cooldown
active_effects = await db.get_player_effects(current_player['id'])
cd_source = f"cd:{skill.id}"
for eff in active_effects:
if eff.get('source') == cd_source and eff.get('ticks_remaining', 0) > 0:
raise HTTPException(status_code=400, detail=f"Skill on cooldown ({eff['ticks_remaining']} turns)")
# Check stamina
if current_player['stamina'] < skill.stamina_cost:
raise HTTPException(status_code=400, detail="Not enough stamina")
# Deduct stamina
new_stamina = current_player['stamina'] - skill.stamina_cost
await db.update_player_stamina(current_player['id'], new_stamina)
current_player['stamina'] = new_stamina
# Add cooldown effect
if skill.cooldown > 0:
await db.add_effect(
player_id=current_player['id'],
effect_name=f"{skill.id}_cooldown",
effect_icon="",
effect_type="cooldown",
value=0,
ticks_remaining=skill.cooldown,
persist_after_combat=False,
source=cd_source
)
# Get weapon info
equipment = await db.get_all_equipment(current_player['id'])
weapon_damage = 0
weapon_inv_id = None
inv_item = None
weapon_def = None
if equipment.get('weapon') and equipment['weapon']:
weapon_slot = equipment['weapon']
inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id'])
if inv_item:
weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
if weapon_def and weapon_def.stats:
weapon_damage = random.randint(
weapon_def.stats.get('damage_min', 0),
weapon_def.stats.get('damage_max', 0)
)
weapon_inv_id = inv_item['id']
effects = skill.effects
new_opponent_hp = opponent['hp']
damage_done = 0
actual_damage = 0
armor_absorbed = 0
# Damage skills
if 'damage_multiplier' in effects:
base_damage = 5
strength_bonus = int(current_player['strength'] * 1.5)
level_bonus = current_player['level']
variance = random.randint(-2, 2)
raw_damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
multiplier = effects['damage_multiplier']
if 'execute_threshold' in effects:
opponent_hp_pct = opponent['hp'] / opponent['max_hp'] if opponent['max_hp'] > 0 else 1
if opponent_hp_pct <= effects['execute_threshold']:
multiplier = effects.get('execute_multiplier', multiplier)
damage = max(1, int(raw_damage * multiplier))
if effects.get('guaranteed_crit'):
damage = int(damage * 1.5)
num_hits = effects.get('hits', 1)
for hit in range(num_hits):
hit_dmg = damage if hit == 0 else max(1, int(raw_damage * multiplier))
absorbed, broken_armor = await reduce_armor_durability(opponent['id'], hit_dmg)
armor_absorbed += absorbed
for broken in broken_armor:
messages.append(create_combat_message(
"item_broken",
origin="enemy",
item_name=broken['name'],
emoji=broken['emoji']
))
last_action_text += f"\\n💔 {opponent['name']}'s {broken['emoji']} {broken['name']} broke!"
actual_hit = max(1, hit_dmg - absorbed)
damage_done += actual_hit
new_opponent_hp = max(0, new_opponent_hp - actual_hit)
actual_damage = damage_done
messages.append(create_combat_message(
"skill_attack",
origin="player",
damage=damage_done,
skill_name=skill.name,
skill_icon=skill.icon,
hits=num_hits
))
last_action_text = f"{current_player['name']} used {skill.name} on {opponent['name']} for {damage_done} damage! (Armor absorbed {armor_absorbed})"
# Lifesteal
if 'lifesteal' in effects:
heal_amount = int(damage_done * effects['lifesteal'])
new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount)
if new_hp > current_player['hp']:
await db.update_player(current_player['id'], hp=new_hp)
current_player['hp'] = new_hp
messages.append(create_combat_message(
"skill_heal", origin="player", heal=heal_amount, skill_icon="🩸"
))
# Poison DoT
if 'poison_damage' in effects:
await db.add_effect(
player_id=opponent['id'],
effect_name="Poison",
effect_icon="🧪",
effect_type="damage",
damage_per_tick=effects['poison_damage'],
ticks_remaining=effects['poison_duration'],
persist_after_combat=True,
source=f"skill_poison:{skill.id}"
)
messages.append(create_combat_message(
"skill_effect", origin="player", message=f"🧪 Poisoned! ({effects['poison_damage']} dmg/turn)"
))
# Stun chance
if 'stun_chance' in effects and random.random() < effects['stun_chance']:
# Stun in PvP can be modeled as taking away a turn
await db.add_effect(
player_id=opponent['id'],
effect_name="Stunned",
effect_icon="💫",
effect_type="debuff",
ticks_remaining=1,
persist_after_combat=False,
source="skill_stun"
)
messages.append(create_combat_message(
"skill_effect", origin="player", message="💫 Stunned! (Currently skip effect)"
))
# Weapon durability
if weapon_inv_id and inv_item and inv_item.get('unique_item_id'):
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
if new_durability is None:
messages.append(create_combat_message("weapon_broke", origin="player", item_name=weapon_def.name if weapon_def else "weapon"))
await db.unequip_item(current_player['id'], 'weapon')
# Heal skills
if 'heal_percent' in effects:
heal_amount = int(current_player['max_hp'] * effects['heal_percent'])
new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount)
actual_heal = new_hp - current_player['hp']
if actual_heal > 0:
await db.update_player(current_player['id'], hp=new_hp)
current_player['hp'] = new_hp
messages.append(create_combat_message(
"skill_heal", origin="player", heal=actual_heal, skill_icon=skill.icon
))
last_action_text = f"{current_player['name']} used {skill.name} and healed for {actual_heal} HP!"
# Fortify
if 'armor_boost' in effects:
await db.add_effect(
player_id=current_player['id'],
effect_name="Fortify",
effect_icon="🛡️",
effect_type="buff",
value=effects['armor_boost'],
ticks_remaining=effects['duration'],
persist_after_combat=False,
source=f"skill_fortify:{skill.id}"
)
messages.append(create_combat_message(
"skill_effect", origin="player", message=f"🛡️ Fortified! (+{effects['armor_boost']} Armor)"
))
last_action_text = f"{current_player['name']} used {skill.name} and bolstered their defenses!"
# Process opponent HP if damage done
if damage_done > 0:
await db.update_player(opponent['id'], hp=new_opponent_hp)
if new_opponent_hp <= 0:
last_action_text += f"\\n🏆 {current_player['name']} has defeated {opponent['name']}!"
messages.append(create_combat_message("victory", origin="neutral", npc_name=opponent['name']))
combat_over = True
winner_id = current_player['id']
await db.update_player(opponent['id'], hp=0, is_dead=True)
# Create corpse
import json
import time as time_module
inventory = await db.get_inventory(opponent['id'])
inventory_items = []
for inv_item in inventory:
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
inventory_items.append({
'item_id': inv_item['item_id'],
'name': item_def.name if item_def else inv_item['item_id'],
'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '📦',
'quantity': inv_item['quantity'],
'durability': inv_item.get('durability'),
'max_durability': inv_item.get('max_durability'),
'tier': inv_item.get('tier')
})
corpse_data = None
if inventory_items:
corpse_id = await db.create_player_corpse(
player_name=opponent['name'],
location_id=opponent['location_id'],
items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
)
await db.clear_inventory(opponent['id'])
corpse_data = {
"id": f"player_{corpse_id}",
"type": "player",
"name": f"{opponent['name']}'s Corpse",
"emoji": "⚰️",
"player_name": opponent['name'],
"loot_count": len(inventory_items),
"items": inventory_items,
"timestamp": time_module.time()
}
# Update statistics
await db.update_player_statistics(opponent['id'], pvp_deaths=1, pvp_combats_lost=1, pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True)
await db.update_player_statistics(current_player['id'], players_killed=1, pvp_combats_won=1, pvp_damage_dealt=actual_damage, pvp_attacks_landed=1, increment=True)
# Broadcast corpse
broadcast_data = {
"message": get_game_message('pvp_defeat_broadcast', locale, opponent=opponent['name'], winner=current_player['name']),
"action": "player_died",
"player_id": opponent['id']
}
if corpse_data:
broadcast_data["corpse"] = corpse_data
await manager.send_to_location(
location_id=opponent['location_id'],
message={
"type": "location_update",
"data": broadcast_data,
"timestamp": datetime.utcnow().isoformat()
}
)
await db.end_pvp_combat(pvp_combat['id'])
else:
await db.update_player_statistics(current_player['id'], pvp_damage_dealt=actual_damage, pvp_attacks_landed=1, increment=True)
await db.update_player_statistics(opponent['id'], pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True)
# End of turn swap
updates = {
'turn': 'defender' if is_attacker else 'attacker',
'turn_started_at': time.time(),
'last_action': f"{last_action_text}|{time.time()}"
}
await db.update_pvp_combat(pvp_combat['id'], updates)
else:
# Skill didn't do damage, but turn still ends
updates = {
'turn': 'defender' if is_attacker else 'attacker',
'turn_started_at': time.time(),
'last_action': f"{last_action_text}|{time.time()}"
}
await db.update_pvp_combat(pvp_combat['id'], updates)
elif req.action == 'use_item':
if not req.item_id:
raise HTTPException(status_code=400, detail="item_id required for use_item action")
player_inventory = await db.get_inventory(current_player['id'])
inv_item = next((item for item in player_inventory if item['item_id'] == req.item_id), None)
if not inv_item:
raise HTTPException(status_code=400, detail="Item not found in inventory")
item_def = ITEMS_MANAGER.get_item(req.item_id)
if not item_def or not item_def.combat_usable:
raise HTTPException(status_code=400, detail="This item cannot be used in combat")
item_name = get_locale_string(item_def.name, locale)
effects_applied = []
if item_def.effects.get('status_effect'):
status_data = item_def.effects['status_effect']
await db.add_effect(
player_id=current_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,
source=f"item:{item_def.id}"
)
effects_applied.append(f"Applied {status_data['name']}")
if item_def.effects.get('cures'):
for cure_effect in item_def.effects['cures']:
if await db.remove_effect(current_player['id'], cure_effect):
effects_applied.append(f"Cured {cure_effect}")
if item_def.effects.get('hp_restore') and item_def.effects['hp_restore'] > 0:
item_effectiveness = current_player_stats.get('item_effectiveness', 1.0)
restore_amount = int(item_def.effects['hp_restore'] * item_effectiveness)
new_hp = min(current_player['max_hp'], current_player['hp'] + restore_amount)
actual_heal = new_hp - current_player['hp']
if actual_heal > 0:
await db.update_player(current_player['id'], hp=new_hp)
current_player['hp'] = new_hp
effects_applied.append(get_game_message('healed_amount_text', locale, amount=actual_heal))
messages.append(create_combat_message("item_heal", origin="player", heal=actual_heal))
if item_def.effects.get('stamina_restore') and item_def.effects['stamina_restore'] > 0:
item_effectiveness = current_player_stats.get('item_effectiveness', 1.0)
restore_amount = int(item_def.effects['stamina_restore'] * item_effectiveness)
new_stamina = min(current_player['max_stamina'], current_player['stamina'] + restore_amount)
actual_restore = new_stamina - current_player['stamina']
if actual_restore > 0:
await db.update_player_stamina(current_player['id'], new_stamina)
effects_applied.append(f"Restored {actual_restore} stamina")
messages.append(create_combat_message("item_restore", origin="player", stat="stamina", amount=actual_restore))
if inv_item['quantity'] > 1:
await db.update_inventory_quantity(inv_item['id'], inv_item['quantity'] - 1)
else:
await db.remove_from_inventory(inv_item['id'])
messages.append(create_combat_message(
"use_item", origin="player", item_name=item_name,
message=get_game_message('used_item_text', locale, name=item_name) + " (" + ", ".join(effects_applied) + ")"
))
last_action_text = f"{current_player['name']} used {item_name}!"
# End of turn swap
updates = {
'turn': 'defender' if is_attacker else 'attacker',
'turn_started_at': time.time(),
'last_action': f"{last_action_text}|{time.time()}"
}
await db.update_pvp_combat(pvp_combat['id'], updates)
"""
content = content.replace(
" elif req.action == 'flee':",
skill_and_item_code + "\n elif req.action == 'flee':"
)
# Indent the blocks bounded by the combat_over condition
lines = content.split('\\n')
inside_combat_over = False
new_lines = []
for line in lines:
new_lines.append(line)
# Since doing line manipulation might be tricky via replace string,
# we need to be very precise. We will apply the wrapper around attack and flee logic inside the Python script by using Regex.
# Well, wait, I can just write out the fully rewritten function or use `re` substitution for indenting.
# It is simpler to just ensure all attack and flee actions are under `if not combat_over:`
# by indenting the whole block manually in the script.
try:
with open('/opt/dockers/echoes_of_the_ashes/api/routers/combat.py', 'w', encoding='utf-8') as f:
f.write(content)
print("Script ran. Patched combat.py.")
except Exception as e:
print(e)