5 Commits

67 changed files with 4026 additions and 136 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*

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
# ========================================================================
@@ -2937,3 +2947,47 @@ async def acknowledge_pvp_combat(combat_id: int, player_id: int) -> bool:
await session.commit()
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 = []
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.stats import calculate_derived_stats
from api.items import items_manager as ITEMS_MANAGER
inventory = await db.get_inventory(player_id)
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)
for item_id, quantity in outcome.items_reward.items():
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)
items_dropped.append(f"{emoji} {item_name}")
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_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:
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
inventory = await db.get_inventory(player_id)
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)
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']
new_hp = min(player['max_hp'], old_hp + hp_restore)
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")
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']
new_stamina = min(player['max_stamina'], old_stamina + stamina_restore)
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
)
# 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 {
"leveled_up": True,
"new_level": current_level,
@@ -588,7 +617,7 @@ def generate_npc_intent(npc_def, combat_state: dict) -> dict:
return intent
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -> Tuple[List[dict], bool]:
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, player_stats: dict = None) -> Tuple[List[dict], bool]:
"""
Execute NPC turn based on PREVIOUS intent, then generate NEXT intent.
Returns: (messages_list, player_defeated)
@@ -603,21 +632,38 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
npc_hp = combat['npc_hp']
npc_max_hp = combat['npc_max_hp']
npc_status_str = combat.get('npc_status_effects', '')
is_stunned = False
if npc_status_str:
# Parse status: "bleeding:5:3" (name:dmg:ticks) or multiple "bleeding:5:3|burning:2:2"
# Parse status: "bleeding:5:3" (name:dmg:ticks) or "stun:1"
# Handling multiple effects separated by |
effects_list = npc_status_str.split('|')
active_effects = []
npc_damage_taken = 0
npc_healing_received = 0
is_stunned = False
for effect_str in effects_list:
if not effect_str: continue
try:
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=f"💫 {npc_def.name} is stunned and cannot act!"
))
ticks -= 1
if ticks > 0:
active_effects.append(f"stun:{ticks}")
continue
if len(parts) >= 3:
name = parts[0]
dmg = int(parts[1])
ticks = int(parts[2])
@@ -698,7 +744,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
actual_damage = 0
# EXECUTE INTENT
if npc_hp > 0: # Only attack if alive
if npc_hp > 0 and not is_stunned: # Only attack if alive and not stunned
if intent_type == 'defend':
# NPC defends - heals 5% HP
heal_amount = int(combat['npc_max_hp'] * 0.05)
@@ -765,28 +811,65 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
# 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:
# Check for dodge
dodged = False
if player_stats and 'dodge_chance' in player_stats:
if random.random() < player_stats['dodge_chance']:
dodged = True
messages.append(create_combat_message(
"item_broken",
origin="player",
item_name=armor['name'],
emoji=armor['emoji']
"combat_dodge",
origin="player"
))
# Prevent damage calculation
actual_damage = 0
new_player_hp = player['hp']
# Check for block (if shield is equipped)
blocked = False
if not dodged and player_stats and player_stats.get('has_shield', False):
if random.random() < player_stats.get('block_chance', 0):
blocked = True
messages.append(create_combat_message(
"combat_block",
origin="player"
))
# Apply blocked effect (damage reduced significantly or nullified)
npc_damage = max(1, int(npc_damage * 0.2)) # Block mitigates 80% damage
await db.update_player(player_id, hp=new_player_hp)
if not dodged:
# Calculate armor durability loss based on PRE-reduction damage
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
# If player_stats provides a percentage reduction, apply it instead of raw absorption
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)))
# Still show "armor_absorbed" conceptually for UI logs, though it's % based now
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)
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 and not dodged:
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

View File

@@ -31,6 +31,8 @@ class Item:
tier: int = 1 # Item tier (1-5)
encumbrance: int = 0 # Encumbrance penalty when equipped
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
repairable: bool = False # Can this item be repaired?
repair_materials: list = None # Materials needed for repair
@@ -72,6 +74,8 @@ class Item:
self.uncraft_tools = []
if self.combat_effects is None:
self.combat_effects = {}
if self.equip_requirements is None:
self.equip_requirements = {}
class ItemsManager:
@@ -139,7 +143,9 @@ class ItemsManager:
uncraft_tools=item_data.get('uncraft_tools', []),
combat_usable=item_data.get('combat_usable', is_consumable), # Default: consumables are combat usable
combat_only=item_data.get('combat_only', False),
combat_effects=item_data.get('combat_effects', {})
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

View File

@@ -186,7 +186,7 @@ except Exception as e:
# Initialize routers with game data dependencies
game_routes.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
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)
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
statistics.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)

View File

@@ -250,6 +250,10 @@ async def combat_action(
player = current_user # current_user is already the character dict
npc_def = NPCS.get(combat['npc_id'])
# Get player derived stats
from ..services.stats import calculate_derived_stats
stats = await calculate_derived_stats(player['id'], redis_manager)
messages = []
combat_over = False
@@ -290,7 +294,7 @@ async def combat_action(
elif total_impact < 0:
# HEALING
heal = abs(total_impact)
new_hp = min(player['max_hp'], player['hp'] + heal)
new_hp = min(stats['max_hp'], player['hp'] + heal)
actual_heal = new_hp - player['hp']
if actual_heal > 0:
@@ -307,26 +311,20 @@ async def combat_action(
if req.action == 'attack':
# Calculate player damage
base_damage = 5
strength_bonus = player['strength'] // 2
level_bonus = player['level']
weapon_damage = 0
# Calculate player damage using derived stats
base_damage = stats.get('attack_power', 5)
weapon_effects = {}
weapon_inv_id = None
# Check for equipped weapon
# Check for equipped weapon to apply durability loss and effects
# (Attack power from the weapon is already included in stats['attack_power'])
equipment = await db.get_all_equipment(player['id'])
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)
)
if weapon_def:
weapon_effects = weapon_def.weapon_effects if hasattr(weapon_def, 'weapon_effects') else {}
weapon_inv_id = weapon_slot['item_id']
@@ -339,7 +337,7 @@ async def combat_action(
attack_failed = True
variance = random.randint(-2, 2)
damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
damage = max(1, base_damage + variance)
if attack_failed:
messages.append(create_combat_message(
@@ -349,12 +347,31 @@ async def combat_action(
))
new_npc_hp = combat['npc_hp']
else:
# Check for critical hit
is_critical = False
crit_chance = stats.get('crit_chance', 0.05)
if random.random() < crit_chance:
is_critical = True
damage = int(damage * stats.get('crit_damage', 1.5))
# Apply NPC defense reduction
npc_defense = getattr(npc_def, 'defense', 0)
actual_damage = max(1, damage - npc_defense)
# Apply damage to NPC
new_npc_hp = max(0, combat['npc_hp'] - damage)
new_npc_hp = max(0, combat['npc_hp'] - actual_damage)
if is_critical:
messages.append(create_combat_message(
"combat_crit",
origin="player"
))
messages.append(create_combat_message(
"player_attack",
origin="player",
damage=damage
damage=actual_damage,
armor_absorbed=npc_defense if npc_defense > 0 else 0
))
# Apply weapon effects
@@ -715,36 +732,278 @@ async def combat_action(
await db.update_player_statistics(player['id'], failed_flees=1, damage_taken=npc_damage, increment=True)
await db.update_combat(player['id'], {'turn': 'player', 'turn_started_at': time.time()})
elif req.action == 'defend':
# Apply "defending" status effect - reduces incoming damage by 50% for 1 turn
await db.add_effect(
player_id=player['id'],
effect_name='defending',
effect_icon='🛡️',
effect_type='buff',
value=50, # 50% damage reduction
ticks_remaining=1,
persist_after_combat=False,
source='action:defend'
)
elif req.action == 'skill':
# ── SKILL ACTION ──
if not req.skill_id:
raise HTTPException(status_code=400, detail="skill_id required for skill action")
messages.append(create_combat_message(
"defend",
origin="player",
message=get_game_message('defend_text', locale, name=player['name'])
))
from ..services.skills import skills_manager
skill = skills_manager.get_skill(req.skill_id)
if not skill:
raise HTTPException(status_code=404, detail="Skill not found")
# NPC's turn after defend
npc_attack_messages, player_defeated = await game_logic.npc_attack(
player['id'],
{'npc_hp': combat['npc_hp'], 'npc_max_hp': combat['npc_max_hp']},
npc_def,
reduce_armor_durability
)
messages.extend(npc_attack_messages)
if player_defeated:
await db.remove_non_persistent_effects(player['id'])
# Check unlocked
stat_val = player.get(skill.stat_requirement, 0)
if stat_val < skill.stat_threshold or player['level'] < skill.level_requirement:
raise HTTPException(status_code=400, detail="Skill not unlocked")
# Check cooldown
active_effects = await db.get_player_effects(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 player['stamina'] < skill.stamina_cost:
raise HTTPException(status_code=400, detail="Not enough stamina")
# Deduct stamina
new_stamina = player['stamina'] - skill.stamina_cost
await db.update_player_stamina(player['id'], new_stamina)
player['stamina'] = new_stamina
# Add cooldown effect
if skill.cooldown > 0:
await db.add_effect(
player_id=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(player['id'])
weapon_damage = 0
inv_item = None
weapon_inv_id = 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_npc_hp = combat['npc_hp']
combat_over = False
player_won = False
# ── Damage skills ──
if 'damage_multiplier' in effects:
base_damage = 5
strength_bonus = int(player['strength'] * 1.5)
level_bonus = player['level']
variance = random.randint(-2, 2)
raw_damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
multiplier = effects['damage_multiplier']
# Execute check
if 'execute_threshold' in effects:
npc_hp_pct = combat['npc_hp'] / combat['npc_max_hp'] if combat['npc_max_hp'] > 0 else 1
if npc_hp_pct <= effects['execute_threshold']:
multiplier = effects.get('execute_multiplier', multiplier)
# Exploit Weakness check
if effects.get('requires_analyzed'):
# Check if NPC has been analyzed this combat
analyzed = combat.get('npc_status_effects', '') or ''
if 'analyzed' not in analyzed:
multiplier = 1.0 # No bonus if not analyzed
damage = max(1, int(raw_damage * multiplier))
# Guaranteed crit
if effects.get('guaranteed_crit'):
damage = int(damage * 1.5)
# Multi-hit
num_hits = effects.get('hits', 1)
total_damage = 0
for hit in range(num_hits):
hit_dmg = damage if hit == 0 else max(1, int(raw_damage * multiplier))
# Armor penetration
npc_defense = getattr(npc_def, 'defense', 0)
if 'armor_penetration' in effects:
npc_defense = int(npc_defense * (1 - effects['armor_penetration']))
actual_hit = max(1, hit_dmg - npc_defense)
total_damage += actual_hit
new_npc_hp = max(0, new_npc_hp - actual_hit)
messages.append(create_combat_message(
"skill_attack",
origin="player",
damage=total_damage,
skill_name=skill.name,
skill_icon=skill.icon,
hits=num_hits
))
# Lifesteal
if 'lifesteal' in effects:
heal_amount = int(total_damage * effects['lifesteal'])
new_hp = min(player['max_hp'], player['hp'] + heal_amount)
if new_hp > player['hp']:
await db.update_player_hp(player['id'], new_hp)
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:
poison_str = f"poison:{effects['poison_damage']}:{effects['poison_duration']}"
existing = combat.get('npc_status_effects', '') or ''
if existing:
existing += '|' + poison_str
else:
existing = poison_str
await db.update_combat(player['id'], {'npc_status_effects': existing})
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']:
messages.append(create_combat_message(
"skill_effect", origin="player", message="💫 Stunned!"
))
# 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(player['id'], 'weapon')
# ── Heal skills ──
if 'heal_percent' in effects:
heal_amount = int(player['max_hp'] * effects['heal_percent'])
new_hp = min(player['max_hp'], player['hp'] + heal_amount)
actual = new_hp - player['hp']
if actual > 0:
await db.update_player_hp(player['id'], new_hp)
player['hp'] = new_hp
messages.append(create_combat_message(
"skill_heal", origin="player", heal=actual, skill_name=skill.name, skill_icon=skill.icon
))
# ── Stamina restore skills ──
if 'stamina_restore_percent' in effects:
restore = int(player['max_stamina'] * effects['stamina_restore_percent'])
new_stam = min(player['max_stamina'], player['stamina'] + restore)
actual = new_stam - player['stamina']
if actual > 0:
await db.update_player_stamina(player['id'], new_stam)
player['stamina'] = new_stam
messages.append(create_combat_message(
"skill_effect", origin="player", message=f"⚡ +{actual} Stamina"
))
# ── Buff skills ──
if 'buff' in effects:
buff_name_raw = effects['buff']
duration = effects.get('buff_duration', 2)
value = 0
if 'damage_reduction' in effects:
value = int(effects['damage_reduction'] * 100)
elif 'damage_bonus' in effects:
value = int(effects['damage_bonus'] * 100)
await db.add_effect(
player_id=player['id'],
effect_name=buff_name_raw,
effect_icon=skill.icon,
effect_type='buff',
value=value,
ticks_remaining=duration,
persist_after_combat=False,
source=f'skill:{skill.id}'
)
messages.append(create_combat_message(
"skill_buff", origin="player",
skill_name=skill.name, skill_icon=skill.icon, duration=duration
))
# ── Analyze skill ──
if effects.get('mark_analyzed'):
existing = combat.get('npc_status_effects', '') or ''
if 'analyzed' not in existing:
if existing:
existing += '|analyzed:0:99'
else:
existing = 'analyzed:0:99'
await db.update_combat(player['id'], {'npc_status_effects': existing})
npc_hp_pct = int((combat['npc_hp'] / combat['npc_max_hp']) * 100) if combat['npc_max_hp'] > 0 else 0
intent = combat.get('npc_intent', 'attack')
messages.append(create_combat_message(
"skill_analyze", origin="player",
skill_icon=skill.icon,
npc_name=npc_def.name,
npc_hp_pct=npc_hp_pct,
npc_intent=intent
))
# Check NPC death
if new_npc_hp <= 0:
messages.append(create_combat_message("victory", origin="neutral", npc_name=npc_def.name))
combat_over = True
player_won = True
# Award XP
xp_reward = npc_def.xp_reward
current_xp = player['xp'] + xp_reward
await db.update_player(player['id'], xp=current_xp)
player['xp'] = current_xp
messages.append(create_combat_message("xp_gained", origin="neutral", xp=xp_reward))
await db.update_player_statistics(player['id'], enemies_killed=1, increment=True)
# Level up check
level_result = await game_logic.check_and_apply_level_up(player['id'])
if level_result['leveled_up']:
messages.append(create_combat_message("level_up", origin="neutral", new_level=level_result['new_level']))
# Loot
loot_items = npc_def.loot if hasattr(npc_def, 'loot') else []
generated_loot = []
if loot_items:
for loot in loot_items:
if random.random() < loot.get('chance', 1.0):
qty = random.randint(loot.get('min', 1), loot.get('max', 1))
# Only append message in combat log, actual items are in corpse
messages.append(create_combat_message("loot", origin="neutral", item_id=loot['item_id'], quantity=qty))
generated_loot.append({"item_id": loot['item_id'], "quantity": qty})
# Create corpse
import json
await db.create_npc_corpse(
combat['npc_id'],
combat.get('location_id', player.get('location_id', '')),
json.dumps(generated_loot)
)
await db.remove_non_persistent_effects(player['id'])
messages.extend(npc_attack_messages)
if player_defeated:
await db.remove_non_persistent_effects(player['id'])
combat_over = True
elif req.action == 'use_item':
combat_over = False
@@ -791,9 +1050,10 @@ async def combat_action(
# 1. Apply Status Effects (e.g. Regeneration from Bandage)
if item_def.effects.get('status_effect'):
status_data = item_def.effects['status_effect']
status_name = status_data['name']
await db.add_effect(
player_id=player['id'],
effect_name=status_data['name'],
effect_name=status_name,
effect_icon=status_data.get('icon', ''),
effect_type=status_data.get('type', 'buff'),
damage_per_tick=status_data.get('damage_per_tick', 0),
@@ -813,7 +1073,10 @@ async def combat_action(
# 3. Handle Direct healing (legacy/instant)
if item_def.effects.get('hp_restore') and item_def.effects['hp_restore'] > 0:
hp_restore = item_def.effects['hp_restore']
item_effectiveness = stats.get('item_effectiveness', 1.0)
base_hp_restore = item_def.effects['hp_restore']
hp_restore = int(base_hp_restore * item_effectiveness)
old_hp = player['hp']
new_hp = min(player.get('max_hp', 100), old_hp + hp_restore)
actual_restored = new_hp - old_hp
@@ -821,8 +1084,11 @@ async def combat_action(
await db.update_player_hp(player['id'], new_hp)
effects_applied.append(f"+{actual_restored} HP")
if item_def.effects.get('stamina_restore'):
stamina_restore = item_def.effects['stamina_restore']
if item_def.effects.get('stamina_restore') and item_def.effects['stamina_restore'] > 0:
item_effectiveness = stats.get('item_effectiveness', 1.0)
base_stamina_restore = item_def.effects['stamina_restore']
stamina_restore = int(base_stamina_restore * item_effectiveness)
old_stamina = player['stamina']
new_stamina = min(player.get('max_stamina', 100), old_stamina + stamina_restore)
actual_restored = new_stamina - old_stamina
@@ -1259,6 +1525,12 @@ async def pvp_combat_action(
current_player = attacker if is_attacker else defender
opponent = defender if is_attacker else attacker
# Get derived stats for both players
from ..services.stats import calculate_derived_stats
current_player_stats = await calculate_derived_stats(current_player['id'], redis_manager)
# Opponent stats won't be used for attack calculation but could be used for defense logic
# opponent_stats = await calculate_derived_stats(opponent['id'], redis_manager)
messages = []
combat_over = False
winner_id = None
@@ -1267,24 +1539,18 @@ async def pvp_combat_action(
last_action_text = ""
if req.action == 'attack':
# Calculate damage (similar to PvE)
base_damage = 5
strength_bonus = current_player['strength'] * 2
level_bonus = current_player['level']
# Calculate damage (unified formula with derived stats)
base_damage = current_player_stats.get('attack_power', 5)
# Check for equipped weapon
weapon_damage = 0
# Check for equipped weapon to apply durability loss
# (Attack power from the weapon is already included in stats['attack_power'])
equipment = await db.get_all_equipment(current_player['id'])
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)
)
if weapon_def:
# Decrease weapon durability
if inv_item.get('unique_item_id'):
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
@@ -1297,17 +1563,31 @@ async def pvp_combat_action(
await db.unequip_item(current_player['id'], 'weapon')
variance = random.randint(-2, 2)
damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
damage = max(1, base_damage + variance)
# Check for critical hit
is_critical = False
crit_chance = current_player_stats.get('crit_chance', 0.05)
if random.random() < crit_chance:
is_critical = True
damage = int(damage * current_player_stats.get('crit_damage', 1.5))
# Apply armor reduction and durability loss to opponent
armor_absorbed, broken_armor = await reduce_armor_durability(opponent['id'], damage)
actual_damage = max(1, damage - armor_absorbed)
if is_critical:
messages.append(create_combat_message(
"combat_crit",
origin="player"
))
last_action_text += f"\nCritical Hit! "
# Structure the attack message
messages.append(create_combat_message(
"player_attack",
origin="player",
damage=damage,
damage=actual_damage,
armor_absorbed=armor_absorbed
))

View File

@@ -179,6 +179,11 @@ async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get
if not player:
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 = LOCATIONS.get(location_id)
@@ -287,11 +292,13 @@ async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get
if hasattr(item_def, 'durability') and item_def.durability:
# This is a unique item - generate random stats
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
durability_percent = (random_durability / base_durability)
# Random durability: 90-110% of base, plus crafting_bonus (e.g. +0.05 from Intellect)
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:
tier = 5 # Gold
elif durability_percent >= 1.04:
@@ -308,8 +315,9 @@ async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get
if hasattr(item_def, 'stats') and item_def.stats:
for stat_key, stat_value in item_def.stats.items():
if isinstance(stat_value, (int, float)):
# Random stat: 90-110% of base
random_stats[stat_key] = int(stat_value * random.uniform(0.9, 1.1))
# Random stat: same multiplier logic applied to base stats
stat_percent = random.uniform(0.9, 1.1) + crafting_bonus
random_stats[stat_key] = int(stat_value * stat_percent)
else:
random_stats[stat_key] = stat_value

View File

@@ -24,13 +24,15 @@ logger = logging.getLogger(__name__)
LOCATIONS = None
ITEMS_MANAGER = 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"""
global LOCATIONS, ITEMS_MANAGER, WORLD
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
LOCATIONS = locations
ITEMS_MANAGER = items_manager
WORLD = world
redis_manager = redis_mgr
router = APIRouter(tags=["equipment"])
@@ -62,6 +64,25 @@ async def equip_item(
if not item_def.equippable or not item_def.slot:
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
valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack']
if item_def.slot not in valid_slots:
@@ -113,6 +134,10 @@ async def equip_item(
else:
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 {
"success": True,
"message": message,
@@ -192,6 +217,10 @@ async def unequip_item(
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)
# Invalidate cached derived stats
from ..services.stats import invalidate_stats_cache
await invalidate_stats_cache(player_id, redis_manager)
return {
"success": True,
"message": get_game_message('unequip_dropped', locale, item=get_locale_string(item_def.name, locale)),
@@ -202,6 +231,10 @@ async def unequip_item(
await db.unequip_item(player_id, unequip_req.slot)
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 {
"success": True,
"message": get_game_message('unequipped', locale, item=get_locale_string(item_def.name, locale)),

View File

@@ -17,6 +17,7 @@ from .. import database as db
from ..items import ItemsManager
from .. import game_logic
from ..core.websockets import manager
from ..services.stats import STAT_CAP, invalidate_stats_cache
logger = logging.getLogger(__name__)
@@ -457,19 +458,28 @@ async def spend_stat_point(
if stat not in 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_data = {
stat: player[stat] + 1,
'unspent_points': player['unspent_points'] - 1
}
# Endurance increases max HP
# Endurance increases max HP and max stamina
if stat == 'endurance':
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['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)
# Invalidate cached derived stats
await invalidate_stats_cache(current_user['id'], redis_manager)
return {
"success": True,
"message": f"Increased {stat} by 1!",
@@ -1550,4 +1560,139 @@ async def drop_item(
return {
"success": True,
"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)
# 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,
},
"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

@@ -195,9 +195,13 @@ async def loot_corpse(
# Parse corpse ID
corpse_type, corpse_db_id = req.corpse_id.split('_', 1)
corpse_db_id = int(corpse_db_id)
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
inventory = await db.get_inventory(player['id'])
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
@@ -246,7 +250,6 @@ async def loot_corpse(
success, error_msg, tools_consumed = await consume_tool_durability(player['id'], tool_req, inventory)
if not success:
raise HTTPException(status_code=400, detail=error_msg)
# Determine quantity
quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max'])
@@ -254,6 +257,13 @@ async def loot_corpse(
# Check if item fits in inventory
item_def = ITEMS_MANAGER.get_item(loot_item['item_id'])
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_volume = item_def.volume * quantity
@@ -305,11 +315,16 @@ async def loot_corpse(
if can_loot:
# Can loot this item
quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max'])
if quantity > 0:
# Check if item fits in inventory
item_def = ITEMS_MANAGER.get_item(loot_item['item_id'])
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_volume = item_def.volume * quantity

View File

@@ -72,6 +72,8 @@ GAME_MESSAGES = {
'unequipped': {'en': "Unequipped {item}", 'es': "Desequipado {item}"},
'unequip_dropped': {'en': "Unequipped {item} (dropped to ground - inventory full)", 'es': "Desequipado {item} (tirado al suelo - inventario lleno)"},
'repaired_success': {'en': "Repaired {item}! Restored {amount} durability.", 'es': "¡Reparado {item}! Restaurados {amount} puntos de durabilidad."},
'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
'character_created': {'en': "Character created successfully", 'es': "Personaje creado con éxito"},

View File

@@ -79,8 +79,9 @@ class InitiateCombatRequest(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
skill_id: Optional[str] = None # For skill action
class PvPCombatInitiateRequest(BaseModel):

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

@@ -0,0 +1,178 @@
"""
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():
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()

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

@@ -0,0 +1,225 @@
"""
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()
equipment = await db.get_all_equipment(character_id)
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, 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
# Get inventory item to find the item definition
inv_item_sync = item_data # equipment dict already has item_id reference
item_def = ITEMS_MANAGER.get_item(inv_item_sync.get('item_id', ''))
# Try to get item_id from the inventory item if the direct lookup failed
if not item_def:
continue
if item_def.stats:
total_armor += item_def.stats.get('armor', 0)
weapon_crit += item_def.stats.get('crit_chance', 0)
if slot == 'weapon':
weapon_damage_min = item_def.stats.get('damage_min', 0)
weapon_damage_max = item_def.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
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)))

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 turn (min 1)",
"es": "Los efectos de estado duran 1 turno 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
}
}
}
}

332
gamedata/skills.json Normal file
View File

@@ -0,0 +1,332 @@
{
"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 turns, but +25% damage taken",
"es": "+50% de daño durante 3 turnos, 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 (3 dmg/turn for 4 turns)",
"es": "80% de daño + veneno (3 de daño/turno durante 4 turnos)"
},
"icon": "🧪",
"stat_requirement": "agility",
"stat_threshold": 25,
"level_requirement": 20,
"cooldown": 5,
"stamina_cost": 6,
"effects": {
"damage_multiplier": 0.8,
"poison_damage": 3,
"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 turns",
"es": "Reduce el daño recibido en un 60% durante 2 turnos"
},
"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 turns",
"es": "Inmune a efectos de estado durante 3 turnos"
},
"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
}
}
}
}

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: 140 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 { QuestJournal } from './game/QuestJournal'
import { CharacterSheet } from './game/CharacterSheet'
import GameHeader from './GameHeader'
import './Game.css'
@@ -18,6 +19,7 @@ function Game() {
const [token] = useState(() => localStorage.getItem('token'))
const [showQuestJournal, setShowQuestJournal] = useState(false)
const [showCharacterSheet, setShowCharacterSheet] = useState(false)
// Handle WebSocket messages
const handleWebSocketMessage = async (message: any) => {
@@ -527,6 +529,7 @@ function Game() {
}}
onSpendPoint={actions.handleSpendPoint}
onOpenQuestJournal={() => setShowQuestJournal(true)}
onOpenCharacterSheet={() => setShowCharacterSheet(true)}
/>
)}
</div>
@@ -595,6 +598,13 @@ function Game() {
{showQuestJournal && (
<QuestJournal onClose={() => setShowQuestJournal(false)} />
)}
{showCharacterSheet && (
<CharacterSheet
onClose={() => setShowCharacterSheet(false)}
onSpendPoint={actions.handleSpendPoint}
/>
)}
</div>
</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

@@ -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;
align?: 'left' | 'right';
labelAlignment?: 'left' | 'right';
customColor?: string;
}
export const GameProgressBar: React.FC<GameProgressBarProps> = ({
@@ -22,7 +23,8 @@ export const GameProgressBar: React.FC<GameProgressBarProps> = ({
unit = '',
height = '8px',
align = 'left',
labelAlignment
labelAlignment,
customColor
}) => {
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
const getGradient = () => {
if (customColor) return customColor;
switch (type) {
// InventoryModal.css defines .weight and .volume gradients
// 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.hp_bonus) && (
<span className="stat-badge health">
+{stats.hp_bonus} {t('stats.hpMax')}
</span>
)}
{(stats.stamina_bonus) && (
<span className="stat-badge stamina">
+{stats.stamina_bonus} {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,412 @@
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 './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[];
};
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 } = 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>
{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)}</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)}</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

@@ -495,6 +495,46 @@ export const Combat: React.FC<CombatProps> = ({
case 'quest_update':
addNotification(data.message || 'Quest Progress', 'quest');
break;
// ── Skill messages ──
case 'skill_attack':
triggerAnim('playerAttacking');
triggerAnim('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', 'enemy');
}
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]);
@@ -740,6 +780,7 @@ export const Combat: React.FC<CombatProps> = ({
onClose={handleCloseWrapper}
onShowSupplies={() => setShowSuppliesModal(true)}
isProcessing={isProcessingQueue}
playerStamina={playerState?.stamina || 0}
combatResult={combatResult}
equipment={_equipment}
playerName={profile?.name}

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getTranslatedText } from '../../utils/i18nUtils';
import { useAudio } from '../../contexts/AudioContext';
@@ -7,6 +7,8 @@ import { Equipment } from './types';
import './CombatEffects.css';
import { GameProgressBar } from '../common/GameProgressBar';
import { GameButton } from '../common/GameButton';
import { GameDropdown } from '../common/GameDropdown';
import api from '../../services/api';
interface CombatViewProps {
state: CombatState;
@@ -16,6 +18,7 @@ interface CombatViewProps {
onClose: () => void;
onShowSupplies: () => void;
isProcessing: boolean;
playerStamina: number;
combatResult: 'victory' | 'defeat' | 'fled' | null;
equipment?: Equipment | any;
playerName?: string;
@@ -30,6 +33,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
onClose,
onShowSupplies,
isProcessing,
playerStamina,
combatResult,
equipment,
playerName,
@@ -257,7 +261,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
)}
{!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: '1fr 1fr', gap: '0.5rem', width: '100%', maxWidth: '400px', margin: '0 auto' }}>
<GameButton
variant="danger"
onClick={() => onAction('attack')}
@@ -266,13 +270,11 @@ export const CombatView: React.FC<CombatViewProps> = ({
👊 {t('combat.actions.attack')}
</GameButton>
<GameButton
variant="primary"
onClick={() => onAction('defend')}
<AbilitiesDropdown
onAction={onAction}
disabled={isProcessing || !state.yourTurn}
>
🛡 {t('combat.actions.defend')}
</GameButton>
playerStamina={playerStamina}
/>
<GameButton
variant="secondary"
@@ -377,3 +379,108 @@ export const CombatView: React.FC<CombatViewProps> = ({
</div >
);
};
// ─── Abilities Dropdown ───
interface SkillInfo {
id: string;
name: 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', '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 (
<GameButton
key={s.id}
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: '#e53e3e', fontSize: '0.65rem', fontWeight: 'bold' }}> {s.current_cooldown}T</span>
) : (
<span style={{ color: notEnoughStamina ? '#e53e3e' : '#a0a0b0', fontSize: '0.65rem' }}>{s.stamina_cost}</span>
)}
</div>
</GameButton>
);
})}
</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

@@ -41,6 +41,7 @@
}
.game-modal-title {
flex: 1;
margin: 0;
font-size: 1.2rem;
font-weight: 600;
@@ -104,4 +105,22 @@
/* Entity "Show All" modal - wider like inventory */
.game-modal-container.entity-show-all-modal {
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';
interface GameModalProps {
title?: string;
title?: ReactNode;
onClose: () => void;
children: ReactNode;
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-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>
</div>

View File

@@ -27,6 +27,7 @@ interface PlayerSidebarProps {
onDropItem: (itemId: number, invId: number, quantity: number) => void
onSpendPoint: (stat: string) => void
onOpenQuestJournal: () => void
onOpenCharacterSheet: () => void
}
function PlayerSidebar({
@@ -43,7 +44,8 @@ function PlayerSidebar({
onUnequipItem,
onDropItem,
onSpendPoint,
onOpenQuestJournal
onOpenQuestJournal,
onOpenCharacterSheet
}: PlayerSidebarProps) {
const [showInventory, setShowInventory] = useState(false)
const [activeSlot, setActiveSlot] = useState<string | null>(null)
@@ -263,6 +265,16 @@ function PlayerSidebar({
{t('game.inventory')}
</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
className={`quest-journal-btn ${hasReadyQuests ? 'pulse-gold' : ''}`}
variant={hasReadyQuests ? 'warning' : 'secondary'}
@@ -270,7 +282,7 @@ function PlayerSidebar({
onClick={onOpenQuestJournal}
style={{ flex: 1, justifyContent: 'center' }}
>
{hasReadyQuests ? '❗ ' : '📜 '}{t('common.quests')}
<span>{hasReadyQuests ? '❗' : '📜'}</span> {t('common.quests')}
</GameButton>
</div>
</div>

View File

@@ -1,7 +1,11 @@
.quest-journal-modal {
width: 90vw;
max-width: 1200px;
height: 95%;
display: flex;
flex-direction: column;
width: 95vw;
max-width: 1400px;
height: 90%;
max-height: 90%;
overflow: hidden;
}
.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 }}>
{/* 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">
<button
className={`journal-tab ${activeTab === 'active' ? 'active' : ''}`}

View File

@@ -895,7 +895,9 @@ export function useGameEngine(
const handleCombatAction = async (action: string) => {
try {
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(':')
payload = { action: act, item_id: itemId }
}

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

@@ -329,8 +329,12 @@
"enemy_attack": "Enemy hits for {{damage}} damage",
"player_miss": "You missed!",
"enemy_miss": "Enemy missed!",
"item_broken": "Your {{item}} broke!",
"xp_gain": "You gained {{xp}} XP!",
"weapon_broke": "Your {{item_name}} broke!",
"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!",
"defend": "You brace for impact!",
"item_used": "Used {{item}}",
@@ -377,6 +381,30 @@
"potentialBaseStats": "Potential base stats. Actual stats may vary.",
"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": {
"all": "All Items",
"weapon": "Weapons",

View File

@@ -327,7 +327,12 @@
"enemy_attack": "El enemigo golpea por {{damage}} de daño",
"player_miss": "¡Fallaste!",
"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_fail": "¡No pudiste escapar!",
"defend": "¡Te preparas para el impacto!",
@@ -375,6 +380,30 @@
"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."
},
"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": {
"all": "Todos los Objetos",
"weapon": "Armas",

View File

@@ -21,24 +21,50 @@ if (!isElectron) {
})
}
// Initialize Twemoji after React renders
const initTwemoji = () => {
twemoji.parse(document.body, {
folder: 'svg',
ext: '.svg',
base: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/'
});
const twemojiOpts = {
folder: 'svg',
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 }) => {
useEffect(() => {
// Initial parse
// Initial parse of entire body
initTwemoji();
// Set up MutationObserver to re-parse when DOM changes
const observer = new MutationObserver(() => {
initTwemoji();
// Collect added nodes and parse them in batches AFTER React finishes
// its synchronous render cycle. Without deferral, Twemoji replaces
// 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, {
@@ -46,7 +72,10 @@ const TwemojiWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) =
subtree: true,
});
return () => observer.disconnect();
return () => {
observer.disconnect();
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, []);
return <>{children}</>;