Compare commits
8 Commits
d38d4cc288
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8dc8211d5 | ||
|
|
d5afd28eb9 | ||
|
|
540df02ae7 | ||
|
|
bd27404941 | ||
|
|
6f9ce8b448 | ||
|
|
fd94387d54 | ||
|
|
185781d168 | ||
|
|
aa71a6be7c |
53
GEMINI.md
Normal 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
@@ -0,0 +1,164 @@
|
|||||||
|
# Visual Style Guide - Echoes of the Ash
|
||||||
|
|
||||||
|
This document defines the unified visual system for the application, ensuring a consistent, mature "videogame" aesthetic.
|
||||||
|
|
||||||
|
## 1. Core Design Philosophy
|
||||||
|
- **Aesthetic**: **Mature Post-Apocalyptic**. Think "High-Tech Scavenger".
|
||||||
|
- **Dark & Gritty**: Deep blacks (`#0a0a0a`) mixed with industrial dark greys (`#1a1a20`).
|
||||||
|
- **Sharp & Distinct**: Avoid overly rounded "Web 2.0" corners. Use chamfered corners (clip-path) for a militaristic/industrial feel.
|
||||||
|
- **Glassmorphism**: Use semi-transparent backgrounds with blur (`backdrop-filter`) to keep the player connected to the world.
|
||||||
|
- **Cinematic**: High contrast text, subtle glows on active elements, and distinct borders.
|
||||||
|
- **Interaction**: **Instant Feedback**.
|
||||||
|
- **Custom Tooltips**: **MANDATORY**. Do NOT use the HTML `title` attribute. All information must appear instantly in a custom game-styled tooltip anchor.
|
||||||
|
- **Micro-animations**: Subtle pulses, border glows on hover.
|
||||||
|
|
||||||
|
## 2. CSS Variables (Design Tokens)
|
||||||
|
Defined in `:root`.
|
||||||
|
|
||||||
|
### Colors
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Backgrounds */
|
||||||
|
--game-bg-app: #050505; /* Deepest black */
|
||||||
|
--game-bg-panel: rgba(18, 18, 24, 0.96); /* Almost solid panels */
|
||||||
|
--game-bg-glass: rgba(10, 10, 15, 0.85); /* Overlays */
|
||||||
|
--game-bg-slot: rgba(0, 0, 0, 0.5); /* Item slots - darker than panels */
|
||||||
|
--game-bg-slot-hover: rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
|
/* Borders / Separators */
|
||||||
|
--game-border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
--game-border-active: rgba(255, 255, 255, 0.4);
|
||||||
|
--game-border-highlight: #ff6b6b; /* Red accent border */
|
||||||
|
|
||||||
|
/* Corner Radius - Tighter for mature look */
|
||||||
|
--game-radius-xs: 2px;
|
||||||
|
--game-radius-sm: 4px;
|
||||||
|
--game-radius-md: 6px;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--game-font-main: 'Saira Condensed', system-ui, sans-serif;
|
||||||
|
--game-text-primary: #e0e0e0; /* Off-white is better for eyes than pure white */
|
||||||
|
--game-text-secondary: #94a3b8; /* Cool grey */
|
||||||
|
--game-text-highlight: #fbbf24; /* Amber/Gold */
|
||||||
|
--game-text-danger: #ef4444;
|
||||||
|
|
||||||
|
/* Semantic Colors (Desaturated/Industrial) */
|
||||||
|
--game-color-primary: #e11d48; /* Blood Red - Action/Health */
|
||||||
|
--game-color-stamina: #d97706; /* Amber - Stamina */
|
||||||
|
--game-color-magic: #3b82f6; /* Blue - Mana/Tech */
|
||||||
|
--game-color-success: #10b981; /* Emerald - Durability High */
|
||||||
|
--game-color-warning: #f59e0b; /* Amber - Warning */
|
||||||
|
|
||||||
|
/* Rarity Colors */
|
||||||
|
--rarity-common: #9ca3af;
|
||||||
|
--rarity-uncommon: #ffffff;
|
||||||
|
--rarity-rare: #34d399;
|
||||||
|
--rarity-epic: #60a5fa;
|
||||||
|
--rarity-legendary: #fbbf24;
|
||||||
|
|
||||||
|
/* Effects */
|
||||||
|
--game-shadow-panel: 0 8px 32px rgba(0, 0, 0, 0.8);
|
||||||
|
--game-shadow-glow: 0 0 15px rgba(225, 29, 72, 0.3); /* Subtle red glow */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Tooltip System (CRITICAL)
|
||||||
|
**Goal**: Replicate a native game HUD tooltip.
|
||||||
|
**Rule**: NEVER use `title="Description"`.
|
||||||
|
|
||||||
|
### The Component: `<GameTooltip />`
|
||||||
|
Every interactive element with info must be wrapped or associated with this component.
|
||||||
|
- **Behavior**: Appears instantly on hover (0ms delay). Follows cursor OR anchored to element.
|
||||||
|
- **Visuals**:
|
||||||
|
- Background: Solid dark (`#0f0f12`) with high opacity (98%).
|
||||||
|
- Border: Thin accent border (`1px solid --game-border-color`).
|
||||||
|
- Shadow: Strong drop shadow to separate from UI.
|
||||||
|
- Content: Supports HTML (rich text, stats, icons).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Usage Example
|
||||||
|
<GameTooltip content={<ItemStats item={sword} />}>
|
||||||
|
<button className="game-slot">
|
||||||
|
<img src="sword.png" />
|
||||||
|
</button>
|
||||||
|
</GameTooltip>
|
||||||
|
|
||||||
|
## 4. Reusable Components
|
||||||
|
Use these React components located in `pwa/src/components/common/` to ensure visual consistency.
|
||||||
|
|
||||||
|
### Buttons: `<GameButton />`
|
||||||
|
**Look**: Rectangular, tactile, with optional mecha-cut corners.
|
||||||
|
- **Variants**: `primary`, `secondary`, `success`, `danger`, `info`, `warning`.
|
||||||
|
- **Sizes**: `sm`, `md`, `lg`.
|
||||||
|
- **States**: Auto-handles hover glows and active transforms.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<GameButton variant="success" size="md" onClick={handleSave}>
|
||||||
|
Save Game
|
||||||
|
</GameButton>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tooltips: `<GameTooltip />`
|
||||||
|
**Rule**: NEVER use the HTML `title` attribute.
|
||||||
|
- **Behavior**: Appears instantly (0ms), follows cursor. Rendering via React Portal avoids z-index clipping.
|
||||||
|
- **Usage**: Wrap any interactive element.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<GameTooltip content="Drains 10 Stamina">
|
||||||
|
<button className="ability-slot">Dash</button>
|
||||||
|
</GameTooltip>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Item Content: `<ItemTooltipContent />`
|
||||||
|
Specialized sub-component for rich item data (stats, weight, durability bars).
|
||||||
|
- **Props**: `item`, `showValue` (for trading), `showDurability`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<GameTooltip content={<ItemTooltipContent item={sword} />}>
|
||||||
|
<div className="inventory-slot">...</div>
|
||||||
|
</GameTooltip>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Progress Bars: `<GameProgressBar />`
|
||||||
|
For health, stamina, XP, weight, and volume.
|
||||||
|
- **Types**: `health`, `stamina`, `xp`, `weight`, `volume`, `durability`.
|
||||||
|
- **Modes**: Supports `showText`, custom `label`, and alignment.
|
||||||
|
- **Logic**: Auto-colors (e.g., weight turns red as it nears capacity).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<GameProgressBar
|
||||||
|
value={80}
|
||||||
|
max={100}
|
||||||
|
type="health"
|
||||||
|
showText
|
||||||
|
label="Health"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dropdowns: `<GameDropdown />`
|
||||||
|
Contextual menus that render outside the DOM hierarchy.
|
||||||
|
- **Features**: Auto-positioning (flips up if near bottom), click-outside to close.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<GameDropdown isOpen={menuOpen} onClose={() => setMenuOpen(false)}>
|
||||||
|
<div className="menu-item">Inspect</div>
|
||||||
|
<div className="menu-item">Discard</div>
|
||||||
|
</GameDropdown>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notifications: `<NotificationContainer />`
|
||||||
|
Global toast system. Used via `useNotification` hook.
|
||||||
|
- **Types**: `success`, `error`, `info`, `warning`.
|
||||||
|
- **Visuals**: Slide-in/out animations, industrial styling.
|
||||||
|
|
||||||
|
## 5. UI Layout Patterns
|
||||||
|
|
||||||
|
### Panels & Containers (`.game-panel`)
|
||||||
|
Use this class for modals and sidebars.
|
||||||
|
- **Visuals**: 96% opacity dark panels, 8px blur, 1px subtle border.
|
||||||
|
|
||||||
|
### Grid Slots (`.game-slot`)
|
||||||
|
Consistent 1:1 ratio slots for items or abilities. Darker background than panels to create depth.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Updated: Feb 16, 2026*
|
||||||
26
add_boss.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
with open('gamedata/npcs.json', 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
if 'test_boss' not in data['npcs']:
|
||||||
|
data['npcs']['test_boss'] = {
|
||||||
|
'name': {'en': 'Level 50 Test Boss', 'es': 'Jefe de Prueba Nivel 50'},
|
||||||
|
'description': {'en': 'A huge terrifying monster.', 'es': 'Un monstruo enorme y aterrador.'},
|
||||||
|
'emoji': '👹',
|
||||||
|
'hp_min': 1000,
|
||||||
|
'hp_max': 1500,
|
||||||
|
'damage_min': 25,
|
||||||
|
'damage_max': 45,
|
||||||
|
'defense': 15,
|
||||||
|
'xp_reward': 500,
|
||||||
|
'loot_table': [],
|
||||||
|
'flee_chance": 0.0,
|
||||||
|
'status_inflict_chance': 0.5,
|
||||||
|
'death_message': {'en': 'The boss is defeated.', 'es': 'El jefe ha sido derrotado.'}
|
||||||
|
}
|
||||||
|
with open('gamedata/npcs.json', 'w') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
print("Boss added.")
|
||||||
|
else:
|
||||||
|
print("Boss exists.")
|
||||||
@@ -308,6 +308,16 @@ player_statistics = Table(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
character_perks = Table(
|
||||||
|
"character_perks",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
Column("perk_id", String(50), nullable=False),
|
||||||
|
Column("acquired_at", Float, nullable=False),
|
||||||
|
UniqueConstraint("character_id", "perk_id", name="uix_character_perk")
|
||||||
|
)
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# QUESTS AND TRADE TABLES
|
# QUESTS AND TRADE TABLES
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
@@ -2937,3 +2947,47 @@ async def acknowledge_pvp_combat(combat_id: int, player_id: int) -> bool:
|
|||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# CHARACTER PERKS
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def get_character_perks(character_id: int) -> list:
|
||||||
|
"""Get all perks owned by a character."""
|
||||||
|
async with DatabaseSession() as session:
|
||||||
|
stmt = select(character_perks).where(
|
||||||
|
character_perks.c.character_id == character_id
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return [dict(row._mapping) for row in result.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
async def add_character_perk(character_id: int, perk_id: str) -> bool:
|
||||||
|
"""Add a perk to a character. Returns False if already owned."""
|
||||||
|
import time
|
||||||
|
async with DatabaseSession() as session:
|
||||||
|
try:
|
||||||
|
stmt = insert(character_perks).values(
|
||||||
|
character_id=character_id,
|
||||||
|
perk_id=perk_id,
|
||||||
|
acquired_at=time.time()
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_character_perk(character_id: int, perk_id: str) -> bool:
|
||||||
|
"""Remove a perk from a character."""
|
||||||
|
async with DatabaseSession() as session:
|
||||||
|
stmt = delete(character_perks).where(
|
||||||
|
character_perks.c.character_id == character_id,
|
||||||
|
character_perks.c.perk_id == perk_id
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
|||||||
@@ -208,12 +208,17 @@ async def interact_with_object(
|
|||||||
items_dropped = []
|
items_dropped = []
|
||||||
damage_taken = outcome.damage_taken
|
damage_taken = outcome.damage_taken
|
||||||
|
|
||||||
# Calculate current capacity
|
# Calculate current capacity and fetch derived stats
|
||||||
from api.services.helpers import calculate_player_capacity
|
from api.services.helpers import calculate_player_capacity
|
||||||
|
from api.services.stats import calculate_derived_stats
|
||||||
from api.items import items_manager as ITEMS_MANAGER
|
from api.items import items_manager as ITEMS_MANAGER
|
||||||
|
|
||||||
inventory = await db.get_inventory(player_id)
|
inventory = await db.get_inventory(player_id)
|
||||||
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
|
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
|
||||||
|
|
||||||
|
stats = await calculate_derived_stats(player_id)
|
||||||
|
loot_quality = stats.get('loot_quality', 1.0)
|
||||||
|
|
||||||
# Add items to inventory (or drop if over capacity)
|
# Add items to inventory (or drop if over capacity)
|
||||||
for item_id, quantity in outcome.items_reward.items():
|
for item_id, quantity in outcome.items_reward.items():
|
||||||
item = items_manager.get_item(item_id)
|
item = items_manager.get_item(item_id)
|
||||||
@@ -258,7 +263,12 @@ async def interact_with_object(
|
|||||||
await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id)
|
await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id)
|
||||||
items_dropped.append(f"{emoji} {item_name}")
|
items_dropped.append(f"{emoji} {item_name}")
|
||||||
else:
|
else:
|
||||||
# Stackable items - process as before
|
# Stackable items - apply loot quality bonus for resources and consumables
|
||||||
|
if getattr(item, 'category', item.type) in ['resource', 'consumable'] and loot_quality > 1.0:
|
||||||
|
bonus_chance = loot_quality - 1.0
|
||||||
|
if random.random() < bonus_chance:
|
||||||
|
quantity += 1
|
||||||
|
|
||||||
item_weight = item.weight * quantity
|
item_weight = item.weight * quantity
|
||||||
item_volume = item.volume * quantity
|
item_volume = item.volume * quantity
|
||||||
|
|
||||||
@@ -312,6 +322,15 @@ async def use_item(player_id: int, item_id: str, items_manager, locale: str = 'e
|
|||||||
if not player:
|
if not player:
|
||||||
return {"success": False, "message": "Player not found"}
|
return {"success": False, "message": "Player not found"}
|
||||||
|
|
||||||
|
# Get derived stats for item effectiveness
|
||||||
|
# In some paths redis_manager might not be injected, so we attempt to fetch it from websockets module if needed,
|
||||||
|
# or let stats service fetch without cache
|
||||||
|
from api.services.stats import calculate_derived_stats
|
||||||
|
import api.core.websockets as ws
|
||||||
|
redis_mgr = getattr(ws.manager, 'redis_manager', None)
|
||||||
|
stats = await calculate_derived_stats(player['id'], redis_mgr)
|
||||||
|
item_effectiveness = stats.get('item_effectiveness', 1.0)
|
||||||
|
|
||||||
# Check if player has the item
|
# Check if player has the item
|
||||||
inventory = await db.get_inventory(player_id)
|
inventory = await db.get_inventory(player_id)
|
||||||
item_entry = None
|
item_entry = None
|
||||||
@@ -385,7 +404,8 @@ async def use_item(player_id: int, item_id: str, items_manager, locale: str = 'e
|
|||||||
|
|
||||||
# 3. Direct Healing (Legacy/Instant)
|
# 3. Direct Healing (Legacy/Instant)
|
||||||
if 'hp_restore' in item.effects:
|
if 'hp_restore' in item.effects:
|
||||||
hp_restore = item.effects['hp_restore']
|
base_hp_restore = item.effects['hp_restore']
|
||||||
|
hp_restore = int(base_hp_restore * item_effectiveness)
|
||||||
old_hp = player['hp']
|
old_hp = player['hp']
|
||||||
new_hp = min(player['max_hp'], old_hp + hp_restore)
|
new_hp = min(player['max_hp'], old_hp + hp_restore)
|
||||||
actual_restored = new_hp - old_hp
|
actual_restored = new_hp - old_hp
|
||||||
@@ -395,7 +415,8 @@ async def use_item(player_id: int, item_id: str, items_manager, locale: str = 'e
|
|||||||
effects_msg.append(f"+{actual_restored} HP")
|
effects_msg.append(f"+{actual_restored} HP")
|
||||||
|
|
||||||
if 'stamina_restore' in item.effects:
|
if 'stamina_restore' in item.effects:
|
||||||
stamina_restore = item.effects['stamina_restore']
|
base_stamina_restore = item.effects['stamina_restore']
|
||||||
|
stamina_restore = int(base_stamina_restore * item_effectiveness)
|
||||||
old_stamina = player['stamina']
|
old_stamina = player['stamina']
|
||||||
new_stamina = min(player['max_stamina'], old_stamina + stamina_restore)
|
new_stamina = min(player['max_stamina'], old_stamina + stamina_restore)
|
||||||
actual_restored = new_stamina - old_stamina
|
actual_restored = new_stamina - old_stamina
|
||||||
@@ -532,6 +553,14 @@ async def check_and_apply_level_up(player_id: int) -> Dict[str, Any]:
|
|||||||
unspent_points=new_unspent_points
|
unspent_points=new_unspent_points
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Invalidate cached derived stats (level affects max_hp, max_stamina, attack_power, etc.)
|
||||||
|
from api.services.stats import invalidate_stats_cache
|
||||||
|
try:
|
||||||
|
from api.core.websockets import manager as ws_manager
|
||||||
|
await invalidate_stats_cache(player_id, getattr(ws_manager, 'redis_manager', None))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"leveled_up": True,
|
"leveled_up": True,
|
||||||
"new_level": current_level,
|
"new_level": current_level,
|
||||||
@@ -570,29 +599,85 @@ def generate_npc_intent(npc_def, combat_state: dict) -> dict:
|
|||||||
Generate the NEXT intent for an NPC.
|
Generate the NEXT intent for an NPC.
|
||||||
Returns a dict with intent type and details.
|
Returns a dict with intent type and details.
|
||||||
"""
|
"""
|
||||||
# Default intent is attack
|
import random
|
||||||
intent = {"type": "attack", "value": 0}
|
from api.services.skills import skills_manager
|
||||||
|
|
||||||
# Logic could be more complex based on NPC type, HP, etc.
|
npc_hp_pct = combat_state['npc_hp'] / combat_state['npc_max_hp'] if combat_state['npc_max_hp'] > 0 else 0
|
||||||
roll = random.random()
|
skills = getattr(npc_def, 'skills', [])
|
||||||
|
|
||||||
# 20% chance to defend if HP < 50%
|
active_effects = combat_state.get('npc_status_effects', '')
|
||||||
if (combat_state['npc_hp'] / combat_state['npc_max_hp'] < 0.5) and roll < 0.2:
|
|
||||||
intent = {"type": "defend", "value": 0}
|
cooldowns = {}
|
||||||
# 15% chance for special attack (if defined, otherwise strong attack)
|
if active_effects:
|
||||||
elif roll < 0.35:
|
for eff in active_effects.split('|'):
|
||||||
intent = {"type": "special", "value": 0}
|
if eff.startswith('cd_'):
|
||||||
|
parts = eff.split(':')
|
||||||
|
if len(parts) >= 2:
|
||||||
|
cooldowns[parts[0][3:]] = int(parts[1])
|
||||||
|
|
||||||
|
available_skills = []
|
||||||
|
has_heal = None
|
||||||
|
has_buff = None
|
||||||
|
damage_skills = []
|
||||||
|
|
||||||
|
for skill_id in skills:
|
||||||
|
if cooldowns.get(skill_id, 0) > 0:
|
||||||
|
continue
|
||||||
|
skill = skills_manager.get_skill(skill_id)
|
||||||
|
if not skill: continue
|
||||||
|
available_skills.append(skill)
|
||||||
|
|
||||||
|
if 'heal_percent' in skill.effects:
|
||||||
|
has_heal = skill
|
||||||
|
elif 'buff' in skill.effects:
|
||||||
|
has_buff = skill
|
||||||
else:
|
else:
|
||||||
intent = {"type": "attack", "value": 0}
|
damage_skills.append(skill)
|
||||||
|
|
||||||
return intent
|
# 1. Survival First
|
||||||
|
if has_heal and npc_hp_pct < 0.3:
|
||||||
|
if random.random() < 0.8:
|
||||||
|
return {"type": "skill", "value": has_heal.id}
|
||||||
|
|
||||||
|
# 2. Buffs
|
||||||
|
if has_buff:
|
||||||
|
buff_name = has_buff.effects['buff']
|
||||||
|
is_buff_active = False
|
||||||
|
if active_effects:
|
||||||
|
for eff in active_effects.split('|'):
|
||||||
|
if eff.startswith(buff_name + ':'):
|
||||||
|
is_buff_active = True
|
||||||
|
break
|
||||||
|
if not is_buff_active and random.random() < 0.6:
|
||||||
|
return {"type": "skill", "value": has_buff.id}
|
||||||
|
|
||||||
|
# 3. Telegraphed Attack Check (15% chance if health > 30%)
|
||||||
|
if npc_hp_pct > 0.3 and random.random() < 0.15:
|
||||||
|
return {"type": "charge", "value": "charging_attack"}
|
||||||
|
|
||||||
|
# 4. Damage Skills
|
||||||
|
if damage_skills and random.random() < 0.4:
|
||||||
|
chosen = random.choice(damage_skills)
|
||||||
|
return {"type": "skill", "value": chosen.id}
|
||||||
|
|
||||||
|
# Default to attack or defend (legacy logic)
|
||||||
|
roll = random.random()
|
||||||
|
if npc_hp_pct < 0.5 and roll < 0.1:
|
||||||
|
return {"type": "defend", "value": 0}
|
||||||
|
|
||||||
|
return {"type": "attack", "value": 0}
|
||||||
|
|
||||||
|
|
||||||
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -> Tuple[List[dict], bool]:
|
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, player_stats: dict = None, locale: str = 'en') -> Tuple[List[dict], bool]:
|
||||||
"""
|
"""
|
||||||
Execute NPC turn based on PREVIOUS intent, then generate NEXT intent.
|
Execute NPC turn based on PREVIOUS intent, then generate NEXT intent.
|
||||||
Returns: (messages_list, player_defeated)
|
Returns: (messages_list, player_defeated)
|
||||||
"""
|
"""
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from api import database as db
|
||||||
|
from api.services.helpers import create_combat_message, get_game_message, get_locale_string
|
||||||
|
from api.services.skills import skills_manager
|
||||||
player = await db.get_player_by_id(player_id)
|
player = await db.get_player_by_id(player_id)
|
||||||
if not player:
|
if not player:
|
||||||
return [], True
|
return [], True
|
||||||
@@ -603,10 +688,9 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
|||||||
npc_hp = combat['npc_hp']
|
npc_hp = combat['npc_hp']
|
||||||
npc_max_hp = combat['npc_max_hp']
|
npc_max_hp = combat['npc_max_hp']
|
||||||
npc_status_str = combat.get('npc_status_effects', '')
|
npc_status_str = combat.get('npc_status_effects', '')
|
||||||
|
is_stunned = False
|
||||||
|
|
||||||
if npc_status_str:
|
if npc_status_str:
|
||||||
# Parse status: "bleeding:5:3" (name:dmg:ticks) or multiple "bleeding:5:3|burning:2:2"
|
|
||||||
# Handling multiple effects separated by |
|
|
||||||
effects_list = npc_status_str.split('|')
|
effects_list = npc_status_str.split('|')
|
||||||
active_effects = []
|
active_effects = []
|
||||||
npc_damage_taken = 0
|
npc_damage_taken = 0
|
||||||
@@ -616,12 +700,33 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
|||||||
if not effect_str: continue
|
if not effect_str: continue
|
||||||
try:
|
try:
|
||||||
parts = effect_str.split(':')
|
parts = effect_str.split(':')
|
||||||
if len(parts) >= 3:
|
|
||||||
name = parts[0]
|
name = parts[0]
|
||||||
|
|
||||||
|
if name == 'stun' and len(parts) >= 2:
|
||||||
|
ticks = int(parts[1])
|
||||||
|
if ticks > 0:
|
||||||
|
is_stunned = True
|
||||||
|
messages.append(create_combat_message(
|
||||||
|
"skill_effect",
|
||||||
|
origin="enemy",
|
||||||
|
message=get_game_message('npc_stunned_cannot_act', locale, npc_name=get_locale_string(npc_def.name, locale))
|
||||||
|
))
|
||||||
|
ticks -= 1
|
||||||
|
if ticks > 0:
|
||||||
|
active_effects.append(f"stun:{ticks}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if name.startswith('cd_') and len(parts) >= 3:
|
||||||
|
ticks = int(parts[2])
|
||||||
|
ticks -= 1
|
||||||
|
if ticks > 0:
|
||||||
|
active_effects.append(f"{name}:{parts[1]}:{ticks}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(parts) >= 3:
|
||||||
dmg = int(parts[1])
|
dmg = int(parts[1])
|
||||||
ticks = int(parts[2])
|
ticks = int(parts[2])
|
||||||
|
|
||||||
# Apply effect
|
|
||||||
if ticks > 0:
|
if ticks > 0:
|
||||||
if dmg > 0:
|
if dmg > 0:
|
||||||
npc_damage_taken += dmg
|
npc_damage_taken += dmg
|
||||||
@@ -636,182 +741,193 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
|||||||
heal = abs(dmg)
|
heal = abs(dmg)
|
||||||
npc_healing_received += heal
|
npc_healing_received += heal
|
||||||
messages.append(create_combat_message(
|
messages.append(create_combat_message(
|
||||||
"effect_heal", # Check if this message type exists or fallback
|
"effect_heal",
|
||||||
origin="enemy",
|
origin="enemy",
|
||||||
heal=heal,
|
heal=heal,
|
||||||
effect_name=name,
|
effect_name=name,
|
||||||
npc_name=npc_def.name
|
npc_name=npc_def.name
|
||||||
))
|
))
|
||||||
|
elif name in ["berserker_rage", "fortify", "analyzed"]:
|
||||||
|
pass
|
||||||
|
|
||||||
# Decrement tick
|
|
||||||
ticks -= 1
|
ticks -= 1
|
||||||
if ticks > 0:
|
if ticks > 0:
|
||||||
active_effects.append(f"{name}:{dmg}:{ticks}")
|
active_effects.append(f"{name}:{dmg}:{ticks}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error parsing NPC status: {e}")
|
print(f"Error parsing NPC status: {e}")
|
||||||
|
|
||||||
# Update NPC active effects
|
|
||||||
new_status_str = "|".join(active_effects)
|
new_status_str = "|".join(active_effects)
|
||||||
if new_status_str != npc_status_str:
|
if new_status_str != npc_status_str:
|
||||||
await db.update_combat(player_id, {'npc_status_effects': new_status_str})
|
await db.update_combat(player_id, {'npc_status_effects': new_status_str})
|
||||||
|
|
||||||
# Apply Total Damage/Healing
|
|
||||||
if npc_damage_taken > 0:
|
if npc_damage_taken > 0:
|
||||||
npc_hp = max(0, npc_hp - npc_damage_taken)
|
npc_hp = max(0, npc_hp - npc_damage_taken)
|
||||||
|
|
||||||
if npc_healing_received > 0:
|
if npc_healing_received > 0:
|
||||||
npc_hp = min(npc_max_hp, npc_hp + npc_healing_received)
|
npc_hp = min(npc_max_hp, npc_hp + npc_healing_received)
|
||||||
|
|
||||||
# Update NPC HP in DB
|
|
||||||
await db.update_combat(player_id, {'npc_hp': npc_hp})
|
await db.update_combat(player_id, {'npc_hp': npc_hp})
|
||||||
|
|
||||||
# Check if NPC died from effects
|
|
||||||
if npc_hp <= 0:
|
if npc_hp <= 0:
|
||||||
messages.append(create_combat_message(
|
messages.append(create_combat_message("victory", origin="neutral", npc_name=npc_def.name))
|
||||||
"victory",
|
|
||||||
origin="neutral",
|
|
||||||
npc_name=npc_def.name
|
|
||||||
))
|
|
||||||
# Award XP/Loot logic handled in combat route mostly, but we need to signal it.
|
|
||||||
# Returning true for player_defeated is definitely WRONG here if NPC died.
|
|
||||||
# The router usually handles "victory" check after action.
|
|
||||||
# But here this is triggered during NPC turn (which happens after Player turn).
|
|
||||||
# If NPC dies on its OWN turn, we need to handle it.
|
|
||||||
# However, typically NPC dies on Player turn.
|
|
||||||
# If NPC dies from bleeding on its turn, the player wins.
|
|
||||||
# We need to signal this back to router.
|
|
||||||
# But the current return signature is (messages, player_defeated).
|
|
||||||
# We might need to handle the win logic here or update signature.
|
|
||||||
# For now, let's update HP and let the flow continue.
|
|
||||||
# Wait, if NPC is dead, it shouldn't attack!
|
|
||||||
# returning here prevents NPC from attacking if it died from status effects
|
|
||||||
return messages, False
|
return messages, False
|
||||||
|
|
||||||
# Parse current intent (stored in DB as string or JSON, assuming simple string for now or we parse it)
|
|
||||||
current_intent_str = combat.get('npc_intent', 'attack')
|
current_intent_str = combat.get('npc_intent', 'attack')
|
||||||
# Handle legacy/null
|
|
||||||
if not current_intent_str:
|
if not current_intent_str:
|
||||||
current_intent_str = 'attack'
|
current_intent_str = 'attack'
|
||||||
|
|
||||||
intent_type = current_intent_str
|
intent_parts = current_intent_str.split(':')
|
||||||
|
intent_type = intent_parts[0]
|
||||||
|
intent_value = intent_parts[1] if len(intent_parts) > 1 else None
|
||||||
|
|
||||||
actual_damage = 0
|
actual_damage = 0
|
||||||
|
new_player_hp = player['hp']
|
||||||
|
|
||||||
# EXECUTE INTENT
|
if npc_hp > 0 and not is_stunned:
|
||||||
if npc_hp > 0: # Only attack if alive
|
|
||||||
if intent_type == 'defend':
|
if intent_type == 'defend':
|
||||||
# NPC defends - heals 5% HP
|
|
||||||
heal_amount = int(combat['npc_max_hp'] * 0.05)
|
heal_amount = int(combat['npc_max_hp'] * 0.05)
|
||||||
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
|
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
|
||||||
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
|
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
|
||||||
|
messages.append(create_combat_message("enemy_defend", origin="enemy", npc_name=npc_def.name, heal=heal_amount))
|
||||||
|
|
||||||
|
elif intent_type == 'charge':
|
||||||
messages.append(create_combat_message(
|
messages.append(create_combat_message(
|
||||||
"enemy_defend",
|
"skill_effect", origin="enemy", message=get_game_message('enemy_charging', locale, enemy=get_locale_string(npc_def.name, locale))
|
||||||
origin="enemy",
|
|
||||||
npc_name=npc_def.name,
|
|
||||||
heal=heal_amount
|
|
||||||
))
|
))
|
||||||
|
|
||||||
elif intent_type == 'special':
|
elif intent_type in ('charging_attack', 'special', 'attack', 'skill'):
|
||||||
# Strong attack (1.5x damage)
|
|
||||||
npc_damage = int(random.randint(npc_def.damage_min, npc_def.damage_max) * 1.5)
|
|
||||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
|
||||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
|
||||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
|
||||||
|
|
||||||
messages.append(create_combat_message(
|
|
||||||
"enemy_special",
|
|
||||||
origin="enemy",
|
|
||||||
npc_name=npc_def.name,
|
|
||||||
damage=npc_damage,
|
|
||||||
armor_absorbed=armor_absorbed
|
|
||||||
))
|
|
||||||
|
|
||||||
if broken_armor:
|
|
||||||
for armor in broken_armor:
|
|
||||||
messages.append(create_combat_message(
|
|
||||||
"item_broken",
|
|
||||||
origin="player",
|
|
||||||
item_name=armor['name'],
|
|
||||||
emoji=armor['emoji']
|
|
||||||
))
|
|
||||||
|
|
||||||
await db.update_player(player_id, hp=new_player_hp)
|
|
||||||
|
|
||||||
else: # Default 'attack'
|
|
||||||
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
||||||
|
skill = None
|
||||||
|
is_charging = intent_type == 'charging_attack'
|
||||||
|
|
||||||
# Enrage bonus if NPC is below 30% HP
|
if intent_type == 'charging_attack':
|
||||||
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3
|
npc_damage = int(npc_damage * 2.5)
|
||||||
if is_enraged:
|
elif intent_type == 'special':
|
||||||
npc_damage = int(npc_damage * 1.5)
|
npc_damage = int(npc_damage * 1.5)
|
||||||
messages.append(create_combat_message(
|
elif intent_type == 'skill' and intent_value:
|
||||||
"enemy_enraged",
|
skill = skills_manager.get_skill(intent_value)
|
||||||
origin="enemy",
|
if skill:
|
||||||
npc_name=npc_def.name
|
if skill.cooldown > 0:
|
||||||
))
|
cd_str = f"cd_{skill.id}:0:{skill.cooldown}"
|
||||||
|
curr_combat = await db.get_active_combat(player_id)
|
||||||
|
curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else ''
|
||||||
|
new_status = curr_status + f"|{cd_str}" if curr_status else cd_str
|
||||||
|
await db.update_combat(player_id, {'npc_status_effects': new_status})
|
||||||
|
|
||||||
# Check if player is defending (reduces damage by value%)
|
effects = skill.effects
|
||||||
|
if 'heal_percent' in effects:
|
||||||
|
heal_amount = int(combat['npc_max_hp'] * effects['heal_percent'])
|
||||||
|
new_npc_hp = min(combat['npc_max_hp'], npc_hp + heal_amount)
|
||||||
|
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
|
||||||
|
messages.append(create_combat_message("skill_heal", origin="enemy", heal=heal_amount, skill_icon=skill.icon, skill_name=get_locale_string(skill.name, locale), npc_name=npc_def.name))
|
||||||
|
npc_damage = 0
|
||||||
|
|
||||||
|
if 'buff' in effects:
|
||||||
|
buff_str = f"{effects['buff']}:0:{effects['buff_duration']}"
|
||||||
|
curr_combat = await db.get_active_combat(player_id)
|
||||||
|
curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else ''
|
||||||
|
new_status = curr_status + f"|{buff_str}" if curr_status else buff_str
|
||||||
|
await db.update_combat(player_id, {'npc_status_effects': new_status})
|
||||||
|
messages.append(create_combat_message("skill_buff", origin="enemy", skill_name=get_locale_string(skill.name, locale), skill_icon=skill.icon, duration=effects['buff_duration'], npc_name=npc_def.name))
|
||||||
|
if 'damage_multiplier' not in effects and 'poison_damage' not in effects:
|
||||||
|
npc_damage = 0
|
||||||
|
|
||||||
|
if 'damage_multiplier' in effects:
|
||||||
|
npc_damage = max(1, int(npc_damage * effects['damage_multiplier']))
|
||||||
|
|
||||||
|
from api.services.helpers import calculate_dynamic_status_damage
|
||||||
|
poison_dmg = calculate_dynamic_status_damage(effects, 'poison', player)
|
||||||
|
if poison_dmg is not None:
|
||||||
|
await db.add_effect(player_id=player_id, effect_name="Poison", effect_icon="🧪", effect_type="damage", damage_per_tick=poison_dmg, ticks_remaining=effects.get('poison_duration', 3), persist_after_combat=True, source=f"enemy_skill:{skill.id}")
|
||||||
|
|
||||||
|
burn_dmg = calculate_dynamic_status_damage(effects, 'burn', player)
|
||||||
|
if burn_dmg is not None:
|
||||||
|
await db.add_effect(player_id=player_id, effect_name="Burning", effect_icon="🔥", effect_type="damage", damage_per_tick=burn_dmg, ticks_remaining=effects.get('burn_duration', 3), persist_after_combat=True, source=f"enemy_skill:{skill.id}")
|
||||||
|
|
||||||
|
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3
|
||||||
|
if is_enraged and npc_damage > 0:
|
||||||
|
npc_damage = int(npc_damage * 1.5)
|
||||||
|
messages.append(create_combat_message("enemy_enraged", origin="enemy", npc_name=npc_def.name))
|
||||||
|
|
||||||
|
curr_combat = await db.get_active_combat(player_id)
|
||||||
|
curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else ''
|
||||||
|
if 'berserker_rage' in curr_status and npc_damage > 0:
|
||||||
|
npc_damage = int(npc_damage * 1.5)
|
||||||
|
|
||||||
|
if npc_damage > 0:
|
||||||
|
dodged = False
|
||||||
|
|
||||||
|
is_defending = False
|
||||||
player_effects = await db.get_player_effects(player_id)
|
player_effects = await db.get_player_effects(player_id)
|
||||||
defending_effect = next((e for e in player_effects if e['effect_name'] == 'defending'), None)
|
defending_effect = next((e for e in player_effects if e['effect_name'] == 'defending'), None)
|
||||||
if defending_effect:
|
if defending_effect:
|
||||||
reduction = defending_effect.get('value', 50) / 100 # Default 50% reduction
|
is_defending = True
|
||||||
npc_damage = int(npc_damage * (1 - reduction))
|
reduction = defending_effect.get('value', 50) / 100
|
||||||
messages.append(create_combat_message(
|
npc_damage = max(1, int(npc_damage * (1 - reduction)))
|
||||||
"damage_reduced",
|
messages.append(create_combat_message("damage_reduced", origin="player", reduction=int(reduction * 100)))
|
||||||
origin="player",
|
|
||||||
reduction=int(reduction * 100)
|
|
||||||
))
|
|
||||||
# Remove defending effect after use
|
|
||||||
await db.remove_effect(player_id, 'defending')
|
await db.remove_effect(player_id, 'defending')
|
||||||
|
|
||||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
buff_dmg_reduction = player_stats.get('buff_damage_reduction', 0.0) if player_stats else 0.0
|
||||||
|
if buff_dmg_reduction > 0:
|
||||||
|
npc_damage = max(1, int(npc_damage * (1 - buff_dmg_reduction)))
|
||||||
|
messages.append(create_combat_message("damage_reduced", origin="player", reduction=int(buff_dmg_reduction * 100)))
|
||||||
|
|
||||||
|
buff_dmg_taken_increase = player_stats.get('buff_damage_taken_increase', 0.0) if player_stats else 0.0
|
||||||
|
if buff_dmg_taken_increase > 0:
|
||||||
|
npc_damage = int(npc_damage * (1 + buff_dmg_taken_increase))
|
||||||
|
|
||||||
|
if player_stats and player_stats.get('buff_guaranteed_dodge', False):
|
||||||
|
dodged = True
|
||||||
|
messages.append(create_combat_message("combat_dodge", origin="player"))
|
||||||
|
await db.remove_effect(player_id, 'evade')
|
||||||
|
elif player_stats and player_stats.get('buff_enemy_miss', False):
|
||||||
|
dodged = True
|
||||||
|
messages.append(create_combat_message("combat_dodge", origin="player"))
|
||||||
|
elif player_stats and 'dodge_chance' in player_stats and random.random() < player_stats['dodge_chance']:
|
||||||
|
dodged = True
|
||||||
|
messages.append(create_combat_message("combat_dodge", origin="player"))
|
||||||
|
|
||||||
|
if not dodged and player_stats and player_stats.get('has_shield', False) and random.random() < player_stats.get('block_chance', 0):
|
||||||
|
messages.append(create_combat_message("combat_block", origin="player"))
|
||||||
|
npc_damage = max(1, int(npc_damage * 0.2))
|
||||||
|
|
||||||
|
if not dodged:
|
||||||
|
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage, is_defending)
|
||||||
|
if player_stats and player_stats.get('armor_reduction', 0) > 0:
|
||||||
|
pct_reduction = player_stats['armor_reduction']
|
||||||
|
actual_damage = max(1, int(npc_damage * (1 - pct_reduction)))
|
||||||
|
armor_absorbed_visual = npc_damage - actual_damage
|
||||||
|
else:
|
||||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||||
|
armor_absorbed_visual = armor_absorbed
|
||||||
|
|
||||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||||
|
|
||||||
messages.append(create_combat_message(
|
if skill and 'damage_multiplier' in skill.effects:
|
||||||
"enemy_attack",
|
messages.append(create_combat_message("skill_attack", origin="enemy", damage=actual_damage, skill_name=get_locale_string(skill.name, locale), skill_icon=skill.icon, hits=1))
|
||||||
origin="enemy",
|
elif is_charging:
|
||||||
npc_name=npc_def.name,
|
messages.append(create_combat_message("enemy_special", origin="enemy", npc_name=npc_def.name, damage=actual_damage, armor_absorbed=armor_absorbed_visual))
|
||||||
damage=npc_damage,
|
else:
|
||||||
armor_absorbed=armor_absorbed
|
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:
|
if broken_armor:
|
||||||
for armor in broken_armor:
|
for armor in broken_armor:
|
||||||
messages.append(create_combat_message(
|
messages.append(create_combat_message("item_broken", origin="player", item_name=armor['name'], emoji=armor['emoji']))
|
||||||
"item_broken",
|
|
||||||
origin="player",
|
|
||||||
item_name=armor['name'],
|
|
||||||
emoji=armor['emoji']
|
|
||||||
))
|
|
||||||
|
|
||||||
await db.update_player(player_id, hp=new_player_hp)
|
await db.update_player(player_id, hp=new_player_hp)
|
||||||
|
|
||||||
# GENERATE NEXT INTENT
|
|
||||||
|
|
||||||
# Check if player defeated
|
|
||||||
player_defeated = False
|
player_defeated = False
|
||||||
if player['hp'] - actual_damage <= 0 and intent_type != 'defend': # Check HP after damage
|
if new_player_hp <= 0 and intent_type != 'defend' and intent_type != 'charge':
|
||||||
# Re-fetch to be sure or just trust calculation
|
messages.append(create_combat_message("player_defeated", origin="neutral", npc_name=npc_def.name))
|
||||||
if new_player_hp <= 0:
|
|
||||||
messages.append(create_combat_message(
|
|
||||||
"player_defeated",
|
|
||||||
origin="neutral",
|
|
||||||
npc_name=npc_def.name
|
|
||||||
))
|
|
||||||
player_defeated = True
|
player_defeated = True
|
||||||
await db.update_player(player_id, hp=0, is_dead=True)
|
await db.update_player(player_id, hp=0, is_dead=True)
|
||||||
await db.update_player_statistics(player_id, deaths=1, damage_taken=actual_damage, increment=True)
|
await db.update_player_statistics(player_id, deaths=1, damage_taken=actual_damage, increment=True)
|
||||||
await db.end_combat(player_id)
|
await db.end_combat(player_id)
|
||||||
return messages, player_defeated
|
return messages, player_defeated
|
||||||
|
|
||||||
if not player_defeated:
|
|
||||||
if actual_damage > 0:
|
if actual_damage > 0:
|
||||||
await db.update_player_statistics(player_id, damage_taken=actual_damage, increment=True)
|
await db.update_player_statistics(player_id, damage_taken=actual_damage, increment=True)
|
||||||
|
|
||||||
# Generate NEXT intent
|
|
||||||
# We need the updated NPC HP for the logic
|
|
||||||
current_npc_hp = combat['npc_hp']
|
current_npc_hp = combat['npc_hp']
|
||||||
if intent_type == 'defend':
|
if intent_type == 'defend':
|
||||||
current_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + int(combat['npc_max_hp'] * 0.05))
|
current_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + int(combat['npc_max_hp'] * 0.05))
|
||||||
@@ -819,13 +935,16 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
|||||||
temp_combat_state = combat.copy()
|
temp_combat_state = combat.copy()
|
||||||
temp_combat_state['npc_hp'] = current_npc_hp
|
temp_combat_state['npc_hp'] = current_npc_hp
|
||||||
|
|
||||||
|
if intent_type == 'charge':
|
||||||
|
next_intent_str = 'charging_attack'
|
||||||
|
else:
|
||||||
next_intent = generate_npc_intent(npc_def, temp_combat_state)
|
next_intent = generate_npc_intent(npc_def, temp_combat_state)
|
||||||
|
next_intent_str = f"{next_intent['type']}:{next_intent['value']}" if next_intent['type'] == 'skill' else next_intent['type']
|
||||||
|
|
||||||
# Update combat with new intent and turn
|
|
||||||
await db.update_combat(player_id, {
|
await db.update_combat(player_id, {
|
||||||
'turn': 'player',
|
'turn': 'player',
|
||||||
'turn_started_at': time.time(),
|
'turn_started_at': time.time(),
|
||||||
'npc_intent': next_intent['type']
|
'npc_intent': next_intent_str
|
||||||
})
|
})
|
||||||
|
|
||||||
return messages, player_defeated
|
return messages, player_defeated
|
||||||
|
|||||||
53
api/give_gear.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
import asyncpg
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
conn = await asyncpg.connect(settings.DATABASE_URL)
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
user = await conn.fetchrow("SELECT id FROM characters ORDER BY created_at DESC LIMIT 1 OFFSET 0")
|
||||||
|
if not user:
|
||||||
|
print("No user found")
|
||||||
|
return
|
||||||
|
c_id = user['id']
|
||||||
|
print(f"Adding items to character {c_id}")
|
||||||
|
|
||||||
|
# Items: Greatsword, Full Plate, Kite Shield
|
||||||
|
items = [
|
||||||
|
{"item_id": "iron_greatsword", "base_durability": 100},
|
||||||
|
{"item_id": "steel_plate", "base_durability": 200},
|
||||||
|
{"item_id": "iron_kite_shield", "base_durability": 120}
|
||||||
|
]
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
# Create unique item
|
||||||
|
unique_id = str(uuid.uuid4())
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO unique_items (id, item_id, durability, max_durability)
|
||||||
|
VALUES ($1, $2, $3, $3)
|
||||||
|
""",
|
||||||
|
unique_id, item['item_id'], item['base_durability']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add to inventory
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO inventory_items (character_id, item_id, quantity, is_equipped, unique_item_id)
|
||||||
|
VALUES ($1, $2, 1, false, $3)
|
||||||
|
""",
|
||||||
|
c_id, item['item_id'], unique_id
|
||||||
|
)
|
||||||
|
print(f"Added {item['item_id']} ({unique_id}) to inventory.")
|
||||||
|
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
17
api/give_gear_final.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
char_id INTEGER;
|
||||||
|
uid INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO char_id FROM characters ORDER BY created_at DESC LIMIT 1;
|
||||||
|
IF char_id IS NOT NULL THEN
|
||||||
|
INSERT INTO unique_items (item_id, durability, max_durability) VALUES ('iron_greatsword', 100, 100) RETURNING id INTO uid;
|
||||||
|
INSERT INTO inventory (character_id, item_id, quantity, is_equipped, unique_item_id) VALUES (char_id, 'iron_greatsword', 1, false, uid);
|
||||||
|
|
||||||
|
INSERT INTO unique_items (item_id, durability, max_durability) VALUES ('steel_plate', 200, 200) RETURNING id INTO uid;
|
||||||
|
INSERT INTO inventory (character_id, item_id, quantity, is_equipped, unique_item_id) VALUES (char_id, 'steel_plate', 1, false, uid);
|
||||||
|
|
||||||
|
INSERT INTO unique_items (item_id, durability, max_durability) VALUES ('iron_kite_shield', 120, 120) RETURNING id INTO uid;
|
||||||
|
INSERT INTO inventory (character_id, item_id, quantity, is_equipped, unique_item_id) VALUES (char_id, 'iron_kite_shield', 1, false, uid);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -31,6 +31,8 @@ class Item:
|
|||||||
tier: int = 1 # Item tier (1-5)
|
tier: int = 1 # Item tier (1-5)
|
||||||
encumbrance: int = 0 # Encumbrance penalty when equipped
|
encumbrance: int = 0 # Encumbrance penalty when equipped
|
||||||
weapon_effects: Dict[str, Any] = None # Weapon effects: bleeding, stun, etc.
|
weapon_effects: Dict[str, Any] = None # Weapon effects: bleeding, stun, etc.
|
||||||
|
weapon_type: str = None # e.g. 'two_handed', 'one_handed', 'dagger', 'bow'
|
||||||
|
equip_requirements: Dict[str, int] = None # e.g. {'level': 15, 'strength': 20}
|
||||||
# Repair system
|
# Repair system
|
||||||
repairable: bool = False # Can this item be repaired?
|
repairable: bool = False # Can this item be repaired?
|
||||||
repair_materials: list = None # Materials needed for repair
|
repair_materials: list = None # Materials needed for repair
|
||||||
@@ -72,6 +74,8 @@ class Item:
|
|||||||
self.uncraft_tools = []
|
self.uncraft_tools = []
|
||||||
if self.combat_effects is None:
|
if self.combat_effects is None:
|
||||||
self.combat_effects = {}
|
self.combat_effects = {}
|
||||||
|
if self.equip_requirements is None:
|
||||||
|
self.equip_requirements = {}
|
||||||
|
|
||||||
|
|
||||||
class ItemsManager:
|
class ItemsManager:
|
||||||
@@ -139,7 +143,9 @@ class ItemsManager:
|
|||||||
uncraft_tools=item_data.get('uncraft_tools', []),
|
uncraft_tools=item_data.get('uncraft_tools', []),
|
||||||
combat_usable=item_data.get('combat_usable', is_consumable), # Default: consumables are combat usable
|
combat_usable=item_data.get('combat_usable', is_consumable), # Default: consumables are combat usable
|
||||||
combat_only=item_data.get('combat_only', False),
|
combat_only=item_data.get('combat_only', False),
|
||||||
combat_effects=item_data.get('combat_effects', {})
|
combat_effects=item_data.get('combat_effects', {}),
|
||||||
|
weapon_type=item_data.get('weapon_type'),
|
||||||
|
equip_requirements=item_data.get('equip_requirements', {})
|
||||||
)
|
)
|
||||||
self.items[item_id] = item
|
self.items[item_id] = item
|
||||||
|
|
||||||
|
|||||||
@@ -186,9 +186,9 @@ except Exception as e:
|
|||||||
# Initialize routers with game data dependencies
|
# Initialize routers with game data dependencies
|
||||||
game_routes.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
game_routes.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
||||||
combat.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager, QUESTS_DATA)
|
combat.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager, QUESTS_DATA)
|
||||||
equipment.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
equipment.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
||||||
crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
||||||
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
||||||
statistics.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
statistics.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||||
admin.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR)
|
admin.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR)
|
||||||
quests.init_router_dependencies(ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA, LOCATIONS)
|
quests.init_router_dependencies(ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA, LOCATIONS)
|
||||||
|
|||||||
@@ -25,13 +25,15 @@ logger = logging.getLogger(__name__)
|
|||||||
LOCATIONS = None
|
LOCATIONS = None
|
||||||
ITEMS_MANAGER = None
|
ITEMS_MANAGER = None
|
||||||
WORLD = None
|
WORLD = None
|
||||||
|
redis_manager = None
|
||||||
|
|
||||||
def init_router_dependencies(locations, items_manager, world):
|
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
|
||||||
"""Initialize router with game data dependencies"""
|
"""Initialize router with game data dependencies"""
|
||||||
global LOCATIONS, ITEMS_MANAGER, WORLD
|
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
|
||||||
LOCATIONS = locations
|
LOCATIONS = locations
|
||||||
ITEMS_MANAGER = items_manager
|
ITEMS_MANAGER = items_manager
|
||||||
WORLD = world
|
WORLD = world
|
||||||
|
redis_manager = redis_mgr
|
||||||
|
|
||||||
router = APIRouter(tags=["crafting"])
|
router = APIRouter(tags=["crafting"])
|
||||||
|
|
||||||
@@ -179,6 +181,11 @@ async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get
|
|||||||
if not player:
|
if not player:
|
||||||
raise HTTPException(status_code=404, detail="Player not found")
|
raise HTTPException(status_code=404, detail="Player not found")
|
||||||
|
|
||||||
|
# Get derived stats for crafting bonus
|
||||||
|
from ..services.stats import calculate_derived_stats
|
||||||
|
stats = await calculate_derived_stats(player['id'], redis_manager)
|
||||||
|
crafting_bonus = stats.get('crafting_bonus', 0.0)
|
||||||
|
|
||||||
location_id = player['location_id']
|
location_id = player['location_id']
|
||||||
location = LOCATIONS.get(location_id)
|
location = LOCATIONS.get(location_id)
|
||||||
|
|
||||||
@@ -287,11 +294,13 @@ async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get
|
|||||||
if hasattr(item_def, 'durability') and item_def.durability:
|
if hasattr(item_def, 'durability') and item_def.durability:
|
||||||
# This is a unique item - generate random stats
|
# This is a unique item - generate random stats
|
||||||
base_durability = item_def.durability
|
base_durability = item_def.durability
|
||||||
# Random durability: 90-110% of base
|
|
||||||
random_durability = int(base_durability * random.uniform(0.9, 1.1))
|
|
||||||
|
|
||||||
# Generate tier based on durability roll
|
# Random durability: 90-110% of base, plus crafting_bonus (e.g. +0.05 from Intellect)
|
||||||
durability_percent = (random_durability / base_durability)
|
base_roll = random.uniform(0.9, 1.1)
|
||||||
|
durability_percent = base_roll + crafting_bonus
|
||||||
|
random_durability = int(base_durability * durability_percent)
|
||||||
|
|
||||||
|
# Generate tier based on the final durability percentage
|
||||||
if durability_percent >= 1.08:
|
if durability_percent >= 1.08:
|
||||||
tier = 5 # Gold
|
tier = 5 # Gold
|
||||||
elif durability_percent >= 1.04:
|
elif durability_percent >= 1.04:
|
||||||
@@ -308,8 +317,9 @@ async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get
|
|||||||
if hasattr(item_def, 'stats') and item_def.stats:
|
if hasattr(item_def, 'stats') and item_def.stats:
|
||||||
for stat_key, stat_value in item_def.stats.items():
|
for stat_key, stat_value in item_def.stats.items():
|
||||||
if isinstance(stat_value, (int, float)):
|
if isinstance(stat_value, (int, float)):
|
||||||
# Random stat: 90-110% of base
|
# Random stat: same multiplier logic applied to base stats
|
||||||
random_stats[stat_key] = int(stat_value * random.uniform(0.9, 1.1))
|
stat_percent = random.uniform(0.9, 1.1) + crafting_bonus
|
||||||
|
random_stats[stat_key] = int(stat_value * stat_percent)
|
||||||
else:
|
else:
|
||||||
random_stats[stat_key] = stat_value
|
random_stats[stat_key] = stat_value
|
||||||
|
|
||||||
@@ -501,9 +511,8 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
|||||||
adjusted_quantity = int(round(base_quantity * durability_ratio))
|
adjusted_quantity = int(round(base_quantity * durability_ratio))
|
||||||
|
|
||||||
mat_def = ITEMS_MANAGER.items.get(material['item_id'])
|
mat_def = ITEMS_MANAGER.items.get(material['item_id'])
|
||||||
mat_name = mat_def.name if mat_def else material['item_id']
|
|
||||||
|
|
||||||
loss_key = (material['item_id'], mat_name)
|
loss_key = material['item_id']
|
||||||
|
|
||||||
# If durability is too low (< 10%), yield nothing for this material
|
# If durability is too low (< 10%), yield nothing for this material
|
||||||
if durability_ratio < 0.1 or adjusted_quantity <= 0:
|
if durability_ratio < 0.1 or adjusted_quantity <= 0:
|
||||||
@@ -527,7 +536,7 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
|||||||
# But we need to check capacity.
|
# But we need to check capacity.
|
||||||
# Let's accumulate pending yield.
|
# Let's accumulate pending yield.
|
||||||
|
|
||||||
yield_key = (material['item_id'], mat_name, mat_def.emoji if mat_def else '📦', mat_def)
|
yield_key = material['item_id']
|
||||||
if yield_key not in materials_yielded_dict:
|
if yield_key not in materials_yielded_dict:
|
||||||
materials_yielded_dict[yield_key] = 0
|
materials_yielded_dict[yield_key] = 0
|
||||||
materials_yielded_dict[yield_key] += adjusted_quantity
|
materials_yielded_dict[yield_key] += adjusted_quantity
|
||||||
@@ -538,18 +547,23 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
|||||||
materials_dropped = []
|
materials_dropped = []
|
||||||
|
|
||||||
# Convert lost dict to list
|
# Convert lost dict to list
|
||||||
for (item_id, name), qty in materials_lost_dict.items():
|
for item_id, qty in materials_lost_dict.items():
|
||||||
|
mat_def = ITEMS_MANAGER.items.get(item_id)
|
||||||
materials_lost.append({
|
materials_lost.append({
|
||||||
'item_id': item_id,
|
'item_id': item_id,
|
||||||
'name': name,
|
'name': mat_def.name if mat_def else item_id,
|
||||||
'quantity': qty,
|
'emoji': mat_def.emoji if mat_def else '📦',
|
||||||
'reason': 'lost_or_low_durability'
|
'quantity': qty
|
||||||
})
|
})
|
||||||
|
|
||||||
# Process yield
|
# Process yield
|
||||||
for (item_id, name, emoji, mat_def), qty in materials_yielded_dict.items():
|
for item_id, qty in materials_yielded_dict.items():
|
||||||
mat_weight = getattr(mat_def, 'weight', 0) * qty
|
mat_def = ITEMS_MANAGER.items.get(item_id)
|
||||||
mat_volume = getattr(mat_def, 'volume', 0) * qty
|
mat_name = mat_def.name if mat_def else item_id
|
||||||
|
emoji = mat_def.emoji if mat_def else '📦'
|
||||||
|
|
||||||
|
mat_weight = getattr(mat_def, 'weight', 0) * qty if mat_def else 0
|
||||||
|
mat_volume = getattr(mat_def, 'volume', 0) * qty if mat_def else 0
|
||||||
|
|
||||||
# Simple check against capacity (assuming current_weight was just updated from DB)
|
# Simple check against capacity (assuming current_weight was just updated from DB)
|
||||||
# Note: we might fill up mid-loop. ideally we add one by one or check total.
|
# Note: we might fill up mid-loop. ideally we add one by one or check total.
|
||||||
|
|||||||
@@ -24,13 +24,15 @@ logger = logging.getLogger(__name__)
|
|||||||
LOCATIONS = None
|
LOCATIONS = None
|
||||||
ITEMS_MANAGER = None
|
ITEMS_MANAGER = None
|
||||||
WORLD = None
|
WORLD = None
|
||||||
|
redis_manager = None
|
||||||
|
|
||||||
def init_router_dependencies(locations, items_manager, world):
|
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
|
||||||
"""Initialize router with game data dependencies"""
|
"""Initialize router with game data dependencies"""
|
||||||
global LOCATIONS, ITEMS_MANAGER, WORLD
|
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
|
||||||
LOCATIONS = locations
|
LOCATIONS = locations
|
||||||
ITEMS_MANAGER = items_manager
|
ITEMS_MANAGER = items_manager
|
||||||
WORLD = world
|
WORLD = world
|
||||||
|
redis_manager = redis_mgr
|
||||||
|
|
||||||
router = APIRouter(tags=["equipment"])
|
router = APIRouter(tags=["equipment"])
|
||||||
|
|
||||||
@@ -48,6 +50,14 @@ async def equip_item(
|
|||||||
player_id = current_user['id']
|
player_id = current_user['id']
|
||||||
locale = request.headers.get('Accept-Language', 'en')
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
|
# Check if in combat
|
||||||
|
in_combat = await db.get_active_combat(player_id)
|
||||||
|
if in_combat:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=get_game_message('cannot_equip_combat', locale)
|
||||||
|
)
|
||||||
|
|
||||||
# Get the inventory item
|
# Get the inventory item
|
||||||
inv_item = await db.get_inventory_item_by_id(equip_req.inventory_id)
|
inv_item = await db.get_inventory_item_by_id(equip_req.inventory_id)
|
||||||
if not inv_item or inv_item['character_id'] != player_id:
|
if not inv_item or inv_item['character_id'] != player_id:
|
||||||
@@ -62,6 +72,25 @@ async def equip_item(
|
|||||||
if not item_def.equippable or not item_def.slot:
|
if not item_def.equippable or not item_def.slot:
|
||||||
raise HTTPException(status_code=400, detail="This item cannot be equipped")
|
raise HTTPException(status_code=400, detail="This item cannot be equipped")
|
||||||
|
|
||||||
|
# Check equipment requirements (level + stat gates)
|
||||||
|
if item_def.equip_requirements:
|
||||||
|
player = await db.get_player_by_id(player_id) if 'level' not in current_user else current_user
|
||||||
|
|
||||||
|
req_level = item_def.equip_requirements.get('level', 0)
|
||||||
|
if player.get('level', 1) < req_level:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=get_game_message('equip_level_required', locale, level=req_level)
|
||||||
|
)
|
||||||
|
|
||||||
|
for stat_name in ['strength', 'agility', 'endurance', 'intellect']:
|
||||||
|
req_value = item_def.equip_requirements.get(stat_name, 0)
|
||||||
|
if req_value > 0 and player.get(stat_name, 0) < req_value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=get_game_message('equip_stat_required', locale, stat=stat_name.capitalize(), value=req_value)
|
||||||
|
)
|
||||||
|
|
||||||
# Check if slot is valid
|
# Check if slot is valid
|
||||||
valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack']
|
valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack']
|
||||||
if item_def.slot not in valid_slots:
|
if item_def.slot not in valid_slots:
|
||||||
@@ -113,6 +142,10 @@ async def equip_item(
|
|||||||
else:
|
else:
|
||||||
message = get_game_message('equipped', locale, item=get_locale_string(item_def.name, locale))
|
message = get_game_message('equipped', locale, item=get_locale_string(item_def.name, locale))
|
||||||
|
|
||||||
|
# Invalidate cached derived stats (equipment changed)
|
||||||
|
from ..services.stats import invalidate_stats_cache
|
||||||
|
await invalidate_stats_cache(player_id, redis_manager)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": message,
|
"message": message,
|
||||||
@@ -131,6 +164,14 @@ async def unequip_item(
|
|||||||
player_id = current_user['id']
|
player_id = current_user['id']
|
||||||
locale = request.headers.get('Accept-Language', 'en')
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
|
# Check if in combat
|
||||||
|
in_combat = await db.get_active_combat(player_id)
|
||||||
|
if in_combat:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=get_game_message('cannot_equip_combat', locale)
|
||||||
|
)
|
||||||
|
|
||||||
# Check if slot is valid
|
# Check if slot is valid
|
||||||
valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack']
|
valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack']
|
||||||
if unequip_req.slot not in valid_slots:
|
if unequip_req.slot not in valid_slots:
|
||||||
@@ -192,6 +233,10 @@ async def unequip_item(
|
|||||||
await db.drop_item(player_id, inv_item['item_id'], 1, current_user['location_id'])
|
await db.drop_item(player_id, inv_item['item_id'], 1, current_user['location_id'])
|
||||||
await db.remove_from_inventory(player_id, inv_item['item_id'], 1)
|
await db.remove_from_inventory(player_id, inv_item['item_id'], 1)
|
||||||
|
|
||||||
|
# Invalidate cached derived stats
|
||||||
|
from ..services.stats import invalidate_stats_cache
|
||||||
|
await invalidate_stats_cache(player_id, redis_manager)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": get_game_message('unequip_dropped', locale, item=get_locale_string(item_def.name, locale)),
|
"message": get_game_message('unequip_dropped', locale, item=get_locale_string(item_def.name, locale)),
|
||||||
@@ -202,6 +247,10 @@ async def unequip_item(
|
|||||||
await db.unequip_item(player_id, unequip_req.slot)
|
await db.unequip_item(player_id, unequip_req.slot)
|
||||||
await db.update_inventory_item(equipped['item_id'], is_equipped=False)
|
await db.update_inventory_item(equipped['item_id'], is_equipped=False)
|
||||||
|
|
||||||
|
# Invalidate cached derived stats
|
||||||
|
from ..services.stats import invalidate_stats_cache
|
||||||
|
await invalidate_stats_cache(player_id, redis_manager)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": get_game_message('unequipped', locale, item=get_locale_string(item_def.name, locale)),
|
"message": get_game_message('unequipped', locale, item=get_locale_string(item_def.name, locale)),
|
||||||
@@ -379,7 +428,7 @@ async def repair_item(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def reduce_armor_durability(player_id: int, damage_taken: int) -> tuple:
|
async def reduce_armor_durability(player_id: int, damage_taken: int, is_defending: bool = False) -> tuple:
|
||||||
"""
|
"""
|
||||||
Reduce durability of equipped armor pieces when taking damage.
|
Reduce durability of equipped armor pieces when taking damage.
|
||||||
Formula: durability_loss = max(1, (damage_taken / armor_value) * base_reduction_rate)
|
Formula: durability_loss = max(1, (damage_taken / armor_value) * base_reduction_rate)
|
||||||
@@ -419,7 +468,7 @@ async def reduce_armor_durability(player_id: int, damage_taken: int) -> tuple:
|
|||||||
|
|
||||||
# Calculate durability loss for each armor piece
|
# Calculate durability loss for each armor piece
|
||||||
# Balanced formula: armor should last many combats (10-20+ hits for low tier)
|
# Balanced formula: armor should last many combats (10-20+ hits for low tier)
|
||||||
base_reduction_rate = 0.1 # Reduced from 0.5 to make armor more durable
|
base_reduction_rate = 0.2 if is_defending else 0.1 # Reduced from 0.5 to make armor more durable
|
||||||
broken_armor = []
|
broken_armor = []
|
||||||
|
|
||||||
for armor in equipped_armor:
|
for armor in equipped_armor:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from .. import database as db
|
|||||||
from ..items import ItemsManager
|
from ..items import ItemsManager
|
||||||
from .. import game_logic
|
from .. import game_logic
|
||||||
from ..core.websockets import manager
|
from ..core.websockets import manager
|
||||||
|
from ..services.stats import STAT_CAP, invalidate_stats_cache
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -227,7 +228,8 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
|
|||||||
raise HTTPException(status_code=404, detail="Player not found")
|
raise HTTPException(status_code=404, detail="Player not found")
|
||||||
|
|
||||||
# Get player status effects
|
# Get player status effects
|
||||||
status_effects = await db.get_player_effects(player_id)
|
from ..services.helpers import get_resolved_player_effects
|
||||||
|
status_effects = await get_resolved_player_effects(player_id)
|
||||||
player['status_effects'] = status_effects
|
player['status_effects'] = status_effects
|
||||||
|
|
||||||
# Get location
|
# Get location
|
||||||
@@ -374,13 +376,21 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
|
|||||||
"tags": getattr(location, 'tags', [])
|
"tags": getattr(location, 'tags', [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from ..services.stats import calculate_derived_stats
|
||||||
|
derived_stats = await calculate_derived_stats(player_id, redis_manager)
|
||||||
|
|
||||||
# Add weight/volume to player data
|
# Add weight/volume to player data
|
||||||
player_with_capacity = dict(player)
|
player_with_capacity = dict(player)
|
||||||
player_with_capacity['current_weight'] = round(total_weight, 2)
|
player_with_capacity['current_weight'] = round(total_weight, 2)
|
||||||
player_with_capacity['max_weight'] = round(max_weight, 2)
|
|
||||||
player_with_capacity['current_volume'] = round(total_volume, 2)
|
player_with_capacity['current_volume'] = round(total_volume, 2)
|
||||||
|
|
||||||
|
player_with_capacity['max_weight'] = round(derived_stats.get('carry_weight', max_weight), 2)
|
||||||
player_with_capacity['max_volume'] = round(max_volume, 2)
|
player_with_capacity['max_volume'] = round(max_volume, 2)
|
||||||
|
|
||||||
|
player_with_capacity['max_hp'] = derived_stats.get('max_hp', player['max_hp'])
|
||||||
|
player_with_capacity['max_stamina'] = derived_stats.get('max_stamina', player['max_stamina'])
|
||||||
|
player_with_capacity['derived_stats'] = derived_stats
|
||||||
|
|
||||||
# Calculate movement cooldown
|
# Calculate movement cooldown
|
||||||
import time
|
import time
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
@@ -411,20 +421,29 @@ async def get_player_profile(current_user: dict = Depends(get_current_user)):
|
|||||||
raise HTTPException(status_code=404, detail="Player not found")
|
raise HTTPException(status_code=404, detail="Player not found")
|
||||||
|
|
||||||
# Get player status effects
|
# Get player status effects
|
||||||
status_effects = await db.get_player_effects(player_id)
|
from ..services.helpers import get_resolved_player_effects
|
||||||
|
status_effects = await get_resolved_player_effects(player_id)
|
||||||
player['status_effects'] = status_effects
|
player['status_effects'] = status_effects
|
||||||
|
|
||||||
# Get capacity metrics (weight/volume) using the helper function
|
# Get capacity metrics (weight/volume) using the helper function
|
||||||
# We don't need the inventory array itself, just the capacity calculations
|
# We don't need the inventory array itself, just the capacity calculations
|
||||||
_, total_weight, total_volume, max_weight, max_volume = await _get_enriched_inventory(player_id)
|
_, total_weight, total_volume, max_weight, max_volume = await _get_enriched_inventory(player_id)
|
||||||
|
|
||||||
|
from ..services.stats import calculate_derived_stats
|
||||||
|
derived_stats = await calculate_derived_stats(player_id, redis_manager)
|
||||||
|
|
||||||
# Add weight/volume to player data
|
# Add weight/volume to player data
|
||||||
player_with_capacity = dict(player)
|
player_with_capacity = dict(player)
|
||||||
player_with_capacity['current_weight'] = round(total_weight, 2)
|
player_with_capacity['current_weight'] = round(total_weight, 2)
|
||||||
player_with_capacity['max_weight'] = round(max_weight, 2)
|
|
||||||
player_with_capacity['current_volume'] = round(total_volume, 2)
|
player_with_capacity['current_volume'] = round(total_volume, 2)
|
||||||
|
|
||||||
|
player_with_capacity['max_weight'] = round(derived_stats.get('carry_weight', max_weight), 2)
|
||||||
player_with_capacity['max_volume'] = round(max_volume, 2)
|
player_with_capacity['max_volume'] = round(max_volume, 2)
|
||||||
|
|
||||||
|
player_with_capacity['max_hp'] = derived_stats.get('max_hp', player['max_hp'])
|
||||||
|
player_with_capacity['max_stamina'] = derived_stats.get('max_stamina', player['max_stamina'])
|
||||||
|
player_with_capacity['derived_stats'] = derived_stats
|
||||||
|
|
||||||
# Calculate movement cooldown
|
# Calculate movement cooldown
|
||||||
import time
|
import time
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
@@ -457,19 +476,28 @@ async def spend_stat_point(
|
|||||||
if stat not in valid_stats:
|
if stat not in valid_stats:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid stat. Must be one of: {', '.join(valid_stats)}")
|
raise HTTPException(status_code=400, detail=f"Invalid stat. Must be one of: {', '.join(valid_stats)}")
|
||||||
|
|
||||||
|
# Check stat cap
|
||||||
|
if player[stat] >= STAT_CAP:
|
||||||
|
raise HTTPException(status_code=400, detail=f"{stat.capitalize()} is already at maximum ({STAT_CAP})")
|
||||||
|
|
||||||
# Update the stat and decrease unspent points
|
# Update the stat and decrease unspent points
|
||||||
update_data = {
|
update_data = {
|
||||||
stat: player[stat] + 1,
|
stat: player[stat] + 1,
|
||||||
'unspent_points': player['unspent_points'] - 1
|
'unspent_points': player['unspent_points'] - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Endurance increases max HP
|
# Endurance increases max HP and max stamina
|
||||||
if stat == 'endurance':
|
if stat == 'endurance':
|
||||||
update_data['max_hp'] = player['max_hp'] + 5
|
update_data['max_hp'] = player['max_hp'] + 5
|
||||||
update_data['hp'] = min(player['hp'] + 5, update_data['max_hp']) # Also heal by 5
|
update_data['hp'] = min(player['hp'] + 5, update_data['max_hp']) # Also heal by 5
|
||||||
|
update_data['max_stamina'] = player['max_stamina'] + 2
|
||||||
|
update_data['stamina'] = min(player['stamina'] + 2, update_data['max_stamina']) # Also restore by 2
|
||||||
|
|
||||||
await db.update_character(current_user['id'], **update_data)
|
await db.update_character(current_user['id'], **update_data)
|
||||||
|
|
||||||
|
# Invalidate cached derived stats
|
||||||
|
await invalidate_stats_cache(current_user['id'], redis_manager)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Increased {stat} by 1!",
|
"message": f"Increased {stat} by 1!",
|
||||||
@@ -952,6 +980,7 @@ async def move(
|
|||||||
await db.update_player_statistics(current_user['id'], combats_initiated=1, increment=True)
|
await db.update_player_statistics(current_user['id'], combats_initiated=1, increment=True)
|
||||||
|
|
||||||
encounter_triggered = True
|
encounter_triggered = True
|
||||||
|
from ..services.helpers import get_resolved_player_effects
|
||||||
combat_data = {
|
combat_data = {
|
||||||
"npc_id": enemy_id,
|
"npc_id": enemy_id,
|
||||||
"npc_name": npc_def.name,
|
"npc_name": npc_def.name,
|
||||||
@@ -962,6 +991,7 @@ async def move(
|
|||||||
"round": 1,
|
"round": 1,
|
||||||
"npc_intent": initial_intent['type']
|
"npc_intent": initial_intent['type']
|
||||||
}
|
}
|
||||||
|
player_effects = await get_resolved_player_effects(current_user['id'], in_combat=True)
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -976,7 +1006,8 @@ async def move(
|
|||||||
"triggered": True,
|
"triggered": True,
|
||||||
"enemy_id": enemy_id,
|
"enemy_id": enemy_id,
|
||||||
"message": get_game_message('enemy_ambush', locale),
|
"message": get_game_message('enemy_ambush', locale),
|
||||||
"combat": combat_data
|
"combat": combat_data,
|
||||||
|
"player_effects": player_effects
|
||||||
}
|
}
|
||||||
|
|
||||||
# Broadcast movement to WebSocket clients
|
# Broadcast movement to WebSocket clients
|
||||||
@@ -1551,3 +1582,143 @@ async def drop_item(
|
|||||||
"success": True,
|
"success": True,
|
||||||
"message": get_game_message('dropped_item_success', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=quantity)
|
"message": get_game_message('dropped_item_success', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=quantity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/game/character-sheet")
|
||||||
|
async def get_character_sheet(current_user: dict = Depends(get_current_user)):
|
||||||
|
"""Get the full character sheet with base stats, derived stats, skills, and perks."""
|
||||||
|
from ..services.stats import calculate_derived_stats
|
||||||
|
from ..services.skills import skills_manager, perks_manager, get_total_perk_points
|
||||||
|
|
||||||
|
player = current_user
|
||||||
|
character_id = player['id']
|
||||||
|
|
||||||
|
# Get derived stats
|
||||||
|
derived = await calculate_derived_stats(character_id, redis_manager)
|
||||||
|
|
||||||
|
# Get available skills
|
||||||
|
available_skills = skills_manager.get_available_skills(player)
|
||||||
|
|
||||||
|
# Get owned perks
|
||||||
|
owned_perks_rows = await db.get_character_perks(character_id)
|
||||||
|
owned_perk_ids = [row['perk_id'] for row in owned_perks_rows]
|
||||||
|
|
||||||
|
# Get all perks with availability
|
||||||
|
all_perks = perks_manager.get_available_perks(player, owned_perk_ids)
|
||||||
|
|
||||||
|
# Get active status effects
|
||||||
|
from ..services.helpers import get_resolved_player_effects
|
||||||
|
status_effects = await get_resolved_player_effects(character_id)
|
||||||
|
|
||||||
|
# Calculate perk points
|
||||||
|
total_perk_points = get_total_perk_points(player['level'])
|
||||||
|
used_perk_points = len(owned_perk_ids)
|
||||||
|
available_perk_points = total_perk_points - used_perk_points
|
||||||
|
|
||||||
|
return {
|
||||||
|
"base_stats": {
|
||||||
|
"strength": player['strength'],
|
||||||
|
"agility": player['agility'],
|
||||||
|
"endurance": player['endurance'],
|
||||||
|
"intellect": player['intellect'],
|
||||||
|
"unspent_points": player['unspent_points'],
|
||||||
|
"stat_cap": STAT_CAP,
|
||||||
|
},
|
||||||
|
"derived_stats": derived,
|
||||||
|
"skills": available_skills,
|
||||||
|
"perks": {
|
||||||
|
"available_points": available_perk_points,
|
||||||
|
"total_points": total_perk_points,
|
||||||
|
"used_points": used_perk_points,
|
||||||
|
"all_perks": all_perks,
|
||||||
|
},
|
||||||
|
"status_effects": status_effects,
|
||||||
|
"character": {
|
||||||
|
"name": player['name'],
|
||||||
|
"level": player['level'],
|
||||||
|
"xp": player['xp'],
|
||||||
|
"hp": player['hp'],
|
||||||
|
"max_hp": player['max_hp'],
|
||||||
|
"stamina": player['stamina'],
|
||||||
|
"max_stamina": player['max_stamina'],
|
||||||
|
"avatar_data": player.get('avatar_data'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/game/select_perk")
|
||||||
|
async def select_perk(
|
||||||
|
perk_id: str,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Select a perk for the character."""
|
||||||
|
from ..services.skills import perks_manager, get_total_perk_points
|
||||||
|
|
||||||
|
player = current_user
|
||||||
|
character_id = player['id']
|
||||||
|
|
||||||
|
# Check perk exists
|
||||||
|
perk = perks_manager.get_perk(perk_id)
|
||||||
|
if not perk:
|
||||||
|
raise HTTPException(status_code=404, detail="Perk not found")
|
||||||
|
|
||||||
|
# Check perk points available
|
||||||
|
owned_perks = await db.get_character_perks(character_id)
|
||||||
|
owned_perk_ids = [row['perk_id'] for row in owned_perks]
|
||||||
|
|
||||||
|
total_points = get_total_perk_points(player['level'])
|
||||||
|
used_points = len(owned_perk_ids)
|
||||||
|
|
||||||
|
if used_points >= total_points:
|
||||||
|
raise HTTPException(status_code=400, detail="No perk points available")
|
||||||
|
|
||||||
|
# Check if already owned
|
||||||
|
if perk_id in owned_perk_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="Perk already selected")
|
||||||
|
|
||||||
|
# Check requirements
|
||||||
|
if not perks_manager.check_requirements(perk, player):
|
||||||
|
raise HTTPException(status_code=400, detail="Requirements not met for this perk")
|
||||||
|
|
||||||
|
# Add perk
|
||||||
|
success = await db.add_character_perk(character_id, perk_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=400, detail="Failed to select perk")
|
||||||
|
|
||||||
|
# Invalidate stats cache (perks affect derived stats)
|
||||||
|
await invalidate_stats_cache(character_id, redis_manager)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Perk '{perk_id}' selected!",
|
||||||
|
"perk": {
|
||||||
|
"id": perk.id,
|
||||||
|
"name": perk.name,
|
||||||
|
"description": perk.description,
|
||||||
|
"icon": perk.icon,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/game/available-skills")
|
||||||
|
async def get_available_skills(current_user: dict = Depends(get_current_user)):
|
||||||
|
"""Get available skills for the combat UI abilities dropdown."""
|
||||||
|
from ..services.skills import skills_manager
|
||||||
|
from .. import database as db
|
||||||
|
|
||||||
|
player = current_user
|
||||||
|
all_skills = skills_manager.get_available_skills(player)
|
||||||
|
|
||||||
|
# Check cooldowns
|
||||||
|
effects = await db.get_player_effects(player['id'])
|
||||||
|
cooldowns = {eff['source']: eff['ticks_remaining'] for eff in effects if eff.get('effect_type') == 'cooldown'}
|
||||||
|
|
||||||
|
# Only return unlocked skills for the combat dropdown
|
||||||
|
unlocked = []
|
||||||
|
for s in all_skills:
|
||||||
|
if s['unlocked']:
|
||||||
|
cd_source = f"cd:{s['id']}"
|
||||||
|
s['current_cooldown'] = cooldowns.get(cd_source, 0)
|
||||||
|
unlocked.append(s)
|
||||||
|
|
||||||
|
return {"skills": unlocked}
|
||||||
@@ -25,13 +25,15 @@ logger = logging.getLogger(__name__)
|
|||||||
LOCATIONS = None
|
LOCATIONS = None
|
||||||
ITEMS_MANAGER = None
|
ITEMS_MANAGER = None
|
||||||
WORLD = None
|
WORLD = None
|
||||||
|
redis_manager = None
|
||||||
|
|
||||||
def init_router_dependencies(locations, items_manager, world):
|
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
|
||||||
"""Initialize router with game data dependencies"""
|
"""Initialize router with game data dependencies"""
|
||||||
global LOCATIONS, ITEMS_MANAGER, WORLD
|
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
|
||||||
LOCATIONS = locations
|
LOCATIONS = locations
|
||||||
ITEMS_MANAGER = items_manager
|
ITEMS_MANAGER = items_manager
|
||||||
WORLD = world
|
WORLD = world
|
||||||
|
redis_manager = redis_mgr
|
||||||
|
|
||||||
router = APIRouter(tags=["loot"])
|
router = APIRouter(tags=["loot"])
|
||||||
|
|
||||||
@@ -195,9 +197,13 @@ async def loot_corpse(
|
|||||||
# Parse corpse ID
|
# Parse corpse ID
|
||||||
corpse_type, corpse_db_id = req.corpse_id.split('_', 1)
|
corpse_type, corpse_db_id = req.corpse_id.split('_', 1)
|
||||||
corpse_db_id = int(corpse_db_id)
|
corpse_db_id = int(corpse_db_id)
|
||||||
|
|
||||||
player = current_user # current_user is already the character dict
|
player = current_user # current_user is already the character dict
|
||||||
|
|
||||||
|
# Get player derived stats for loot quality
|
||||||
|
from ..services.stats import calculate_derived_stats
|
||||||
|
stats = await calculate_derived_stats(player['id'], redis_manager)
|
||||||
|
loot_quality = stats.get('loot_quality', 1.0)
|
||||||
|
|
||||||
# Get player's current capacity
|
# Get player's current capacity
|
||||||
inventory = await db.get_inventory(player['id'])
|
inventory = await db.get_inventory(player['id'])
|
||||||
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
|
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
|
||||||
@@ -246,7 +252,6 @@ async def loot_corpse(
|
|||||||
success, error_msg, tools_consumed = await consume_tool_durability(player['id'], tool_req, inventory)
|
success, error_msg, tools_consumed = await consume_tool_durability(player['id'], tool_req, inventory)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=400, detail=error_msg)
|
raise HTTPException(status_code=400, detail=error_msg)
|
||||||
|
|
||||||
# Determine quantity
|
# Determine quantity
|
||||||
quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max'])
|
quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max'])
|
||||||
|
|
||||||
@@ -254,6 +259,13 @@ async def loot_corpse(
|
|||||||
# Check if item fits in inventory
|
# Check if item fits in inventory
|
||||||
item_def = ITEMS_MANAGER.get_item(loot_item['item_id'])
|
item_def = ITEMS_MANAGER.get_item(loot_item['item_id'])
|
||||||
if item_def:
|
if item_def:
|
||||||
|
# Apply loot quality bonus for resources and consumables
|
||||||
|
if getattr(item_def, 'category', item_def.type) in ['resource', 'consumable'] and loot_quality > 1.0:
|
||||||
|
# e.g., loot_quality 1.15 = 15% chance for +1 extra
|
||||||
|
bonus_chance = loot_quality - 1.0
|
||||||
|
if random.random() < bonus_chance:
|
||||||
|
quantity += 1
|
||||||
|
|
||||||
item_weight = item_def.weight * quantity
|
item_weight = item_def.weight * quantity
|
||||||
item_volume = item_def.volume * quantity
|
item_volume = item_def.volume * quantity
|
||||||
|
|
||||||
@@ -305,11 +317,16 @@ async def loot_corpse(
|
|||||||
if can_loot:
|
if can_loot:
|
||||||
# Can loot this item
|
# Can loot this item
|
||||||
quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max'])
|
quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max'])
|
||||||
|
|
||||||
if quantity > 0:
|
if quantity > 0:
|
||||||
# Check if item fits in inventory
|
# Check if item fits in inventory
|
||||||
item_def = ITEMS_MANAGER.get_item(loot_item['item_id'])
|
item_def = ITEMS_MANAGER.get_item(loot_item['item_id'])
|
||||||
if item_def:
|
if item_def:
|
||||||
|
# Apply loot quality bonus for resources and consumables
|
||||||
|
if getattr(item_def, 'category', item_def.type) in ['resource', 'consumable'] and loot_quality > 1.0:
|
||||||
|
bonus_chance = loot_quality - 1.0
|
||||||
|
if random.random() < bonus_chance:
|
||||||
|
quantity += 1
|
||||||
|
|
||||||
item_weight = item_def.weight * quantity
|
item_weight = item_def.weight * quantity
|
||||||
item_volume = item_def.volume * quantity
|
item_volume = item_def.volume * quantity
|
||||||
|
|
||||||
|
|||||||
1324
api/services/combat_engine.py
Normal file
@@ -3,11 +3,27 @@ Helper utilities for game calculations and common operations.
|
|||||||
Contains distance calculations, stamina costs, capacity calculations, etc.
|
Contains distance calculations, stamina costs, capacity calculations, etc.
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
from typing import Tuple, List, Dict, Any, Union
|
import random
|
||||||
|
from typing import Tuple, List, Dict, Any, Union, Optional
|
||||||
from .. import database as db
|
from .. import database as db
|
||||||
from ..items import ItemsManager
|
from ..items import ItemsManager
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_dynamic_status_damage(effects: dict, prefix: str, target: dict) -> Optional[int]:
|
||||||
|
"""Helper to calculate status damage based on percentage over max HP."""
|
||||||
|
if f'{prefix}_percent' in effects:
|
||||||
|
target_max_hp = target.get('max_hp') or target.get('npc_max_hp', 100)
|
||||||
|
pct = effects[f'{prefix}_percent']
|
||||||
|
base_dmg = target_max_hp * pct
|
||||||
|
# +/- 20% deviation
|
||||||
|
min_dmg = max(1, int(base_dmg * 0.8))
|
||||||
|
max_dmg = max(1, int(base_dmg * 1.2))
|
||||||
|
return random.randint(min_dmg, max_dmg)
|
||||||
|
elif f'{prefix}_damage' in effects:
|
||||||
|
return effects[f'{prefix}_damage']
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> str:
|
def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> str:
|
||||||
"""Helper to safely get string from i18n object or string."""
|
"""Helper to safely get string from i18n object or string."""
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
@@ -54,6 +70,8 @@ GAME_MESSAGES = {
|
|||||||
'pvp_combat_started_broadcast': {'en': "⚔️ PvP combat started between {attacker} and {defender}!", 'es': "⚔️ ¡Combate PvP iniciado entre {attacker} y {defender}!"},
|
'pvp_combat_started_broadcast': {'en': "⚔️ PvP combat started between {attacker} and {defender}!", 'es': "⚔️ ¡Combate PvP iniciado entre {attacker} y {defender}!"},
|
||||||
'flee_success_text': {'en': "{name} fled from combat!", 'es': "¡{name} huyó del combate!"},
|
'flee_success_text': {'en': "{name} fled from combat!", 'es': "¡{name} huyó del combate!"},
|
||||||
'flee_fail_text': {'en': "{name} tried to flee but failed!", 'es': "¡{name} intentó huir pero falló!"},
|
'flee_fail_text': {'en': "{name} tried to flee but failed!", 'es': "¡{name} intentó huir pero falló!"},
|
||||||
|
'stunned_status': {'en': "💫 Stunned!", 'es': "💫 ¡Aturdido!"},
|
||||||
|
'npc_stunned_cannot_act': {'en': "💫 {npc_name} is stunned and cannot act!", 'es': "💫 ¡{npc_name} está aturdido y no puede actuar!"},
|
||||||
|
|
||||||
# Loot
|
# Loot
|
||||||
'corpse_name_npc': {'en': "{name} Corpse", 'es': "Cadáver de {name}"},
|
'corpse_name_npc': {'en': "{name} Corpse", 'es': "Cadáver de {name}"},
|
||||||
@@ -72,6 +90,8 @@ GAME_MESSAGES = {
|
|||||||
'unequipped': {'en': "Unequipped {item}", 'es': "Desequipado {item}"},
|
'unequipped': {'en': "Unequipped {item}", 'es': "Desequipado {item}"},
|
||||||
'unequip_dropped': {'en': "Unequipped {item} (dropped to ground - inventory full)", 'es': "Desequipado {item} (tirado al suelo - inventario lleno)"},
|
'unequip_dropped': {'en': "Unequipped {item} (dropped to ground - inventory full)", 'es': "Desequipado {item} (tirado al suelo - inventario lleno)"},
|
||||||
'repaired_success': {'en': "Repaired {item}! Restored {amount} durability.", 'es': "¡Reparado {item}! Restaurados {amount} puntos de durabilidad."},
|
'repaired_success': {'en': "Repaired {item}! Restored {amount} durability.", 'es': "¡Reparado {item}! Restaurados {amount} puntos de durabilidad."},
|
||||||
|
'equip_level_required': {'en': "Requires level {level} to equip", 'es': "Requiere nivel {level} para equipar"},
|
||||||
|
'equip_stat_required': {'en': "Requires {stat} {value} to equip", 'es': "Requiere {stat} {value} para equipar"},
|
||||||
|
|
||||||
# Characters/Auth
|
# Characters/Auth
|
||||||
'character_created': {'en': "Character created successfully", 'es': "Personaje creado con éxito"},
|
'character_created': {'en': "Character created successfully", 'es': "Personaje creado con éxito"},
|
||||||
@@ -99,12 +119,16 @@ GAME_MESSAGES = {
|
|||||||
'item_used': {'en': "Used {name}", 'es': "Usado {name}"},
|
'item_used': {'en': "Used {name}", 'es': "Usado {name}"},
|
||||||
'no_item': {'en': "You don't have this item", 'es': "No tienes este objeto"},
|
'no_item': {'en': "You don't have this item", 'es': "No tienes este objeto"},
|
||||||
'cannot_use': {'en': "This item cannot be used", 'es': "Este objeto no se puede usar"},
|
'cannot_use': {'en': "This item cannot be used", 'es': "Este objeto no se puede usar"},
|
||||||
|
'cannot_equip_combat': {'en': "Cannot change equipment during combat", 'es': "No puedes cambiar de equipamiento durante el combate"},
|
||||||
'cured': {'en': "Cured", 'es': "Curado"},
|
'cured': {'en': "Cured", 'es': "Curado"},
|
||||||
|
|
||||||
# Status Effects
|
# Status Effects
|
||||||
'statusDamage': {'en': "You took {damage} damage from status effects", 'es': "Has recibido {damage} de daño por efectos de estado"},
|
'statusDamage': {'en': "You took {damage} damage from status effects", 'es': "Has recibido {damage} de daño por efectos de estado"},
|
||||||
'statusHeal': {'en': "You recovered {heal} HP from status effects", 'es': "Has recuperado {heal} vida por efectos de estado"},
|
'statusHeal': {'en': "You recovered {heal} HP from status effects", 'es': "Has recuperado {heal} vida por efectos de estado"},
|
||||||
'diedFromStatus': {'en': "You died from status effects", 'es': "Has muerto por efectos de estado"},
|
'diedFromStatus': {'en': "You died from status effects", 'es': "Has muerto por efectos de estado"},
|
||||||
|
|
||||||
|
# Combat Warnings
|
||||||
|
'enemy_charging': {'en': "⚠️ {enemy} is gathering strength for a massive attack!", 'es': "⚠️ ¡{enemy} está reuniendo fuerzas para un ataque masivo!"},
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_game_message(key: str, lang: str = 'en', **kwargs) -> str:
|
def get_game_message(key: str, lang: str = 'en', **kwargs) -> str:
|
||||||
@@ -138,6 +162,36 @@ def translate_travel_message(direction: str, location_name: str, lang: str = 'en
|
|||||||
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from typing import List, Dict, Any, Tuple
|
||||||
|
from .. import database as db
|
||||||
|
|
||||||
|
async def get_resolved_player_effects(player_id: int, in_combat: bool = False) -> List[Dict]:
|
||||||
|
"""Helper to fetch and format active player effects for combat payloads."""
|
||||||
|
from ..services.skills import skills_manager
|
||||||
|
from ..services.status_effects import status_effects_manager
|
||||||
|
|
||||||
|
player_effects = []
|
||||||
|
all_effects = await db.get_player_effects(player_id)
|
||||||
|
for eff in all_effects:
|
||||||
|
if eff.get('effect_type') == 'cooldown':
|
||||||
|
continue
|
||||||
|
resolved = status_effects_manager.resolve_player_effect(
|
||||||
|
eff.get('effect_name', ''),
|
||||||
|
eff.get('effect_icon', '⚡'),
|
||||||
|
eff.get('source', ''),
|
||||||
|
skills_manager,
|
||||||
|
in_combat=in_combat
|
||||||
|
)
|
||||||
|
player_effects.append({
|
||||||
|
'name': resolved['name'],
|
||||||
|
'effect_name': eff.get('effect_name', ''), # Needed for frontend state tracking
|
||||||
|
'icon': resolved['icon'],
|
||||||
|
'ticks_remaining': eff.get('ticks_remaining', 0),
|
||||||
|
'damage_per_tick': eff.get('damage_per_tick', 0), # Needed for logic
|
||||||
|
'type': eff.get('effect_type', 'buff'),
|
||||||
|
'description': resolved['description'],
|
||||||
|
})
|
||||||
|
return player_effects
|
||||||
|
|
||||||
def create_combat_message(message_type: str, origin: str = "neutral", **data) -> dict:
|
def create_combat_message(message_type: str, origin: str = "neutral", **data) -> dict:
|
||||||
"""Create a structured combat message object.
|
"""Create a structured combat message object.
|
||||||
@@ -272,7 +326,7 @@ async def calculate_player_capacity(inventory: List[Dict[str, Any]], items_manag
|
|||||||
return current_weight, max_weight, current_volume, max_volume
|
return current_weight, max_weight, current_volume, max_volume
|
||||||
|
|
||||||
|
|
||||||
async def reduce_armor_durability(player_id: int, damage_taken: int, items_manager: ItemsManager) -> Tuple[int, List[Dict[str, Any]]]:
|
async def reduce_armor_durability(player_id: int, damage_taken: int, items_manager: ItemsManager, is_defending: bool = False) -> Tuple[int, List[Dict[str, Any]]]:
|
||||||
"""
|
"""
|
||||||
Reduce durability of equipped armor pieces when taking damage.
|
Reduce durability of equipped armor pieces when taking damage.
|
||||||
Returns: (armor_damage_absorbed, broken_armor_pieces)
|
Returns: (armor_damage_absorbed, broken_armor_pieces)
|
||||||
@@ -309,7 +363,7 @@ async def reduce_armor_durability(player_id: int, damage_taken: int, items_manag
|
|||||||
armor_absorbed = min(damage_taken // 2, total_armor)
|
armor_absorbed = min(damage_taken // 2, total_armor)
|
||||||
|
|
||||||
# Calculate durability loss for each armor piece
|
# Calculate durability loss for each armor piece
|
||||||
base_reduction_rate = 0.1
|
base_reduction_rate = 0.2 if is_defending else 0.1
|
||||||
broken_armor = []
|
broken_armor = []
|
||||||
|
|
||||||
for armor in equipped_armor:
|
for armor in equipped_armor:
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ class InitiateCombatRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CombatActionRequest(BaseModel):
|
class CombatActionRequest(BaseModel):
|
||||||
action: str # 'attack', 'defend', 'flee', 'use_item'
|
action: str # 'attack', 'skill', 'flee', 'use_item'
|
||||||
item_id: Optional[str] = None # For use_item action
|
item_id: Optional[str] = None # For use_item action
|
||||||
|
skill_id: Optional[str] = None # For skill action
|
||||||
|
|
||||||
|
|
||||||
class PvPCombatInitiateRequest(BaseModel):
|
class PvPCombatInitiateRequest(BaseModel):
|
||||||
@@ -88,12 +89,13 @@ class PvPCombatInitiateRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class PvPAcknowledgeRequest(BaseModel):
|
class PvPAcknowledgeRequest(BaseModel):
|
||||||
pass # No body needed
|
combat_id: int
|
||||||
|
|
||||||
|
|
||||||
class PvPCombatActionRequest(BaseModel):
|
class PvPCombatActionRequest(BaseModel):
|
||||||
action: str # 'attack', 'defend', 'flee', 'use_item'
|
action: str # 'attack', 'skill', 'flee', 'use_item'
|
||||||
item_id: Optional[str] = None # For use_item action
|
item_id: Optional[str] = None # For use_item action
|
||||||
|
skill_id: Optional[str] = None # For skill action
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
182
api/services/skills.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"""
|
||||||
|
Skills service - loads skill definitions and provides skill availability logic.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class Skill:
|
||||||
|
"""Represents a combat skill."""
|
||||||
|
def __init__(self, skill_id: str, data: Dict[str, Any]):
|
||||||
|
self.id = skill_id
|
||||||
|
self.name = data.get('name', skill_id)
|
||||||
|
self.description = data.get('description', '')
|
||||||
|
self.icon = data.get('icon', '⚔️')
|
||||||
|
self.stat_requirement = data.get('stat_requirement', 'strength')
|
||||||
|
self.stat_threshold = data.get('stat_threshold', 0)
|
||||||
|
self.level_requirement = data.get('level_requirement', 1)
|
||||||
|
self.cooldown = data.get('cooldown', 3)
|
||||||
|
self.stamina_cost = data.get('stamina_cost', 5)
|
||||||
|
self.effects = data.get('effects', {})
|
||||||
|
|
||||||
|
|
||||||
|
class SkillsManager:
|
||||||
|
"""Loads and manages skill definitions from JSON."""
|
||||||
|
|
||||||
|
def __init__(self, gamedata_path: str = "./gamedata"):
|
||||||
|
self.gamedata_path = Path(gamedata_path)
|
||||||
|
self.skills: Dict[str, Skill] = {}
|
||||||
|
self.load_skills()
|
||||||
|
|
||||||
|
def load_skills(self):
|
||||||
|
"""Load skills from skills.json."""
|
||||||
|
json_path = self.gamedata_path / 'skills.json'
|
||||||
|
try:
|
||||||
|
with open(json_path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
for skill_id, skill_data in data.get('skills', {}).items():
|
||||||
|
self.skills[skill_id] = Skill(skill_id, skill_data)
|
||||||
|
|
||||||
|
print(f"⚔️ Loaded {len(self.skills)} skills")
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("⚠️ skills.json not found")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error loading skills.json: {e}")
|
||||||
|
|
||||||
|
def get_skill(self, skill_id: str) -> Optional[Skill]:
|
||||||
|
"""Get a skill by ID."""
|
||||||
|
return self.skills.get(skill_id)
|
||||||
|
|
||||||
|
def get_all_skills(self) -> Dict[str, Skill]:
|
||||||
|
"""Get all skills."""
|
||||||
|
return self.skills
|
||||||
|
|
||||||
|
def get_available_skills(self, character: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all skills available to a character based on their stats and level.
|
||||||
|
Returns list of skill dicts with availability info.
|
||||||
|
"""
|
||||||
|
available = []
|
||||||
|
for skill_id, skill in self.skills.items():
|
||||||
|
# Skip NPC-only skills (assumed to be those with 0 stat threshold and level 1 requirement)
|
||||||
|
if (skill.stat_threshold <= 0 and skill.level_requirement <= 1) or getattr(skill, 'npc_only', False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
stat_value = character.get(skill.stat_requirement, 0)
|
||||||
|
level = character.get('level', 1)
|
||||||
|
|
||||||
|
unlocked = (stat_value >= skill.stat_threshold and level >= skill.level_requirement)
|
||||||
|
|
||||||
|
skill_info = {
|
||||||
|
"id": skill.id,
|
||||||
|
"name": skill.name,
|
||||||
|
"description": skill.description,
|
||||||
|
"icon": skill.icon,
|
||||||
|
"stat_requirement": skill.stat_requirement,
|
||||||
|
"stat_threshold": skill.stat_threshold,
|
||||||
|
"level_requirement": skill.level_requirement,
|
||||||
|
"cooldown": skill.cooldown,
|
||||||
|
"stamina_cost": skill.stamina_cost,
|
||||||
|
"unlocked": unlocked,
|
||||||
|
"effects": skill.effects,
|
||||||
|
}
|
||||||
|
available.append(skill_info)
|
||||||
|
|
||||||
|
return available
|
||||||
|
|
||||||
|
|
||||||
|
class Perk:
|
||||||
|
"""Represents a passive perk."""
|
||||||
|
def __init__(self, perk_id: str, data: Dict[str, Any]):
|
||||||
|
self.id = perk_id
|
||||||
|
self.name = data.get('name', perk_id)
|
||||||
|
self.description = data.get('description', '')
|
||||||
|
self.icon = data.get('icon', '⭐')
|
||||||
|
self.requirements = data.get('requirements', {})
|
||||||
|
self.effects = data.get('effects', {})
|
||||||
|
|
||||||
|
|
||||||
|
class PerksManager:
|
||||||
|
"""Loads and manages perk definitions from JSON."""
|
||||||
|
|
||||||
|
def __init__(self, gamedata_path: str = "./gamedata"):
|
||||||
|
self.gamedata_path = Path(gamedata_path)
|
||||||
|
self.perks: Dict[str, Perk] = {}
|
||||||
|
self.load_perks()
|
||||||
|
|
||||||
|
def load_perks(self):
|
||||||
|
"""Load perks from perks.json."""
|
||||||
|
json_path = self.gamedata_path / 'perks.json'
|
||||||
|
try:
|
||||||
|
with open(json_path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
for perk_id, perk_data in data.get('perks', {}).items():
|
||||||
|
self.perks[perk_id] = Perk(perk_id, perk_data)
|
||||||
|
|
||||||
|
print(f"⭐ Loaded {len(self.perks)} perks")
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("⚠️ perks.json not found")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error loading perks.json: {e}")
|
||||||
|
|
||||||
|
def get_perk(self, perk_id: str) -> Optional[Perk]:
|
||||||
|
"""Get a perk by ID."""
|
||||||
|
return self.perks.get(perk_id)
|
||||||
|
|
||||||
|
def get_all_perks(self) -> Dict[str, Perk]:
|
||||||
|
"""Get all perks."""
|
||||||
|
return self.perks
|
||||||
|
|
||||||
|
def check_requirements(self, perk: Perk, character: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if a character meets a perk's requirements."""
|
||||||
|
for req_key, req_value in perk.requirements.items():
|
||||||
|
if req_key.endswith('_max'):
|
||||||
|
# Max constraint (e.g., endurance_max: 8 means END must be ≤ 8)
|
||||||
|
stat_name = req_key.replace('_max', '')
|
||||||
|
if character.get(stat_name, 0) > req_value:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Min constraint
|
||||||
|
if character.get(req_key, 0) < req_value:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_available_perks(self, character: Dict[str, Any], owned_perk_ids: List[str]) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all perks with availability status for a character.
|
||||||
|
"""
|
||||||
|
available = []
|
||||||
|
for perk_id, perk in self.perks.items():
|
||||||
|
meets_requirements = self.check_requirements(perk, character)
|
||||||
|
owned = perk_id in owned_perk_ids
|
||||||
|
|
||||||
|
perk_info = {
|
||||||
|
"id": perk.id,
|
||||||
|
"name": perk.name,
|
||||||
|
"description": perk.description,
|
||||||
|
"icon": perk.icon,
|
||||||
|
"requirements": perk.requirements,
|
||||||
|
"effects": perk.effects,
|
||||||
|
"meets_requirements": meets_requirements,
|
||||||
|
"owned": owned,
|
||||||
|
}
|
||||||
|
available.append(perk_info)
|
||||||
|
|
||||||
|
return available
|
||||||
|
|
||||||
|
|
||||||
|
# Perk points per level
|
||||||
|
PERK_POINT_INTERVAL = 5 # Every 5 levels
|
||||||
|
|
||||||
|
|
||||||
|
def get_total_perk_points(level: int) -> int:
|
||||||
|
"""Calculate total perk points available for a given level."""
|
||||||
|
return level // PERK_POINT_INTERVAL
|
||||||
|
|
||||||
|
|
||||||
|
# Global instances
|
||||||
|
skills_manager = SkillsManager()
|
||||||
|
perks_manager = PerksManager()
|
||||||
278
api/services/stats.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
"""
|
||||||
|
Central stat calculation service.
|
||||||
|
All derived stats are computed here from base attributes + equipment + buffs.
|
||||||
|
Results are cached in Redis and invalidated on any change.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
from .. import database as db
|
||||||
|
from ..items import items_manager as ITEMS_MANAGER
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Game Config (raise per expansion) ───
|
||||||
|
STAT_CAP = 50 # Max base stat points per attribute
|
||||||
|
MAX_LEVEL = 60 # Max character level
|
||||||
|
POINTS_PER_LEVEL = 1 # Stat points granted per level
|
||||||
|
|
||||||
|
|
||||||
|
async def calculate_derived_stats(character_id: int, redis_mgr=None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Calculate all derived stats for a character.
|
||||||
|
Checks Redis cache first; if miss, computes from DB and caches result.
|
||||||
|
|
||||||
|
Returns dict with all derived stat values.
|
||||||
|
"""
|
||||||
|
# 1. Check Redis cache
|
||||||
|
if redis_mgr and redis_mgr.redis_client:
|
||||||
|
try:
|
||||||
|
cached = await redis_mgr.redis_client.get(f"stats:{character_id}")
|
||||||
|
if cached:
|
||||||
|
return json.loads(cached)
|
||||||
|
except Exception:
|
||||||
|
pass # Graceful degradation — recalculate if Redis fails
|
||||||
|
|
||||||
|
# 2. Fetch data from DB
|
||||||
|
char = await db.get_player_by_id(character_id)
|
||||||
|
if not char:
|
||||||
|
return _empty_stats()
|
||||||
|
|
||||||
|
raw_equipment = await db.get_all_equipment(character_id)
|
||||||
|
enriched_equipment = {}
|
||||||
|
|
||||||
|
for slot, item_data in raw_equipment.items():
|
||||||
|
if not item_data or not item_data.get('item_id'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
inv_item = await db.get_inventory_item_by_id(item_data['item_id'])
|
||||||
|
if not inv_item:
|
||||||
|
continue
|
||||||
|
|
||||||
|
enriched_item = {
|
||||||
|
'item_id': inv_item['item_id'], # String ID
|
||||||
|
'inventory_id': item_data['item_id']
|
||||||
|
}
|
||||||
|
|
||||||
|
unique_item_id = inv_item.get('unique_item_id')
|
||||||
|
if unique_item_id:
|
||||||
|
unique_item = await db.get_unique_item(unique_item_id)
|
||||||
|
if unique_item and unique_item.get('unique_stats'):
|
||||||
|
enriched_item['unique_stats'] = unique_item['unique_stats']
|
||||||
|
|
||||||
|
enriched_equipment[slot] = enriched_item
|
||||||
|
|
||||||
|
effects = await db.get_player_effects(character_id)
|
||||||
|
|
||||||
|
# 3. Fetch owned perks
|
||||||
|
owned_perks = await db.get_character_perks(character_id)
|
||||||
|
owned_perk_ids = [row['perk_id'] for row in owned_perks]
|
||||||
|
|
||||||
|
# 4. Compute derived stats
|
||||||
|
stats = _compute_stats(char, enriched_equipment, effects, owned_perk_ids)
|
||||||
|
|
||||||
|
# 5. Cache in Redis (5 min TTL)
|
||||||
|
if redis_mgr and redis_mgr.redis_client:
|
||||||
|
try:
|
||||||
|
await redis_mgr.redis_client.setex(
|
||||||
|
f"stats:{character_id}", 300, json.dumps(stats)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_stats(char: Dict[str, Any], equipment: Dict[str, Any], effects: List[Dict], perk_ids: List[str] = None) -> Dict[str, Any]:
|
||||||
|
"""Pure computation of derived stats from base data."""
|
||||||
|
strength = char.get('strength', 0)
|
||||||
|
agility = char.get('agility', 0)
|
||||||
|
endurance = char.get('endurance', 0)
|
||||||
|
intellect = char.get('intellect', 0)
|
||||||
|
level = char.get('level', 1)
|
||||||
|
if perk_ids is None:
|
||||||
|
perk_ids = []
|
||||||
|
|
||||||
|
# ─── Base derived stats from attributes ───
|
||||||
|
attack_power = 5 + int(strength * 1.5) + level
|
||||||
|
crit_chance = 0.05 + (agility * 0.005)
|
||||||
|
crit_damage = 1.5 + (strength * 0.01)
|
||||||
|
dodge_chance = min(0.25, 0.02 + (agility * 0.005)) # Cap 25%
|
||||||
|
flee_chance_base = 0.4 + (agility * 0.01)
|
||||||
|
max_hp = 30 + (endurance * 5) + (level * 3)
|
||||||
|
max_stamina = 20 + (endurance * 2) + level
|
||||||
|
status_resistance = endurance * 0.01
|
||||||
|
block_chance = 0.0
|
||||||
|
item_effectiveness = 1.0 + (intellect * 0.02)
|
||||||
|
xp_bonus = 1.0 + (intellect * 0.01)
|
||||||
|
loot_quality = 1.0 + (intellect * 0.005)
|
||||||
|
crafting_bonus = intellect * 0.01
|
||||||
|
carry_weight = 10.0 + (strength * 0.5)
|
||||||
|
|
||||||
|
# ─── Equipment bonuses ───
|
||||||
|
total_armor = 0
|
||||||
|
weapon_crit = 0.0
|
||||||
|
weapon_damage_min = 0
|
||||||
|
weapon_damage_max = 0
|
||||||
|
has_shield = False
|
||||||
|
|
||||||
|
for slot, item_data in equipment.items():
|
||||||
|
if not item_data or not item_data.get('item_id'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
item_id_str = item_data.get('item_id', '')
|
||||||
|
item_def = ITEMS_MANAGER.get_item(item_id_str)
|
||||||
|
|
||||||
|
if not item_def:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Merge base stats and unique stats
|
||||||
|
merged_stats = {}
|
||||||
|
if item_def.stats:
|
||||||
|
merged_stats.update(item_def.stats)
|
||||||
|
if item_data.get('unique_stats'):
|
||||||
|
merged_stats.update(item_data['unique_stats'])
|
||||||
|
|
||||||
|
if merged_stats:
|
||||||
|
total_armor += merged_stats.get('armor', 0)
|
||||||
|
weapon_crit += merged_stats.get('crit_chance', 0)
|
||||||
|
max_hp += merged_stats.get('max_hp', 0)
|
||||||
|
max_stamina += merged_stats.get('max_stamina', 0)
|
||||||
|
carry_weight += merged_stats.get('weight_capacity', 0)
|
||||||
|
|
||||||
|
if slot == 'weapon':
|
||||||
|
weapon_damage_min = merged_stats.get('damage_min', 0)
|
||||||
|
weapon_damage_max = merged_stats.get('damage_max', 0)
|
||||||
|
|
||||||
|
if slot == 'offhand':
|
||||||
|
has_shield = True
|
||||||
|
|
||||||
|
# Apply equipment to derived stats
|
||||||
|
crit_chance += weapon_crit
|
||||||
|
armor_reduction = total_armor / (total_armor + 50) if total_armor > 0 else 0.0
|
||||||
|
|
||||||
|
if has_shield:
|
||||||
|
block_chance = strength * 0.003
|
||||||
|
|
||||||
|
# ─── Buff effects ───
|
||||||
|
for effect in effects:
|
||||||
|
effect_name = effect.get('effect_name', '')
|
||||||
|
value = effect.get('value', 0)
|
||||||
|
# Future: apply buff modifiers here
|
||||||
|
|
||||||
|
# ─── Perk passive bonuses ───
|
||||||
|
if 'thick_skin' in perk_ids:
|
||||||
|
max_hp = int(max_hp * 1.10) # +10% max HP
|
||||||
|
if 'lucky_strike' in perk_ids:
|
||||||
|
crit_chance += 0.05 # +5% crit chance
|
||||||
|
if 'quick_learner' in perk_ids:
|
||||||
|
xp_bonus *= 1.15 # +15% XP
|
||||||
|
if 'glass_cannon' in perk_ids:
|
||||||
|
attack_power = int(attack_power * 1.30) # +30% attack
|
||||||
|
max_hp = int(max_hp * 0.80) # -20% HP
|
||||||
|
if 'survivor' in perk_ids:
|
||||||
|
max_hp = int(max_hp * 1.02) # Small HP boost from regen perk
|
||||||
|
if 'scavenger' in perk_ids:
|
||||||
|
loot_quality *= 1.10 # +10% loot quality
|
||||||
|
if 'fleet_footed' in perk_ids:
|
||||||
|
# Travel stamina reduction tracked for movement system
|
||||||
|
pass
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
# Core combat
|
||||||
|
"attack_power": attack_power,
|
||||||
|
"crit_chance": round(crit_chance, 4),
|
||||||
|
"crit_damage": round(crit_damage, 2),
|
||||||
|
"dodge_chance": round(dodge_chance, 4),
|
||||||
|
"flee_chance_base": round(flee_chance_base, 2),
|
||||||
|
# Vitals
|
||||||
|
"max_hp": max_hp,
|
||||||
|
"max_stamina": max_stamina,
|
||||||
|
# Defense
|
||||||
|
"total_armor": total_armor,
|
||||||
|
"armor_reduction": round(armor_reduction, 4),
|
||||||
|
"block_chance": round(block_chance, 4),
|
||||||
|
"status_resistance": round(status_resistance, 4),
|
||||||
|
# Utility
|
||||||
|
"item_effectiveness": round(item_effectiveness, 2),
|
||||||
|
"xp_bonus": round(xp_bonus, 2),
|
||||||
|
"loot_quality": round(loot_quality, 3),
|
||||||
|
"crafting_bonus": round(crafting_bonus, 2),
|
||||||
|
"carry_weight": round(carry_weight, 1),
|
||||||
|
# Weapon info
|
||||||
|
"weapon_damage_min": weapon_damage_min,
|
||||||
|
"weapon_damage_max": weapon_damage_max,
|
||||||
|
"has_shield": has_shield,
|
||||||
|
# Perk flags
|
||||||
|
"has_last_stand": 'last_stand' in perk_ids,
|
||||||
|
"has_resilient": 'resilient' in perk_ids,
|
||||||
|
"has_iron_fist": 'iron_fist' in perk_ids,
|
||||||
|
"has_heavy_hitter": 'heavy_hitter' in perk_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_stats() -> Dict[str, Any]:
|
||||||
|
"""Default stats for error cases."""
|
||||||
|
return {
|
||||||
|
"attack_power": 5,
|
||||||
|
"crit_chance": 0.05,
|
||||||
|
"crit_damage": 1.5,
|
||||||
|
"dodge_chance": 0.02,
|
||||||
|
"flee_chance_base": 0.4,
|
||||||
|
"max_hp": 30,
|
||||||
|
"max_stamina": 20,
|
||||||
|
"total_armor": 0,
|
||||||
|
"armor_reduction": 0.0,
|
||||||
|
"block_chance": 0.0,
|
||||||
|
"status_resistance": 0.0,
|
||||||
|
"item_effectiveness": 1.0,
|
||||||
|
"xp_bonus": 1.0,
|
||||||
|
"loot_quality": 1.0,
|
||||||
|
"crafting_bonus": 0.0,
|
||||||
|
"carry_weight": 10.0,
|
||||||
|
"weapon_damage_min": 0,
|
||||||
|
"weapon_damage_max": 0,
|
||||||
|
"has_shield": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def invalidate_stats_cache(character_id: int, redis_mgr=None):
|
||||||
|
"""
|
||||||
|
Delete cached stats for a character. Call this whenever:
|
||||||
|
- Equipment changes (equip/unequip/break)
|
||||||
|
- Stat points allocated
|
||||||
|
- Level up
|
||||||
|
- Buff applied/expired
|
||||||
|
"""
|
||||||
|
if redis_mgr and redis_mgr.redis_client:
|
||||||
|
try:
|
||||||
|
await redis_mgr.redis_client.delete(f"stats:{character_id}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Sync derived max_hp and max_stamina to the database characters table
|
||||||
|
try:
|
||||||
|
derived = await calculate_derived_stats(character_id, redis_mgr)
|
||||||
|
char = await db.get_player_by_id(character_id)
|
||||||
|
if char:
|
||||||
|
new_max_hp = derived.get('max_hp', char['max_hp'])
|
||||||
|
new_max_stamina = derived.get('max_stamina', char['max_stamina'])
|
||||||
|
|
||||||
|
if new_max_hp != char['max_hp'] or new_max_stamina != char['max_stamina']:
|
||||||
|
new_hp = min(char['hp'], new_max_hp)
|
||||||
|
new_stamina = min(char['stamina'], new_max_stamina)
|
||||||
|
await db.update_player(
|
||||||
|
character_id,
|
||||||
|
max_hp=new_max_hp,
|
||||||
|
max_stamina=new_max_stamina,
|
||||||
|
hp=new_hp,
|
||||||
|
stamina=new_stamina
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning(f"Failed to sync derived stats to DB for {character_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_flee_chance(flee_chance_base: float, enemy_level: int) -> float:
|
||||||
|
"""Calculate actual flee chance against a specific enemy."""
|
||||||
|
return max(0.1, min(0.9, flee_chance_base - (enemy_level * 0.02)))
|
||||||
101
api/services/status_effects.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""
|
||||||
|
Status Effects Manager.
|
||||||
|
Loads status effect definitions from gamedata/status_effects.json.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class StatusEffect:
|
||||||
|
"""Represents a status effect definition."""
|
||||||
|
def __init__(self, effect_id: str, data: Dict[str, Any]):
|
||||||
|
self.id = effect_id
|
||||||
|
self.icon = data.get('icon', '⚡')
|
||||||
|
self.name = data.get('name', effect_id.capitalize())
|
||||||
|
self.description = data.get('description', effect_id.capitalize())
|
||||||
|
self.type = data.get('type', 'debuff')
|
||||||
|
|
||||||
|
|
||||||
|
class StatusEffectsManager:
|
||||||
|
"""Manages status effect definitions loaded from JSON."""
|
||||||
|
|
||||||
|
def __init__(self, gamedata_path: str = "./gamedata"):
|
||||||
|
self.effects: Dict[str, StatusEffect] = {}
|
||||||
|
filepath = os.path.join(gamedata_path, 'status_effects.json')
|
||||||
|
self._load(filepath)
|
||||||
|
|
||||||
|
def _load(self, filepath: str):
|
||||||
|
"""Load status effects from a JSON file."""
|
||||||
|
try:
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
for effect_id, effect_data in data.get('effects', {}).items():
|
||||||
|
self.effects[effect_id] = StatusEffect(effect_id, effect_data)
|
||||||
|
print(f"✨ Loaded {len(self.effects)} status effects")
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("⚠️ status_effects.json not found")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error loading status_effects.json: {e}")
|
||||||
|
|
||||||
|
def get_effect(self, effect_id: str) -> Optional[StatusEffect]:
|
||||||
|
"""Get a status effect by its ID."""
|
||||||
|
return self.effects.get(effect_id)
|
||||||
|
|
||||||
|
def get_effect_info(self, effect_id: str) -> Dict[str, Any]:
|
||||||
|
"""Get effect info dict for API responses. Returns a fallback if not found."""
|
||||||
|
effect = self.effects.get(effect_id)
|
||||||
|
if effect:
|
||||||
|
return {
|
||||||
|
'name': effect.name,
|
||||||
|
'icon': effect.icon,
|
||||||
|
'description': effect.description,
|
||||||
|
'type': effect.type,
|
||||||
|
}
|
||||||
|
# Fallback for unknown effects
|
||||||
|
return {
|
||||||
|
'name': {'en': effect_id.capitalize(), 'es': effect_id.capitalize()},
|
||||||
|
'icon': '⚡',
|
||||||
|
'description': {'en': effect_id.capitalize(), 'es': effect_id.capitalize()},
|
||||||
|
'type': 'debuff',
|
||||||
|
}
|
||||||
|
|
||||||
|
def resolve_player_effect(self, effect_name: str, effect_icon: str, source: str, skills_manager=None, in_combat: bool = True) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Resolve translated name and description for a player effect.
|
||||||
|
Tries skill source first, then status_effects.json, then fallback.
|
||||||
|
"""
|
||||||
|
translated_name = effect_name
|
||||||
|
translated_desc = ''
|
||||||
|
|
||||||
|
# 1. Try to get from skill source (e.g., "skill:fortify")
|
||||||
|
if source.startswith('skill:') and skills_manager:
|
||||||
|
skill_id = source.split(':', 1)[1]
|
||||||
|
skill_def = skills_manager.get_skill(skill_id)
|
||||||
|
if skill_def:
|
||||||
|
translated_name = skill_def.name
|
||||||
|
translated_desc = skill_def.description
|
||||||
|
|
||||||
|
# 2. Try to get from status_effects.json by lowercased effect name
|
||||||
|
if not translated_desc:
|
||||||
|
effect_key = effect_name.lower()
|
||||||
|
effect = self.effects.get(effect_key)
|
||||||
|
if effect:
|
||||||
|
translated_name = effect.name
|
||||||
|
translated_desc = effect.description
|
||||||
|
|
||||||
|
# 3. Fallback: wrap the raw name as a translatable dict
|
||||||
|
if not translated_desc:
|
||||||
|
translated_desc = {'en': effect_name, 'es': effect_name}
|
||||||
|
if isinstance(translated_name, str):
|
||||||
|
translated_name = {'en': translated_name, 'es': translated_name}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': translated_name,
|
||||||
|
'icon': effect_icon or '⚡',
|
||||||
|
'description': translated_desc,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton
|
||||||
|
status_effects_manager = StatusEffectsManager()
|
||||||
77
api/setup_test_env.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
from database import Database
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# 1. Update npcs.json to add a test boss
|
||||||
|
with open('../gamedata/npcs.json', 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if 'test_boss' not in data['npcs']:
|
||||||
|
data['npcs']['test_boss'] = {
|
||||||
|
"name": {"en": "Level 50 Test Boss", "es": "Jefe de Prueba Nivel 50"},
|
||||||
|
"description": {"en": "A huge terrifying monster.", "es": "Un monstruo enorme y aterrador."},
|
||||||
|
"emoji": "👹",
|
||||||
|
"hp_min": 1000,
|
||||||
|
"hp_max": 1500,
|
||||||
|
"damage_min": 25,
|
||||||
|
"damage_max": 45,
|
||||||
|
"defense": 15,
|
||||||
|
"xp_reward": 500,
|
||||||
|
"loot_table": [],
|
||||||
|
"flee_chance": 0.0,
|
||||||
|
"status_inflict_chance": 0.5,
|
||||||
|
"death_message": {"en": "The boss is defeated.", "es": "El jefe ha sido derrotado."}
|
||||||
|
}
|
||||||
|
with open('../gamedata/npcs.json', 'w') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
print("Added 'test_boss' to npcs.json")
|
||||||
|
|
||||||
|
db = Database()
|
||||||
|
await db.connect()
|
||||||
|
|
||||||
|
# 2. Get Jocaru
|
||||||
|
player = await db.fetch_one("SELECT * FROM characters WHERE name ILIKE 'Jocaru'")
|
||||||
|
if not player:
|
||||||
|
print("Player Jocaru not found!")
|
||||||
|
return
|
||||||
|
|
||||||
|
pid = player['id']
|
||||||
|
ploc = player['location_id']
|
||||||
|
|
||||||
|
# 3. Give items
|
||||||
|
items_to_give = [
|
||||||
|
('reinforced_pack', 1),
|
||||||
|
('reinforced_bat', 1),
|
||||||
|
('knife', 1),
|
||||||
|
('first_aid_kit', 10),
|
||||||
|
('mystery_pills', 5),
|
||||||
|
('energy_bar', 10),
|
||||||
|
('molotov', 5)
|
||||||
|
]
|
||||||
|
for item_id, qty in items_to_give:
|
||||||
|
await db.add_item_to_inventory(pid, item_id, qty)
|
||||||
|
print("Granted test items and backpack.")
|
||||||
|
|
||||||
|
# 4. Give XP to reach lvl 50 if needed
|
||||||
|
# Level 50 is base + (50 * multiplier) ... the logic is in check_and_apply_level_up
|
||||||
|
await db.execute("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid", {"pid": pid})
|
||||||
|
print("Buffed Jocaru to lvl 50 manually.")
|
||||||
|
|
||||||
|
# 5. Spawn enemies at player's location
|
||||||
|
now = time.time()
|
||||||
|
despawn = now + 86400 # 1 day
|
||||||
|
|
||||||
|
enemies_to_spawn = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2
|
||||||
|
for eid in enemies_to_spawn:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)",
|
||||||
|
{"nid": eid, "loc": ploc, "start": now, "end": despawn}
|
||||||
|
)
|
||||||
|
print(f"Spawned {len(enemies_to_spawn)} enemies at {ploc}")
|
||||||
|
|
||||||
|
await db.disconnect()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -897,7 +897,7 @@
|
|||||||
"repair_percentage": 25,
|
"repair_percentage": 25,
|
||||||
"stats": {
|
"stats": {
|
||||||
"armor": 3,
|
"armor": 3,
|
||||||
"hp_bonus": 10
|
"max_hp": 10
|
||||||
},
|
},
|
||||||
"emoji": "🦺",
|
"emoji": "🦺",
|
||||||
"image_path": "images/items/leather_vest.webp",
|
"image_path": "images/items/leather_vest.webp",
|
||||||
@@ -988,7 +988,7 @@
|
|||||||
"repair_percentage": 25,
|
"repair_percentage": 25,
|
||||||
"stats": {
|
"stats": {
|
||||||
"armor": 2,
|
"armor": 2,
|
||||||
"stamina_bonus": 5
|
"max_stamina": 5
|
||||||
},
|
},
|
||||||
"emoji": "🥾",
|
"emoji": "🥾",
|
||||||
"image_path": "images/items/sturdy_boots.webp",
|
"image_path": "images/items/sturdy_boots.webp",
|
||||||
@@ -1036,7 +1036,7 @@
|
|||||||
"repair_percentage": 25,
|
"repair_percentage": 25,
|
||||||
"stats": {
|
"stats": {
|
||||||
"armor": 2,
|
"armor": 2,
|
||||||
"hp_bonus": 5
|
"max_hp": 5
|
||||||
},
|
},
|
||||||
"emoji": "👖",
|
"emoji": "👖",
|
||||||
"image_path": "images/items/padded_pants.webp",
|
"image_path": "images/items/padded_pants.webp",
|
||||||
@@ -1261,7 +1261,7 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"name": "burning",
|
"name": "burning",
|
||||||
"icon": "🔥",
|
"icon": "🔥",
|
||||||
"damage_per_tick": 3,
|
"damage_percent": 0.08,
|
||||||
"ticks": 3,
|
"ticks": 3,
|
||||||
"persist_after_combat": true
|
"persist_after_combat": true
|
||||||
}
|
}
|
||||||
@@ -1279,8 +1279,8 @@
|
|||||||
"emoji": "💨",
|
"emoji": "💨",
|
||||||
"image_path": "images/items/smoke_bomb.webp",
|
"image_path": "images/items/smoke_bomb.webp",
|
||||||
"description": {
|
"description": {
|
||||||
"en": "Creates a smoke screen. Greatly increases flee chance for 1 turn.",
|
"en": "Creates a smoke screen. Greatly increases flee chance for 1 {{interval}}.",
|
||||||
"es": "Crea una cortina de humo. Aumenta la probabilidad de huir por 1 turno."
|
"es": "Crea una cortina de humo. Aumenta la probabilidad de huir por 1 {{interval}}."
|
||||||
},
|
},
|
||||||
"stackable": true,
|
"stackable": true,
|
||||||
"combat_usable": true,
|
"combat_usable": true,
|
||||||
@@ -1328,8 +1328,8 @@
|
|||||||
"emoji": "⚡",
|
"emoji": "⚡",
|
||||||
"image_path": "images/items/adrenaline.webp",
|
"image_path": "images/items/adrenaline.webp",
|
||||||
"description": {
|
"description": {
|
||||||
"en": "Increases damage output for 2 turns. Only usable in combat.",
|
"en": "Increases damage output for 2 {{intervals_plural}}. Only usable in combat.",
|
||||||
"es": "Aumenta el daño durante 2 turnos. Solo usable en combate."
|
"es": "Aumenta el daño durante 2 {{intervals_plural}}. Solo usable en combate."
|
||||||
},
|
},
|
||||||
"stackable": true,
|
"stackable": true,
|
||||||
"consumable": true,
|
"consumable": true,
|
||||||
|
|||||||
@@ -47,6 +47,10 @@
|
|||||||
],
|
],
|
||||||
"flee_chance": 0.3,
|
"flee_chance": 0.3,
|
||||||
"status_inflict_chance": 0.15,
|
"status_inflict_chance": 0.15,
|
||||||
|
"skills": [
|
||||||
|
"rabid_bite",
|
||||||
|
"howl"
|
||||||
|
],
|
||||||
"image_path": "images/npcs/feral_dog.webp",
|
"image_path": "images/npcs/feral_dog.webp",
|
||||||
"death_message": {
|
"death_message": {
|
||||||
"en": "The feral dog whimpers and collapses. Perhaps it was just hungry...",
|
"en": "The feral dog whimpers and collapses. Perhaps it was just hungry...",
|
||||||
@@ -112,6 +116,10 @@
|
|||||||
],
|
],
|
||||||
"flee_chance": 0.2,
|
"flee_chance": 0.2,
|
||||||
"status_inflict_chance": 0.1,
|
"status_inflict_chance": 0.1,
|
||||||
|
"skills": [
|
||||||
|
"bandage_self",
|
||||||
|
"quick_slash"
|
||||||
|
],
|
||||||
"image_path": "images/npcs/raider_scout.webp",
|
"image_path": "images/npcs/raider_scout.webp",
|
||||||
"death_message": {
|
"death_message": {
|
||||||
"en": "The raider scout falls with a final gasp. Their supplies are yours.",
|
"en": "The raider scout falls with a final gasp. Their supplies are yours.",
|
||||||
@@ -159,6 +167,9 @@
|
|||||||
],
|
],
|
||||||
"flee_chance": 0.5,
|
"flee_chance": 0.5,
|
||||||
"status_inflict_chance": 0.25,
|
"status_inflict_chance": 0.25,
|
||||||
|
"skills": [
|
||||||
|
"rabid_bite"
|
||||||
|
],
|
||||||
"image_path": "images/npcs/mutant_rat.webp",
|
"image_path": "images/npcs/mutant_rat.webp",
|
||||||
"death_message": {
|
"death_message": {
|
||||||
"en": "The mutant rat squeals its last and goes still.",
|
"en": "The mutant rat squeals its last and goes still.",
|
||||||
@@ -212,6 +223,10 @@
|
|||||||
],
|
],
|
||||||
"flee_chance": 0.1,
|
"flee_chance": 0.1,
|
||||||
"status_inflict_chance": 0.3,
|
"status_inflict_chance": 0.3,
|
||||||
|
"skills": [
|
||||||
|
"rabid_bite",
|
||||||
|
"power_strike"
|
||||||
|
],
|
||||||
"image_path": "images/npcs/infected_human.webp",
|
"image_path": "images/npcs/infected_human.webp",
|
||||||
"death_message": {
|
"death_message": {
|
||||||
"en": "The infected human finally finds peace in death.",
|
"en": "The infected human finally finds peace in death.",
|
||||||
@@ -289,11 +304,46 @@
|
|||||||
],
|
],
|
||||||
"flee_chance": 0.25,
|
"flee_chance": 0.25,
|
||||||
"status_inflict_chance": 0.05,
|
"status_inflict_chance": 0.05,
|
||||||
|
"skills": [
|
||||||
|
"bandage_self",
|
||||||
|
"power_strike"
|
||||||
|
],
|
||||||
"image_path": "images/npcs/scavenger.webp",
|
"image_path": "images/npcs/scavenger.webp",
|
||||||
"death_message": {
|
"death_message": {
|
||||||
"en": "The scavenger's struggle ends. Survival has no mercy.",
|
"en": "The scavenger's struggle ends. Survival has no mercy.",
|
||||||
"es": "El deseo de supervivencia del escavador se agota. La supervivencia no tiene misericordia."
|
"es": "El deseo de supervivencia del escavador se agota. La supervivencia no tiene misericordia."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"test_boss": {
|
||||||
|
"npc_id": "test_boss",
|
||||||
|
"name": {
|
||||||
|
"en": "Level 50 Test Boss",
|
||||||
|
"es": "Jefe de Prueba Nivel 50"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "A huge terrifying monster.",
|
||||||
|
"es": "Un monstruo enorme y aterrador."
|
||||||
|
},
|
||||||
|
"image_path": "images/npcs/test_boss.webp",
|
||||||
|
"emoji": "👹",
|
||||||
|
"hp_min": 1000,
|
||||||
|
"hp_max": 2000,
|
||||||
|
"damage_min": 25,
|
||||||
|
"damage_max": 65,
|
||||||
|
"defense": 15,
|
||||||
|
"xp_reward": 500,
|
||||||
|
"loot_table": [],
|
||||||
|
"flee_chance": 0.0,
|
||||||
|
"status_inflict_chance": 0.5,
|
||||||
|
"skills": [
|
||||||
|
"howl",
|
||||||
|
"power_strike",
|
||||||
|
"crushing_blow"
|
||||||
|
],
|
||||||
|
"death_message": {
|
||||||
|
"en": "The boss is defeated.",
|
||||||
|
"es": "El jefe ha sido derrotado."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"danger_levels": {
|
"danger_levels": {
|
||||||
|
|||||||
194
gamedata/perks.json
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
{
|
||||||
|
"perks": {
|
||||||
|
"heavy_hitter": {
|
||||||
|
"name": {
|
||||||
|
"en": "Heavy Hitter",
|
||||||
|
"es": "Golpe Pesado"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "+10% damage with two-handed weapons",
|
||||||
|
"es": "+10% de daño con armas a dos manos"
|
||||||
|
},
|
||||||
|
"icon": "🔨",
|
||||||
|
"requirements": {
|
||||||
|
"strength": 10
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"two_handed_damage_bonus": 0.1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"iron_fist": {
|
||||||
|
"name": {
|
||||||
|
"en": "Iron Fist",
|
||||||
|
"es": "Puño de Hierro"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Unarmed attacks deal STR × 1 damage",
|
||||||
|
"es": "Los ataques sin armas hacen STR × 1 de daño"
|
||||||
|
},
|
||||||
|
"icon": "👊",
|
||||||
|
"requirements": {
|
||||||
|
"strength": 20
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"unarmed_str_scaling": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fleet_footed": {
|
||||||
|
"name": {
|
||||||
|
"en": "Fleet Footed",
|
||||||
|
"es": "Pies Ligeros"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "-20% stamina cost on travel",
|
||||||
|
"es": "-20% de coste de aguante al viajar"
|
||||||
|
},
|
||||||
|
"icon": "🏃",
|
||||||
|
"requirements": {
|
||||||
|
"agility": 10
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"travel_stamina_reduction": 0.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lucky_strike": {
|
||||||
|
"name": {
|
||||||
|
"en": "Lucky Strike",
|
||||||
|
"es": "Golpe de Suerte"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "+5% crit chance",
|
||||||
|
"es": "+5% de probabilidad de crítico"
|
||||||
|
},
|
||||||
|
"icon": "🍀",
|
||||||
|
"requirements": {
|
||||||
|
"agility": 20
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"crit_chance_bonus": 0.05
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thick_skin": {
|
||||||
|
"name": {
|
||||||
|
"en": "Thick Skin",
|
||||||
|
"es": "Piel Gruesa"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "+10% max HP",
|
||||||
|
"es": "+10% de vida máxima"
|
||||||
|
},
|
||||||
|
"icon": "🛡️",
|
||||||
|
"requirements": {
|
||||||
|
"endurance": 10
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"max_hp_bonus_percent": 0.1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resilient": {
|
||||||
|
"name": {
|
||||||
|
"en": "Resilient",
|
||||||
|
"es": "Resistente"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Status effects last 1 fewer {{interval}} (min 1)",
|
||||||
|
"es": "Los efectos de estado duran 1 {{interval}} menos (mín 1)"
|
||||||
|
},
|
||||||
|
"icon": "💪",
|
||||||
|
"requirements": {
|
||||||
|
"endurance": 20
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"status_duration_reduction": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quick_learner": {
|
||||||
|
"name": {
|
||||||
|
"en": "Quick Learner",
|
||||||
|
"es": "Aprendiz Rápido"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "+15% XP gain",
|
||||||
|
"es": "+15% de experiencia ganada"
|
||||||
|
},
|
||||||
|
"icon": "📖",
|
||||||
|
"requirements": {
|
||||||
|
"intellect": 10
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"xp_bonus": 0.15
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scavenger": {
|
||||||
|
"name": {
|
||||||
|
"en": "Scavenger",
|
||||||
|
"es": "Carroñero"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "+1 quantity on consumable/resource drops",
|
||||||
|
"es": "+1 cantidad en drops de consumibles/recursos"
|
||||||
|
},
|
||||||
|
"icon": "🦅",
|
||||||
|
"requirements": {
|
||||||
|
"intellect": 20
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"consumable_loot_bonus": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"survivor": {
|
||||||
|
"name": {
|
||||||
|
"en": "Survivor",
|
||||||
|
"es": "Superviviente"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Heal 2% max HP every combat turn",
|
||||||
|
"es": "Cura 2% de vida máxima cada turno de combate"
|
||||||
|
},
|
||||||
|
"icon": "❤️🩹",
|
||||||
|
"requirements": {
|
||||||
|
"endurance": 15,
|
||||||
|
"agility": 10
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"combat_regen_percent": 0.02
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"glass_cannon": {
|
||||||
|
"name": {
|
||||||
|
"en": "Glass Cannon",
|
||||||
|
"es": "Cañón de Cristal"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "+30% damage, -20% max HP",
|
||||||
|
"es": "+30% de daño, -20% de vida máxima"
|
||||||
|
},
|
||||||
|
"icon": "💣",
|
||||||
|
"requirements": {
|
||||||
|
"strength": 20,
|
||||||
|
"endurance_max": 8
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"damage_bonus": 0.3,
|
||||||
|
"max_hp_penalty_percent": 0.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"last_stand": {
|
||||||
|
"name": {
|
||||||
|
"en": "Last Stand",
|
||||||
|
"es": "Última Resistencia"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Once per combat, survive lethal damage with 1 HP",
|
||||||
|
"es": "Una vez por combate, sobrevive daño letal con 1 de vida"
|
||||||
|
},
|
||||||
|
"icon": "💀",
|
||||||
|
"requirements": {
|
||||||
|
"endurance": 30
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"cheat_death": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
393
gamedata/skills.json
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
{
|
||||||
|
"skills": {
|
||||||
|
"power_strike": {
|
||||||
|
"name": {
|
||||||
|
"en": "Power Strike",
|
||||||
|
"es": "Golpe Poderoso"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "A devastating blow with 20% chance to stun",
|
||||||
|
"es": "Un golpe devastador con 20% de probabilidad de aturdir"
|
||||||
|
},
|
||||||
|
"icon": "💥",
|
||||||
|
"stat_requirement": "strength",
|
||||||
|
"stat_threshold": 8,
|
||||||
|
"level_requirement": 5,
|
||||||
|
"cooldown": 3,
|
||||||
|
"stamina_cost": 5,
|
||||||
|
"effects": {
|
||||||
|
"damage_multiplier": 1.8,
|
||||||
|
"stun_chance": 0.2,
|
||||||
|
"stun_duration": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"crushing_blow": {
|
||||||
|
"name": {
|
||||||
|
"en": "Crushing Blow",
|
||||||
|
"es": "Golpe Aplastante"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Heavy strike that ignores 50% of enemy armor",
|
||||||
|
"es": "Golpe pesado que ignora el 50% de la armadura enemiga"
|
||||||
|
},
|
||||||
|
"icon": "🔨",
|
||||||
|
"stat_requirement": "strength",
|
||||||
|
"stat_threshold": 15,
|
||||||
|
"level_requirement": 12,
|
||||||
|
"cooldown": 4,
|
||||||
|
"stamina_cost": 7,
|
||||||
|
"effects": {
|
||||||
|
"damage_multiplier": 1.5,
|
||||||
|
"armor_penetration": 0.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"berserker_rage": {
|
||||||
|
"name": {
|
||||||
|
"en": "Berserker Rage",
|
||||||
|
"es": "Furia Berserker"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "+50% damage for 3 {{intervals_plural}}, but +25% damage taken",
|
||||||
|
"es": "+50% de daño durante 3 {{intervals_plural}}, pero +25% de daño recibido"
|
||||||
|
},
|
||||||
|
"icon": "🔥",
|
||||||
|
"stat_requirement": "strength",
|
||||||
|
"stat_threshold": 25,
|
||||||
|
"level_requirement": 20,
|
||||||
|
"cooldown": 6,
|
||||||
|
"stamina_cost": 10,
|
||||||
|
"effects": {
|
||||||
|
"buff": "berserker_rage",
|
||||||
|
"buff_duration": 3,
|
||||||
|
"damage_bonus": 0.5,
|
||||||
|
"damage_taken_increase": 0.25
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"execution": {
|
||||||
|
"name": {
|
||||||
|
"en": "Execution",
|
||||||
|
"es": "Ejecución"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "300% damage if target below 25% HP, else 100%",
|
||||||
|
"es": "300% de daño si el objetivo está por debajo del 25% de vida, si no 100%"
|
||||||
|
},
|
||||||
|
"icon": "⚰️",
|
||||||
|
"stat_requirement": "strength",
|
||||||
|
"stat_threshold": 40,
|
||||||
|
"level_requirement": 35,
|
||||||
|
"cooldown": 8,
|
||||||
|
"stamina_cost": 8,
|
||||||
|
"effects": {
|
||||||
|
"damage_multiplier": 1.0,
|
||||||
|
"execute_threshold": 0.25,
|
||||||
|
"execute_multiplier": 3.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quick_slash": {
|
||||||
|
"name": {
|
||||||
|
"en": "Quick Slash",
|
||||||
|
"es": "Tajo Rápido"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Attack twice at 60% power each",
|
||||||
|
"es": "Ataca dos veces al 60% de poder cada una"
|
||||||
|
},
|
||||||
|
"icon": "🗡️",
|
||||||
|
"stat_requirement": "agility",
|
||||||
|
"stat_threshold": 8,
|
||||||
|
"level_requirement": 5,
|
||||||
|
"cooldown": 2,
|
||||||
|
"stamina_cost": 3,
|
||||||
|
"effects": {
|
||||||
|
"hits": 2,
|
||||||
|
"damage_multiplier": 0.6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"evade": {
|
||||||
|
"name": {
|
||||||
|
"en": "Evade",
|
||||||
|
"es": "Evadir"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Guaranteed dodge on the next incoming attack",
|
||||||
|
"es": "Esquivar garantizado en el próximo ataque recibido"
|
||||||
|
},
|
||||||
|
"icon": "🏃",
|
||||||
|
"stat_requirement": "agility",
|
||||||
|
"stat_threshold": 15,
|
||||||
|
"level_requirement": 12,
|
||||||
|
"cooldown": 3,
|
||||||
|
"stamina_cost": 4,
|
||||||
|
"effects": {
|
||||||
|
"buff": "evade",
|
||||||
|
"buff_duration": 1,
|
||||||
|
"guaranteed_dodge": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"poisoned_blade": {
|
||||||
|
"name": {
|
||||||
|
"en": "Poisoned Blade",
|
||||||
|
"es": "Hoja Envenenada"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "80% damage + poison (5% max HP/{{interval}} for 4 {{intervals_plural}})",
|
||||||
|
"es": "80% de daño + veneno (5% vida máx/{{interval}} durante 4 {{intervals_plural}})"
|
||||||
|
},
|
||||||
|
"icon": "🧪",
|
||||||
|
"stat_requirement": "agility",
|
||||||
|
"stat_threshold": 25,
|
||||||
|
"level_requirement": 20,
|
||||||
|
"cooldown": 5,
|
||||||
|
"stamina_cost": 6,
|
||||||
|
"effects": {
|
||||||
|
"damage_multiplier": 0.8,
|
||||||
|
"poison_percent": 0.05,
|
||||||
|
"poison_duration": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shadow_strike": {
|
||||||
|
"name": {
|
||||||
|
"en": "Shadow Strike",
|
||||||
|
"es": "Golpe Sombrío"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "250% damage, guaranteed critical hit",
|
||||||
|
"es": "250% de daño, golpe crítico garantizado"
|
||||||
|
},
|
||||||
|
"icon": "🌑",
|
||||||
|
"stat_requirement": "agility",
|
||||||
|
"stat_threshold": 40,
|
||||||
|
"level_requirement": 35,
|
||||||
|
"cooldown": 7,
|
||||||
|
"stamina_cost": 8,
|
||||||
|
"effects": {
|
||||||
|
"damage_multiplier": 2.5,
|
||||||
|
"guaranteed_crit": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fortify": {
|
||||||
|
"name": {
|
||||||
|
"en": "Fortify",
|
||||||
|
"es": "Fortificar"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Reduce incoming damage by 60% for 2 {{intervals_plural}}",
|
||||||
|
"es": "Reduce el daño recibido en un 60% durante 2 {{intervals_plural}}"
|
||||||
|
},
|
||||||
|
"icon": "🛡️",
|
||||||
|
"stat_requirement": "endurance",
|
||||||
|
"stat_threshold": 8,
|
||||||
|
"level_requirement": 5,
|
||||||
|
"cooldown": 3,
|
||||||
|
"stamina_cost": 4,
|
||||||
|
"effects": {
|
||||||
|
"buff": "fortify",
|
||||||
|
"buff_duration": 2,
|
||||||
|
"damage_reduction": 0.6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"second_wind": {
|
||||||
|
"name": {
|
||||||
|
"en": "Second Wind",
|
||||||
|
"es": "Segundo Aliento"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Restore 20% of max HP",
|
||||||
|
"es": "Restaura el 20% de la vida máxima"
|
||||||
|
},
|
||||||
|
"icon": "💚",
|
||||||
|
"stat_requirement": "endurance",
|
||||||
|
"stat_threshold": 15,
|
||||||
|
"level_requirement": 12,
|
||||||
|
"cooldown": 5,
|
||||||
|
"stamina_cost": 6,
|
||||||
|
"effects": {
|
||||||
|
"heal_percent": 0.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"iron_skin": {
|
||||||
|
"name": {
|
||||||
|
"en": "Iron Skin",
|
||||||
|
"es": "Piel de Hierro"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Immune to status effects for 3 {{intervals_plural}}",
|
||||||
|
"es": "Inmune a efectos de estado durante 3 {{intervals_plural}}"
|
||||||
|
},
|
||||||
|
"icon": "🪨",
|
||||||
|
"stat_requirement": "endurance",
|
||||||
|
"stat_threshold": 25,
|
||||||
|
"level_requirement": 20,
|
||||||
|
"cooldown": 6,
|
||||||
|
"stamina_cost": 8,
|
||||||
|
"effects": {
|
||||||
|
"buff": "iron_skin",
|
||||||
|
"buff_duration": 3,
|
||||||
|
"status_immunity": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"adrenaline_rush": {
|
||||||
|
"name": {
|
||||||
|
"en": "Adrenaline Rush",
|
||||||
|
"es": "Subida de Adrenalina"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Restore 30% of max stamina (free to use)",
|
||||||
|
"es": "Restaura el 30% del aguante máximo (sin coste)"
|
||||||
|
},
|
||||||
|
"icon": "⚡",
|
||||||
|
"stat_requirement": "endurance",
|
||||||
|
"stat_threshold": 40,
|
||||||
|
"level_requirement": 35,
|
||||||
|
"cooldown": 8,
|
||||||
|
"stamina_cost": 0,
|
||||||
|
"effects": {
|
||||||
|
"stamina_restore_percent": 0.3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"analyze": {
|
||||||
|
"name": {
|
||||||
|
"en": "Analyze",
|
||||||
|
"es": "Analizar"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Reveal enemy HP%, next attack, and weakness",
|
||||||
|
"es": "Revela el % de vida del enemigo, su próximo ataque y debilidad"
|
||||||
|
},
|
||||||
|
"icon": "🔍",
|
||||||
|
"stat_requirement": "intellect",
|
||||||
|
"stat_threshold": 8,
|
||||||
|
"level_requirement": 5,
|
||||||
|
"cooldown": 2,
|
||||||
|
"stamina_cost": 2,
|
||||||
|
"effects": {
|
||||||
|
"reveal_hp": true,
|
||||||
|
"reveal_intent": true,
|
||||||
|
"mark_analyzed": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exploit_weakness": {
|
||||||
|
"name": {
|
||||||
|
"en": "Exploit Weakness",
|
||||||
|
"es": "Explotar Debilidad"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "200% damage if Analyze was used this combat",
|
||||||
|
"es": "200% de daño si se usó Analizar en este combate"
|
||||||
|
},
|
||||||
|
"icon": "🎯",
|
||||||
|
"stat_requirement": "intellect",
|
||||||
|
"stat_threshold": 15,
|
||||||
|
"level_requirement": 12,
|
||||||
|
"cooldown": 4,
|
||||||
|
"stamina_cost": 5,
|
||||||
|
"effects": {
|
||||||
|
"damage_multiplier": 2.0,
|
||||||
|
"requires_analyzed": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"drain_life": {
|
||||||
|
"name": {
|
||||||
|
"en": "Drain Life",
|
||||||
|
"es": "Drenar Vida"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "100% damage, heal for 50% of damage dealt",
|
||||||
|
"es": "100% de daño, cura el 50% del daño causado"
|
||||||
|
},
|
||||||
|
"icon": "🩸",
|
||||||
|
"stat_requirement": "intellect",
|
||||||
|
"stat_threshold": 25,
|
||||||
|
"level_requirement": 20,
|
||||||
|
"cooldown": 5,
|
||||||
|
"stamina_cost": 6,
|
||||||
|
"effects": {
|
||||||
|
"damage_multiplier": 1.0,
|
||||||
|
"lifesteal": 0.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foresight": {
|
||||||
|
"name": {
|
||||||
|
"en": "Foresight",
|
||||||
|
"es": "Premonición"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Enemy's next 2 attacks automatically miss",
|
||||||
|
"es": "Los próximos 2 ataques del enemigo fallan automáticamente"
|
||||||
|
},
|
||||||
|
"icon": "👁️",
|
||||||
|
"stat_requirement": "intellect",
|
||||||
|
"stat_threshold": 40,
|
||||||
|
"level_requirement": 35,
|
||||||
|
"cooldown": 7,
|
||||||
|
"stamina_cost": 7,
|
||||||
|
"effects": {
|
||||||
|
"buff": "foresight",
|
||||||
|
"buff_duration": 2,
|
||||||
|
"enemy_miss": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rabid_bite": {
|
||||||
|
"name": {
|
||||||
|
"en": "Rabid Bite",
|
||||||
|
"es": "Mordedura Rabiosa"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "A vicious bite that can infect the target with poison",
|
||||||
|
"es": "Una mordedura feroz que puede infectar al objetivo con veneno"
|
||||||
|
},
|
||||||
|
"icon": "🦷",
|
||||||
|
"stat_requirement": "agility",
|
||||||
|
"stat_threshold": 0,
|
||||||
|
"level_requirement": 1,
|
||||||
|
"cooldown": 4,
|
||||||
|
"stamina_cost": 0,
|
||||||
|
"effects": {
|
||||||
|
"damage_multiplier": 1.2,
|
||||||
|
"poison_percent": 0.04,
|
||||||
|
"poison_duration": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"howl": {
|
||||||
|
"name": {
|
||||||
|
"en": "Howl",
|
||||||
|
"es": "Aullido"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Increases damage by 50% for 3 {{intervals_plural}}",
|
||||||
|
"es": "Aumenta el daño en un 50% durante 3 {{intervals_plural}}"
|
||||||
|
},
|
||||||
|
"icon": "🐺",
|
||||||
|
"stat_requirement": "strength",
|
||||||
|
"stat_threshold": 0,
|
||||||
|
"level_requirement": 1,
|
||||||
|
"cooldown": 8,
|
||||||
|
"stamina_cost": 0,
|
||||||
|
"effects": {
|
||||||
|
"buff": "berserker_rage",
|
||||||
|
"buff_duration": 3,
|
||||||
|
"damage_bonus": 0.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bandage_self": {
|
||||||
|
"name": {
|
||||||
|
"en": "Bandage Self",
|
||||||
|
"es": "Vendarse"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Restores 25% of maximum HP",
|
||||||
|
"es": "Restaura el 25% de la vida máxima"
|
||||||
|
},
|
||||||
|
"icon": "🩹",
|
||||||
|
"stat_requirement": "intellect",
|
||||||
|
"stat_threshold": 0,
|
||||||
|
"level_requirement": 1,
|
||||||
|
"cooldown": 6,
|
||||||
|
"stamina_cost": 0,
|
||||||
|
"effects": {
|
||||||
|
"heal_percent": 0.25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
gamedata/status_effects.json
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"effects": {
|
||||||
|
"poison": {
|
||||||
|
"icon": "🧪",
|
||||||
|
"name": {
|
||||||
|
"en": "Poison",
|
||||||
|
"es": "Veneno"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Deals damage each {{interval}}",
|
||||||
|
"es": "Inflige daño cada {{interval}}"
|
||||||
|
},
|
||||||
|
"type": "damage"
|
||||||
|
},
|
||||||
|
"stun": {
|
||||||
|
"icon": "💫",
|
||||||
|
"name": {
|
||||||
|
"en": "Stunned",
|
||||||
|
"es": "Aturdido"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Cannot act this {{interval}}",
|
||||||
|
"es": "No puede actuar este {{interval}}"
|
||||||
|
},
|
||||||
|
"type": "debuff"
|
||||||
|
},
|
||||||
|
"analyzed": {
|
||||||
|
"icon": "🔍",
|
||||||
|
"name": {
|
||||||
|
"en": "Analyzed",
|
||||||
|
"es": "Analizado"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Weakness exposed, vulnerable to Exploit Weakness",
|
||||||
|
"es": "Debilidad expuesta, vulnerable a Explotar Debilidad"
|
||||||
|
},
|
||||||
|
"type": "debuff"
|
||||||
|
},
|
||||||
|
"bleeding": {
|
||||||
|
"icon": "🩸",
|
||||||
|
"name": {
|
||||||
|
"en": "Bleeding",
|
||||||
|
"es": "Sangrado"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Losing blood each {{interval}}",
|
||||||
|
"es": "Pierde sangre cada {{interval}}"
|
||||||
|
},
|
||||||
|
"type": "damage"
|
||||||
|
},
|
||||||
|
"burning": {
|
||||||
|
"icon": "🔥",
|
||||||
|
"name": {
|
||||||
|
"en": "Burning",
|
||||||
|
"es": "Ardiendo"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Takes fire damage each {{interval}}",
|
||||||
|
"es": "Recibe daño de fuego cada {{interval}}"
|
||||||
|
},
|
||||||
|
"type": "damage"
|
||||||
|
},
|
||||||
|
"regeneration": {
|
||||||
|
"icon": "💚",
|
||||||
|
"name": {
|
||||||
|
"en": "Regeneration",
|
||||||
|
"es": "Regeneración"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Recovers HP every {{interval}}",
|
||||||
|
"es": "Recupera PS cada {{interval}}"
|
||||||
|
},
|
||||||
|
"type": "buff"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
images-source/interactables/dumpster.jpeg
Normal file
|
After Width: | Height: | Size: 434 KiB |
BIN
images-source/interactables/house.jpeg
Normal file
|
After Width: | Height: | Size: 611 KiB |
BIN
images-source/interactables/medkit.jpeg
Normal file
|
After Width: | Height: | Size: 392 KiB |
BIN
images-source/interactables/rubble.jpeg
Normal file
|
After Width: | Height: | Size: 530 KiB |
BIN
images-source/interactables/sedan.jpeg
Normal file
|
After Width: | Height: | Size: 550 KiB |
BIN
images-source/interactables/storage_box.jpeg
Normal file
|
After Width: | Height: | Size: 522 KiB |
BIN
images-source/interactables/toolshed.jpeg
Normal file
|
After Width: | Height: | Size: 474 KiB |
BIN
images-source/interactables/vending_machine.jpeg
Normal file
|
After Width: | Height: | Size: 470 KiB |
BIN
images-source/locations/clinic.jpeg
Normal file
|
After Width: | Height: | Size: 599 KiB |
BIN
images-source/locations/downtown.jpeg
Normal file
|
After Width: | Height: | Size: 558 KiB |
BIN
images-source/locations/office_building.jpeg
Normal file
|
After Width: | Height: | Size: 543 KiB |
BIN
images-source/locations/office_interior.jpeg
Normal file
|
After Width: | Height: | Size: 594 KiB |
BIN
images-source/locations/overpass.jpeg
Normal file
|
After Width: | Height: | Size: 631 KiB |
BIN
images-source/locations/park.jpeg
Normal file
|
After Width: | Height: | Size: 576 KiB |
BIN
images-source/locations/residential.jpeg
Normal file
|
After Width: | Height: | Size: 565 KiB |
BIN
images-source/locations/subway_section_a.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
images-source/locations/subway_tunnels.jpeg
Normal file
|
After Width: | Height: | Size: 429 KiB |
BIN
images-source/locations/warehouse.jpeg
Normal file
|
After Width: | Height: | Size: 517 KiB |
BIN
images-source/locations/warehouse_interior.jpeg
Normal file
|
After Width: | Height: | Size: 547 KiB |
BIN
images-source/npcs/test_boss.jpeg
Normal file
|
After Width: | Height: | Size: 225 KiB |
BIN
images/interactables/vending_machine.webp
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
images/npcs/test_boss.webp
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
pwa/public/landing-bg.jpeg
Normal file
|
After Width: | Height: | Size: 874 KiB |
BIN
pwa/public/landing-bg.webp
Normal file
|
After Width: | Height: | Size: 132 KiB |
13
pwa/src/components/AuthenticatedLayout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import PlayerSidebar from './game/PlayerSidebar'
|
|||||||
|
|
||||||
import { GameProvider } from '../contexts/GameContext'
|
import { GameProvider } from '../contexts/GameContext'
|
||||||
import { QuestJournal } from './game/QuestJournal'
|
import { QuestJournal } from './game/QuestJournal'
|
||||||
|
import { CharacterSheet } from './game/CharacterSheet'
|
||||||
import GameHeader from './GameHeader'
|
import GameHeader from './GameHeader'
|
||||||
import './Game.css'
|
import './Game.css'
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ function Game() {
|
|||||||
|
|
||||||
const [token] = useState(() => localStorage.getItem('token'))
|
const [token] = useState(() => localStorage.getItem('token'))
|
||||||
const [showQuestJournal, setShowQuestJournal] = useState(false)
|
const [showQuestJournal, setShowQuestJournal] = useState(false)
|
||||||
|
const [showCharacterSheet, setShowCharacterSheet] = useState(false)
|
||||||
|
|
||||||
// Handle WebSocket messages
|
// Handle WebSocket messages
|
||||||
const handleWebSocketMessage = async (message: any) => {
|
const handleWebSocketMessage = async (message: any) => {
|
||||||
@@ -422,6 +424,9 @@ function Game() {
|
|||||||
try {
|
try {
|
||||||
const response = await api.post('/api/game/pvp/action', { action })
|
const response = await api.post('/api/game/pvp/action', { action })
|
||||||
actions.setMessage(response.data.message || 'Action performed!')
|
actions.setMessage(response.data.message || 'Action performed!')
|
||||||
|
if (response.data.equipment) {
|
||||||
|
actions.updateEquipment(response.data.equipment)
|
||||||
|
}
|
||||||
// We don't need to fetchGameData here because the websocket update will handle it?
|
// We don't need to fetchGameData here because the websocket update will handle it?
|
||||||
// The user said: "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call."
|
// The user said: "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call."
|
||||||
// So we should probably update state from response if possible, OR fetch.
|
// So we should probably update state from response if possible, OR fetch.
|
||||||
@@ -502,6 +507,8 @@ function Game() {
|
|||||||
onUncraft={(uniqueItemId: string, inventoryId: number, quantity?: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId, quantity)}
|
onUncraft={(uniqueItemId: string, inventoryId: number, quantity?: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId, quantity)}
|
||||||
failedActionItemId={state.failedActionItemId}
|
failedActionItemId={state.failedActionItemId}
|
||||||
quests={state.quests}
|
quests={state.quests}
|
||||||
|
craftedItemResult={state.craftedItemResult}
|
||||||
|
onCloseCraftedItemResult={() => actions.setCraftedItemResult(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -527,6 +534,7 @@ function Game() {
|
|||||||
}}
|
}}
|
||||||
onSpendPoint={actions.handleSpendPoint}
|
onSpendPoint={actions.handleSpendPoint}
|
||||||
onOpenQuestJournal={() => setShowQuestJournal(true)}
|
onOpenQuestJournal={() => setShowQuestJournal(true)}
|
||||||
|
onOpenCharacterSheet={() => setShowCharacterSheet(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -595,6 +603,13 @@ function Game() {
|
|||||||
{showQuestJournal && (
|
{showQuestJournal && (
|
||||||
<QuestJournal onClose={() => setShowQuestJournal(false)} />
|
<QuestJournal onClose={() => setShowQuestJournal(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showCharacterSheet && (
|
||||||
|
<CharacterSheet
|
||||||
|
onClose={() => setShowCharacterSheet(false)}
|
||||||
|
onSpendPoint={actions.handleSpendPoint}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</GameProvider>
|
</GameProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
189
pwa/src/components/LandingHeader.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
pwa/src/components/LandingHeader.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
pwa/src/components/PrivacyPolicy.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
pwa/src/components/PublicLayout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
pwa/src/components/TermsOfService.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -56,8 +56,11 @@ export const GameDropdown: React.FC<GameDropdownProps> = ({
|
|||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
|
||||||
// Handle scroll to close the dropdown (prevents detached menu and layout shifts)
|
// Handle scroll to close the dropdown (prevents detached menu and layout shifts)
|
||||||
const handleScroll = () => {
|
const handleScroll = (event: Event) => {
|
||||||
|
// Only close if scrolling the main document/window, not a sub-container like combat log
|
||||||
|
if (event.target === document || event.target === window || event.target === document.documentElement || event.target === document.body) {
|
||||||
onClose();
|
onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('scroll', handleScroll, true);
|
window.addEventListener('scroll', handleScroll, true);
|
||||||
|
|
||||||
|
|||||||
154
pwa/src/components/common/GameItemCard.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ interface GameProgressBarProps {
|
|||||||
height?: string;
|
height?: string;
|
||||||
align?: 'left' | 'right';
|
align?: 'left' | 'right';
|
||||||
labelAlignment?: 'left' | 'right';
|
labelAlignment?: 'left' | 'right';
|
||||||
|
customColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GameProgressBar: React.FC<GameProgressBarProps> = ({
|
export const GameProgressBar: React.FC<GameProgressBarProps> = ({
|
||||||
@@ -22,7 +23,8 @@ export const GameProgressBar: React.FC<GameProgressBarProps> = ({
|
|||||||
unit = '',
|
unit = '',
|
||||||
height = '8px',
|
height = '8px',
|
||||||
align = 'left',
|
align = 'left',
|
||||||
labelAlignment
|
labelAlignment,
|
||||||
|
customColor
|
||||||
}) => {
|
}) => {
|
||||||
const percentage = Math.min(100, Math.max(0, (value / (max || 1)) * 100));
|
const percentage = Math.min(100, Math.max(0, (value / (max || 1)) * 100));
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ export const GameProgressBar: React.FC<GameProgressBarProps> = ({
|
|||||||
|
|
||||||
// Custom coloring for health/stamina if not using classes matching InventoryModal exactly
|
// Custom coloring for health/stamina if not using classes matching InventoryModal exactly
|
||||||
const getGradient = () => {
|
const getGradient = () => {
|
||||||
|
if (customColor) return customColor;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
// InventoryModal.css defines .weight and .volume gradients
|
// InventoryModal.css defines .weight and .volume gradients
|
||||||
// We can rely on classes if we import the CSS in parent or here
|
// We can rely on classes if we import the CSS in parent or here
|
||||||
|
|||||||
120
pwa/src/components/common/ItemStatBadges.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { EffectBadge } from '../game/EffectBadge';
|
||||||
|
|
||||||
|
interface ItemStatBadgesProps {
|
||||||
|
item: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable component that renders all stat badges for an item.
|
||||||
|
* Used in tooltips, inventory cards, combat inventory, etc.
|
||||||
|
*/
|
||||||
|
export const ItemStatBadges = ({ item }: ItemStatBadgesProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const stats = item.unique_stats || item.stats || {};
|
||||||
|
const effects = item.effects || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stat-badges-container">
|
||||||
|
{/* Capacity */}
|
||||||
|
{(stats.weight_capacity) && (
|
||||||
|
<span className="stat-badge capacity">
|
||||||
|
⚖️ +{stats.weight_capacity}kg
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(stats.volume_capacity) && (
|
||||||
|
<span className="stat-badge capacity">
|
||||||
|
📦 +{stats.volume_capacity}L
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Combat */}
|
||||||
|
{(stats.damage_min) && (
|
||||||
|
<span className="stat-badge damage">
|
||||||
|
⚔️ {stats.damage_min}-{stats.damage_max}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(stats.armor) && (
|
||||||
|
<span className="stat-badge armor">
|
||||||
|
🛡️ +{stats.armor}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(stats.armor_penetration) && (
|
||||||
|
<span className="stat-badge penetration">
|
||||||
|
💔 +{stats.armor_penetration} {t('stats.pen')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(stats.crit_chance) && (
|
||||||
|
<span className="stat-badge crit">
|
||||||
|
🎯 +{Math.round(stats.crit_chance * 100)}% {t('stats.crit')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(stats.accuracy) && (
|
||||||
|
<span className="stat-badge accuracy">
|
||||||
|
👁️ +{Math.round(stats.accuracy * 100)}% {t('stats.acc')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(stats.dodge_chance) && (
|
||||||
|
<span className="stat-badge dodge">
|
||||||
|
💨 +{Math.round(stats.dodge_chance * 100)}% Dodge
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(stats.lifesteal) && (
|
||||||
|
<span className="stat-badge lifesteal">
|
||||||
|
🧛 +{Math.round(stats.lifesteal * 100)}% {t('stats.life')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attributes */}
|
||||||
|
{(stats.strength_bonus) && (
|
||||||
|
<span className="stat-badge strength">
|
||||||
|
💪 +{stats.strength_bonus} {t('stats.str')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(stats.agility_bonus) && (
|
||||||
|
<span className="stat-badge agility">
|
||||||
|
🏃 +{stats.agility_bonus} {t('stats.agi')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(stats.endurance_bonus) && (
|
||||||
|
<span className="stat-badge endurance">
|
||||||
|
🏋️ +{stats.endurance_bonus} {t('stats.end')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(stats.max_hp) && (
|
||||||
|
<span className="stat-badge health">
|
||||||
|
❤️ +{stats.max_hp} {t('stats.hpMax')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(stats.max_stamina) && (
|
||||||
|
<span className="stat-badge stamina">
|
||||||
|
⚡ +{stats.max_stamina} {t('stats.stmMax')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Consumables */}
|
||||||
|
{(item.hp_restore || effects.hp_restore) && (
|
||||||
|
<span className="stat-badge health">
|
||||||
|
❤️ +{item.hp_restore || effects.hp_restore} HP
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(item.stamina_restore || effects.stamina_restore) && (
|
||||||
|
<span className="stat-badge stamina">
|
||||||
|
⚡ +{item.stamina_restore || effects.stamina_restore} Stm
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Effects */}
|
||||||
|
{effects.status_effect && (
|
||||||
|
<EffectBadge effect={effects.status_effect} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{effects.cures && effects.cures.length > 0 && (
|
||||||
|
<span className="stat-badge cure">
|
||||||
|
💊 {t('game.cures')}: {effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
21
pwa/src/components/common/LanguageSelector.css
Normal 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;
|
||||||
|
}
|
||||||
27
pwa/src/components/common/LanguageSelector.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
pwa/src/components/common/Notification.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
pwa/src/components/common/NotificationContainer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
554
pwa/src/components/game/CharacterSheet.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
436
pwa/src/components/game/CharacterSheet.tsx
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getTranslatedText } from '../../utils/i18nUtils';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { GameModal } from './GameModal';
|
||||||
|
import { GameProgressBar } from '../common/GameProgressBar';
|
||||||
|
import { GameButton } from '../common/GameButton';
|
||||||
|
import { GameTooltip } from '../common/GameTooltip';
|
||||||
|
import { EffectBadge } from './EffectBadge';
|
||||||
|
import './CharacterSheet.css';
|
||||||
|
|
||||||
|
interface CharacterSheetProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onSpendPoint: (stat: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DerivedStats {
|
||||||
|
attack_power: number;
|
||||||
|
crit_chance: number;
|
||||||
|
crit_damage: number;
|
||||||
|
dodge_chance: number;
|
||||||
|
flee_chance_base: number;
|
||||||
|
max_hp: number;
|
||||||
|
max_stamina: number;
|
||||||
|
total_armor: number;
|
||||||
|
armor_reduction: number;
|
||||||
|
block_chance: number;
|
||||||
|
status_resistance: number;
|
||||||
|
item_effectiveness: number;
|
||||||
|
xp_bonus: number;
|
||||||
|
loot_quality: number;
|
||||||
|
crafting_bonus: number;
|
||||||
|
carry_weight: number;
|
||||||
|
weapon_damage_min: number;
|
||||||
|
weapon_damage_max: number;
|
||||||
|
has_shield: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillData {
|
||||||
|
id: string;
|
||||||
|
name: any;
|
||||||
|
description: any;
|
||||||
|
icon: string;
|
||||||
|
stat_requirement: string;
|
||||||
|
stat_threshold: number;
|
||||||
|
level_requirement: number;
|
||||||
|
cooldown: number;
|
||||||
|
stamina_cost: number;
|
||||||
|
unlocked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PerkData {
|
||||||
|
id: string;
|
||||||
|
name: any;
|
||||||
|
description: any;
|
||||||
|
icon: string;
|
||||||
|
requirements: Record<string, number>;
|
||||||
|
effects: Record<string, any>;
|
||||||
|
meets_requirements: boolean;
|
||||||
|
owned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterSheetData {
|
||||||
|
base_stats: {
|
||||||
|
strength: number;
|
||||||
|
agility: number;
|
||||||
|
endurance: number;
|
||||||
|
intellect: number;
|
||||||
|
unspent_points: number;
|
||||||
|
stat_cap: number;
|
||||||
|
};
|
||||||
|
derived_stats: DerivedStats;
|
||||||
|
skills: SkillData[];
|
||||||
|
perks: {
|
||||||
|
available_points: number;
|
||||||
|
total_points: number;
|
||||||
|
used_points: number;
|
||||||
|
all_perks: PerkData[];
|
||||||
|
};
|
||||||
|
status_effects: any[];
|
||||||
|
character: {
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
xp: number;
|
||||||
|
hp: number;
|
||||||
|
max_hp: number;
|
||||||
|
stamina: number;
|
||||||
|
max_stamina: number;
|
||||||
|
avatar_data?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAT_ICONS: Record<string, string> = {
|
||||||
|
strength: '💪',
|
||||||
|
agility: '🏃',
|
||||||
|
endurance: '🫀',
|
||||||
|
intellect: '🧠',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STAT_COLORS: Record<string, string> = {
|
||||||
|
strength: '#e74c3c',
|
||||||
|
agility: '#2ecc71',
|
||||||
|
endurance: '#f39c12',
|
||||||
|
intellect: '#3498db',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [data, setData] = useState<CharacterSheetData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState<'stats' | 'skills' | 'perks'>('stats');
|
||||||
|
const [selectingPerk, setSelectingPerk] = useState(false);
|
||||||
|
|
||||||
|
const fetchSheet = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/api/game/character-sheet');
|
||||||
|
setData(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch character sheet:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSheet();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSpendPoint = async (stat: string) => {
|
||||||
|
onSpendPoint(stat);
|
||||||
|
// Refetch after a short delay to get updated derived stats
|
||||||
|
setTimeout(fetchSheet, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectPerk = async (perkId: string) => {
|
||||||
|
setSelectingPerk(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/api/game/select_perk?perk_id=${perkId}`);
|
||||||
|
await fetchSheet();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to select perk:', err.response?.data?.detail || err.message);
|
||||||
|
} finally {
|
||||||
|
setSelectingPerk(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<GameModal title={t('characterSheet.title', 'Character Sheet')} onClose={onClose} className="character-sheet-modal">
|
||||||
|
<div className="cs-loading"><span>⌛</span> {t('common.loading', 'Loading...')}</div>
|
||||||
|
</GameModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { base_stats, derived_stats, skills, perks, character, status_effects } = data;
|
||||||
|
|
||||||
|
const renderStatsTab = () => (
|
||||||
|
<div className="cs-stats-tab">
|
||||||
|
{/* Base Stats Section */}
|
||||||
|
<div className="cs-stats-base-col">
|
||||||
|
<div className="cs-section">
|
||||||
|
<h4 className="cs-section-title">{t('characterSheet.baseStats', 'Base Stats')}</h4>
|
||||||
|
|
||||||
|
{/* Vitals Moved Here */}
|
||||||
|
<div className="cs-vitals" style={{ marginBottom: '1.5rem', display: 'flex', flexDirection: 'column', gap: '0.8rem' }}>
|
||||||
|
<GameProgressBar
|
||||||
|
value={character.hp}
|
||||||
|
max={character.max_hp}
|
||||||
|
type="health"
|
||||||
|
showText={true}
|
||||||
|
height="12px"
|
||||||
|
label={t('stats.hpMax')}
|
||||||
|
/>
|
||||||
|
<GameProgressBar
|
||||||
|
value={character.stamina}
|
||||||
|
max={character.max_stamina}
|
||||||
|
type="stamina"
|
||||||
|
showText={true}
|
||||||
|
height="12px"
|
||||||
|
label={t('stats.stmMax')}
|
||||||
|
/>
|
||||||
|
<GameProgressBar
|
||||||
|
value={character.xp}
|
||||||
|
max={character.level * 100}
|
||||||
|
type="xp"
|
||||||
|
showText={true}
|
||||||
|
height="12px"
|
||||||
|
label={t('stats.xp')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status_effects && status_effects.length > 0 && (
|
||||||
|
<div className="cs-status-effects" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<h5 style={{ margin: '0 0 0.5rem 0', color: '#ffb94a' }}>{t('characterSheet.activeEffects', 'Active Effects')}</h5>
|
||||||
|
<div style={{ display: 'flex', gap: '5px', flexWrap: 'wrap' }}>
|
||||||
|
{status_effects.map((e: any) => (
|
||||||
|
<GameTooltip key={e.effect_name || e.id} content={`${getTranslatedText(e.description, { interval: t('stats.interval_minute'), intervals_plural: t('stats.intervals_minute') })} (${e.ticks_remaining} ${t('game.ticksRemaining', 'ticks left')})`}>
|
||||||
|
<div style={{ display: 'inline-block' }}>
|
||||||
|
<EffectBadge effect={{
|
||||||
|
name: e.name || e.effect_name,
|
||||||
|
icon: e.icon,
|
||||||
|
type: e.type || (e.damage_per_tick > 0 ? 'damage' : 'buff'),
|
||||||
|
damage_per_tick: e.damage_per_tick,
|
||||||
|
ticks: e.ticks_remaining
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</GameTooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{base_stats.unspent_points > 0 && (
|
||||||
|
<div className="cs-unspent-badge">
|
||||||
|
<span>✨</span> {base_stats.unspent_points} {t('characterSheet.pointsAvailable', 'points available')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="cs-base-stats-grid">
|
||||||
|
{(['strength', 'agility', 'endurance', 'intellect'] as const).map(stat => (
|
||||||
|
<div key={stat} className="cs-stat-row">
|
||||||
|
<span className="cs-stat-icon"><span>{STAT_ICONS[stat]}</span></span>
|
||||||
|
<span className="cs-stat-name">{t(`stats.${stat}Full`)}</span>
|
||||||
|
<div className="cs-stat-bar-wrap">
|
||||||
|
<GameProgressBar
|
||||||
|
value={base_stats[stat]}
|
||||||
|
max={base_stats.stat_cap}
|
||||||
|
type="durability"
|
||||||
|
customColor={STAT_COLORS[stat]}
|
||||||
|
showText={true}
|
||||||
|
height="10px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{base_stats.unspent_points > 0 && base_stats[stat] < base_stats.stat_cap && (
|
||||||
|
<button className="cs-plus-btn" onClick={() => handleSpendPoint(stat)}>+</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Derived Stats Section */}
|
||||||
|
<div className="cs-stats-derived-col">
|
||||||
|
<div className="cs-section">
|
||||||
|
<h4 className="cs-section-title">{t('characterSheet.derivedStats', 'Derived Stats')}</h4>
|
||||||
|
<div className="cs-derived-grid">
|
||||||
|
<DerivedStatRow icon="⚔️" label={t('characterSheet.attackPower', 'Attack Power')} value={derived_stats.attack_power} />
|
||||||
|
<DerivedStatRow icon="🎯" label={t('characterSheet.critChance', 'Crit Chance')} value={`${(derived_stats.crit_chance * 100).toFixed(1)}%`} />
|
||||||
|
<DerivedStatRow icon="💥" label={t('characterSheet.critDamage', 'Crit Damage')} value={`${derived_stats.crit_damage}x`} />
|
||||||
|
<DerivedStatRow icon="🏃" label={t('characterSheet.dodgeChance', 'Dodge Chance')} value={`${(derived_stats.dodge_chance * 100).toFixed(1)}%`} />
|
||||||
|
<DerivedStatRow icon="💨" label={t('characterSheet.fleeChance', 'Flee Chance')} value={`${(derived_stats.flee_chance_base * 100).toFixed(0)}%`} />
|
||||||
|
<DerivedStatRow icon="❤️" label={t('characterSheet.maxHp', 'Max HP')} value={derived_stats.max_hp} />
|
||||||
|
<DerivedStatRow icon="⚡" label={t('characterSheet.maxStamina', 'Max Stamina')} value={derived_stats.max_stamina} />
|
||||||
|
<DerivedStatRow icon="🛡️" label={t('characterSheet.armor', 'Armor')} value={`${derived_stats.total_armor} (${(derived_stats.armor_reduction * 100).toFixed(1)}%)`} />
|
||||||
|
<DerivedStatRow icon="🧱" label={t('characterSheet.blockChance', 'Block Chance')} value={`${(derived_stats.block_chance * 100).toFixed(1)}%`} />
|
||||||
|
<DerivedStatRow icon="🧬" label={t('characterSheet.statusResist', 'Status Resist')} value={`${(derived_stats.status_resistance * 100).toFixed(0)}%`} />
|
||||||
|
<DerivedStatRow icon="💊" label={t('characterSheet.itemEffect', 'Item Effectiveness')} value={`${(derived_stats.item_effectiveness * 100).toFixed(0)}%`} />
|
||||||
|
<DerivedStatRow icon="📈" label={t('characterSheet.xpBonus', 'XP Bonus')} value={`${(derived_stats.xp_bonus * 100).toFixed(0)}%`} />
|
||||||
|
<DerivedStatRow icon="🎲" label={t('characterSheet.lootQuality', 'Loot Quality')} value={`${(derived_stats.loot_quality * 100).toFixed(1)}%`} />
|
||||||
|
<DerivedStatRow icon="🔨" label={t('characterSheet.craftBonus', 'Craft Bonus')} value={`${(derived_stats.crafting_bonus * 100).toFixed(0)}%`} />
|
||||||
|
<DerivedStatRow icon="🎒" label={t('characterSheet.carryWeight', 'Carry Weight')} value={`${derived_stats.carry_weight} kg`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderSkillsTab = () => {
|
||||||
|
const grouped: Record<string, SkillData[]> = {
|
||||||
|
strength: [],
|
||||||
|
agility: [],
|
||||||
|
endurance: [],
|
||||||
|
intellect: [],
|
||||||
|
};
|
||||||
|
skills.forEach(s => {
|
||||||
|
if (grouped[s.stat_requirement]) {
|
||||||
|
grouped[s.stat_requirement].push(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cs-skills-tab">
|
||||||
|
{Object.entries(grouped).map(([stat, statSkills]) => (
|
||||||
|
<div key={stat} className="cs-skill-group">
|
||||||
|
<h4 className="cs-skill-group-title" style={{ color: STAT_COLORS[stat] }}>
|
||||||
|
<span>{STAT_ICONS[stat]}</span> {t(`stats.${stat}Full`)}
|
||||||
|
</h4>
|
||||||
|
<div className="cs-skill-list">
|
||||||
|
{statSkills.map(skill => (
|
||||||
|
<div key={skill.id} className={`cs-skill-card ${skill.unlocked ? 'unlocked' : 'locked'}`}>
|
||||||
|
<div className="cs-skill-header">
|
||||||
|
<span className="cs-skill-icon">{skill.icon}</span>
|
||||||
|
<span className="cs-skill-name">{getTranslatedText(skill.name)}</span>
|
||||||
|
{skill.unlocked ? (
|
||||||
|
<span className="cs-skill-badge unlocked"><span>✓</span></span>
|
||||||
|
) : (
|
||||||
|
<span className="cs-skill-badge locked"><span>🔒</span></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="cs-skill-desc">{getTranslatedText(skill.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</p>
|
||||||
|
<div className="cs-skill-meta">
|
||||||
|
<span className="cs-skill-tag"><span>⚡</span> {skill.stamina_cost}</span>
|
||||||
|
<span className="cs-skill-tag"><span>🔄</span> {skill.cooldown}t</span>
|
||||||
|
<span className="cs-skill-tag req">
|
||||||
|
{t(`stats.${skill.stat_requirement}`)}: {skill.stat_threshold}
|
||||||
|
</span>
|
||||||
|
<span className="cs-skill-tag req">
|
||||||
|
{t('stats.level')}: {skill.level_requirement}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPerksTab = () => (
|
||||||
|
<div className="cs-perks-tab">
|
||||||
|
<div className="cs-perk-points">
|
||||||
|
<span className="cs-perk-points-label"><span>⭐</span> {t('characterSheet.perkPoints', 'Perk Points')}:</span>
|
||||||
|
<span className="cs-perk-points-value">
|
||||||
|
{perks.available_points} / {perks.total_points}
|
||||||
|
</span>
|
||||||
|
<span className="cs-perk-points-hint">
|
||||||
|
({t('characterSheet.nextPerkAt', 'Next at Lv')} {((perks.used_points + perks.available_points + 1) * 5)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="cs-perk-list">
|
||||||
|
{perks.all_perks.map(perk => {
|
||||||
|
const canSelect = perk.meets_requirements && !perk.owned && perks.available_points > 0;
|
||||||
|
return (
|
||||||
|
<div key={perk.id} className={`cs-perk-card ${perk.owned ? 'owned' : ''} ${!perk.meets_requirements ? 'locked' : ''}`}>
|
||||||
|
<div className="cs-perk-header">
|
||||||
|
<span className="cs-perk-icon">{perk.icon}</span>
|
||||||
|
<div className="cs-perk-title-block">
|
||||||
|
<span className="cs-perk-name">{getTranslatedText(perk.name)}</span>
|
||||||
|
<p className="cs-perk-desc">{getTranslatedText(perk.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</p>
|
||||||
|
</div>
|
||||||
|
{perk.owned ? (
|
||||||
|
<span className="cs-perk-status owned"><span>✓</span> {t('characterSheet.owned', 'Owned')}</span>
|
||||||
|
) : canSelect ? (
|
||||||
|
<GameButton
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSelectPerk(perk.id)}
|
||||||
|
disabled={selectingPerk}
|
||||||
|
>
|
||||||
|
{t('characterSheet.select', 'Select')}
|
||||||
|
</GameButton>
|
||||||
|
) : (
|
||||||
|
<span className="cs-perk-status locked"><span>🔒</span></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="cs-perk-reqs">
|
||||||
|
{Object.entries(perk.requirements).map(([key, val]) => {
|
||||||
|
const isMax = key.endsWith('_max');
|
||||||
|
const baseKey = isMax ? key.replace('_max', '') : key;
|
||||||
|
const displayKey = ['strength', 'agility', 'endurance', 'intellect', 'level'].includes(baseKey)
|
||||||
|
? t(`stats.${baseKey}`)
|
||||||
|
: baseKey;
|
||||||
|
|
||||||
|
const currentVal = baseKey === 'level' ? character.level : ((data.base_stats as any)[baseKey] || 0);
|
||||||
|
const isMet = isMax ? currentVal <= val : currentVal >= val;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={key} className={`cs-perk-req ${isMet ? 'met' : 'unmet'}`}>
|
||||||
|
{displayKey} {isMax ? '≤' : '≥'} {val}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalTitle = (
|
||||||
|
<div className="cs-header-content">
|
||||||
|
<span className="cs-header-title">{character.name} — Lv. {character.level}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameModal
|
||||||
|
title={modalTitle}
|
||||||
|
onClose={onClose}
|
||||||
|
className="character-sheet-modal"
|
||||||
|
>
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="cs-tabs">
|
||||||
|
<button
|
||||||
|
className={`cs-tab ${activeTab === 'stats' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('stats')}
|
||||||
|
>
|
||||||
|
<span>📊</span> {t('characterSheet.statsTab', 'Stats')}
|
||||||
|
{base_stats.unspent_points > 0 && <span className="cs-tab-badge">{base_stats.unspent_points}</span>}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`cs-tab ${activeTab === 'skills' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('skills')}
|
||||||
|
>
|
||||||
|
<span>⚔️</span> {t('characterSheet.skillsTab', 'Skills')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`cs-tab ${activeTab === 'perks' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('perks')}
|
||||||
|
>
|
||||||
|
<span>⭐</span> {t('characterSheet.perksTab', 'Perks')}
|
||||||
|
{perks.available_points > 0 && <span className="cs-tab-badge">{perks.available_points}</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="cs-tab-content">
|
||||||
|
{activeTab === 'stats' && renderStatsTab()}
|
||||||
|
{activeTab === 'skills' && renderSkillsTab()}
|
||||||
|
{activeTab === 'perks' && renderPerksTab()}
|
||||||
|
</div>
|
||||||
|
</GameModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DerivedStatRow({ icon, label, value }: { icon: string; label: string; value: any }) {
|
||||||
|
return (
|
||||||
|
<div className="cs-derived-row">
|
||||||
|
<span className="cs-derived-icon"><span>{icon}</span></span>
|
||||||
|
<span className="cs-derived-label">{label}</span>
|
||||||
|
<span className="cs-derived-value">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -132,7 +132,10 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
opponentName: isPvP
|
opponentName: isPvP
|
||||||
? (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username)
|
? (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username)
|
||||||
: undefined,
|
: undefined,
|
||||||
turnTimeRemaining: initialCombatData?.pvp_combat?.time_remaining ?? initialCombatData?.turn_time_remaining
|
turnTimeRemaining: initialCombatData?.pvp_combat?.time_remaining ?? initialCombatData?.turn_time_remaining,
|
||||||
|
npcEffects: initialCombatData?.combat?.npc_effects || [],
|
||||||
|
playerEffects: initialCombatData?.player_effects || [],
|
||||||
|
npcIntent: initialCombatData?.combat?.npc_intent
|
||||||
});
|
});
|
||||||
|
|
||||||
const [animState, setAnimState] = useState<AnimationState>({
|
const [animState, setAnimState] = useState<AnimationState>({
|
||||||
@@ -158,6 +161,8 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
const pendingPlayerHpRef = useRef<{ hp: number; max_hp: number } | null>(null);
|
const pendingPlayerHpRef = useRef<{ hp: number; max_hp: number } | null>(null);
|
||||||
// Store server player XP to apply when XP floating text appears
|
// Store server player XP to apply when XP floating text appears
|
||||||
const pendingPlayerXpRef = useRef<{ xp: number; level: number } | null>(null);
|
const pendingPlayerXpRef = useRef<{ xp: number; level: number } | null>(null);
|
||||||
|
// Store server equipment to apply when attack/hit animations occur
|
||||||
|
const pendingEquipmentRef = useRef<any>(null);
|
||||||
|
|
||||||
// Update queueRef
|
// Update queueRef
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -284,6 +289,7 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
yourTurn: newYourTurn !== undefined ? newYourTurn : prev.yourTurn,
|
yourTurn: newYourTurn !== undefined ? newYourTurn : prev.yourTurn,
|
||||||
round: initialCombatData?.combat?.round ?? prev.round,
|
round: initialCombatData?.combat?.round ?? prev.round,
|
||||||
turnTimeRemaining: newTimeRemaining !== undefined ? newTimeRemaining : prev.turnTimeRemaining,
|
turnTimeRemaining: newTimeRemaining !== undefined ? newTimeRemaining : prev.turnTimeRemaining,
|
||||||
|
npcIntent: initialCombatData?.combat?.npc_intent ?? prev.npcIntent,
|
||||||
// Sync HP for PVP from WebSocket updates
|
// Sync HP for PVP from WebSocket updates
|
||||||
...(isPvP && newPlayerHp !== undefined ? { playerHp: newPlayerHp } : {}),
|
...(isPvP && newPlayerHp !== undefined ? { playerHp: newPlayerHp } : {}),
|
||||||
...(isPvP && newNpcHp !== undefined ? { npcHp: newNpcHp } : {})
|
...(isPvP && newNpcHp !== undefined ? { npcHp: newNpcHp } : {})
|
||||||
@@ -411,14 +417,17 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
// Apply server player HP when floating text appears
|
// Apply server player HP when floating text appears
|
||||||
if (pendingPlayerHpRef.current) {
|
if (pendingPlayerHpRef.current) {
|
||||||
const { hp, max_hp } = pendingPlayerHpRef.current;
|
const { hp, max_hp } = pendingPlayerHpRef.current;
|
||||||
setLocalCombatState(prev => ({
|
setLocalCombatState(prev => ({ ...prev, playerHp: hp, playerMaxHp: max_hp }));
|
||||||
...prev,
|
|
||||||
playerHp: hp,
|
|
||||||
playerMaxHp: max_hp
|
|
||||||
}));
|
|
||||||
updatePlayerState({ hp, max_hp });
|
updatePlayerState({ hp, max_hp });
|
||||||
pendingPlayerHpRef.current = null;
|
pendingPlayerHpRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply pending equipment update (durability loss from being hit)
|
||||||
|
if (pendingEquipmentRef.current) {
|
||||||
|
updatePlayerState({ equipment: pendingEquipmentRef.current });
|
||||||
|
pendingEquipmentRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -436,7 +445,23 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
triggerAnim('shaking', 500);
|
triggerAnim('shaking', 500);
|
||||||
if (data.damage) {
|
if (data.damage) {
|
||||||
addFloatingText(`-${data.damage}!`, 'crit', 'player');
|
addFloatingText(`-${data.damage}!`, 'crit', 'player');
|
||||||
|
|
||||||
|
if (pendingPlayerHpRef.current) {
|
||||||
|
const { hp, max_hp } = pendingPlayerHpRef.current;
|
||||||
|
setLocalCombatState(prev => ({ ...prev, playerHp: hp, playerMaxHp: max_hp }));
|
||||||
|
updatePlayerState({ hp, max_hp });
|
||||||
|
pendingPlayerHpRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pendingEquipmentRef.current) {
|
||||||
|
updatePlayerState({ equipment: pendingEquipmentRef.current });
|
||||||
|
pendingEquipmentRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'effect_damage':
|
||||||
|
addFloatingText(`-${data.damage}`, 'damage', origin === 'enemy' ? 'enemy' : 'player');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'effect_bleeding':
|
case 'effect_bleeding':
|
||||||
@@ -495,6 +520,61 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
case 'quest_update':
|
case 'quest_update':
|
||||||
addNotification(data.message || 'Quest Progress', 'quest');
|
addNotification(data.message || 'Quest Progress', 'quest');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// ── Skill messages ──
|
||||||
|
case 'skill_attack':
|
||||||
|
const target_origin = origin === 'enemy' ? 'player' : 'enemy';
|
||||||
|
triggerAnim(origin === 'enemy' ? 'enemyAttacking' : 'playerAttacking');
|
||||||
|
triggerAnim(origin === 'enemy' ? 'playerHit' : 'npcHit', 300);
|
||||||
|
|
||||||
|
if (data.damage) {
|
||||||
|
const label = data.hits > 1
|
||||||
|
? `${data.skill_icon || '⚔️'} -${data.damage} (x${data.hits})`
|
||||||
|
: `${data.skill_icon || '⚔️'} -${data.damage}`;
|
||||||
|
addFloatingText(label, 'damage', target_origin);
|
||||||
|
|
||||||
|
if (target_origin === 'player') {
|
||||||
|
if (pendingPlayerHpRef.current) {
|
||||||
|
const { hp, max_hp } = pendingPlayerHpRef.current;
|
||||||
|
setLocalCombatState(prev => ({ ...prev, playerHp: hp, playerMaxHp: max_hp }));
|
||||||
|
updatePlayerState({ hp, max_hp });
|
||||||
|
pendingPlayerHpRef.current = null;
|
||||||
|
}
|
||||||
|
if (pendingEquipmentRef.current) {
|
||||||
|
updatePlayerState({ equipment: pendingEquipmentRef.current });
|
||||||
|
pendingEquipmentRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'skill_heal':
|
||||||
|
if (data.heal) {
|
||||||
|
addFloatingText(`${data.skill_icon || '💚'} +${data.heal}`, 'heal', 'player');
|
||||||
|
if (pendingPlayerHpRef.current) {
|
||||||
|
const { hp, max_hp } = pendingPlayerHpRef.current;
|
||||||
|
setLocalCombatState(prev => ({
|
||||||
|
...prev, playerHp: hp, playerMaxHp: max_hp
|
||||||
|
}));
|
||||||
|
updatePlayerState({ hp, max_hp });
|
||||||
|
pendingPlayerHpRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'skill_buff':
|
||||||
|
addFloatingText(`${data.skill_icon || '🛡️'} ${data.skill_name ? (typeof data.skill_name === 'object' ? (data.skill_name[(i18n as any).language] || data.skill_name.en) : data.skill_name) : 'Buff'}`, 'info', 'player');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'skill_effect':
|
||||||
|
if (data.message) {
|
||||||
|
addFloatingText(data.message, 'info', 'enemy');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'skill_analyze':
|
||||||
|
addFloatingText(`${data.skill_icon || '🔍'} Analyzed!`, 'info', 'enemy');
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
@@ -528,6 +608,12 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
} else if (messageQueue.length === 0 && isProcessingQueue) {
|
} else if (messageQueue.length === 0 && isProcessingQueue) {
|
||||||
// Queue just finished processing
|
// Queue just finished processing
|
||||||
setIsProcessingQueue(false);
|
setIsProcessingQueue(false);
|
||||||
|
|
||||||
|
// Apply pending equipment updates (durability loss etc.) after ALL animations finish
|
||||||
|
if (pendingEquipmentRef.current) {
|
||||||
|
updatePlayerState({ equipment: pendingEquipmentRef.current });
|
||||||
|
pendingEquipmentRef.current = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [messageQueue, processQueue, isProcessingQueue]);
|
}, [messageQueue, processQueue, isProcessingQueue]);
|
||||||
|
|
||||||
@@ -552,13 +638,23 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
npcMaxHp: data.combat.npc_max_hp,
|
npcMaxHp: data.combat.npc_max_hp,
|
||||||
turn: data.combat.turn,
|
turn: data.combat.turn,
|
||||||
round: data.combat.round,
|
round: data.combat.round,
|
||||||
npcName: resolveName(data.combat.npc_name) || prev.npcName
|
npcName: resolveName(data.combat.npc_name) || prev.npcName,
|
||||||
|
npcEffects: data.combat.npc_effects || [],
|
||||||
|
playerEffects: (data as any).player_effects || [],
|
||||||
|
npcIntent: data.combat.npc_intent
|
||||||
}));
|
}));
|
||||||
} else if (data.combat_over && data.player_won) {
|
} else if (data.combat_over && data.player_won === true && action !== 'flee') {
|
||||||
|
// Apply any remaining pending data on victory
|
||||||
|
if (pendingEquipmentRef.current) {
|
||||||
|
updatePlayerState({ equipment: pendingEquipmentRef.current });
|
||||||
|
pendingEquipmentRef.current = null;
|
||||||
|
}
|
||||||
// Combat ended with victory but data.combat is null - set enemy HP to 0
|
// Combat ended with victory but data.combat is null - set enemy HP to 0
|
||||||
setLocalCombatState(prev => ({
|
setLocalCombatState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
npcHp: 0
|
npcHp: 0,
|
||||||
|
npcEffects: [],
|
||||||
|
playerEffects: []
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,8 +663,13 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
pendingPlayerHpRef.current = { hp: data.player.hp, max_hp: data.player.max_hp };
|
pendingPlayerHpRef.current = { hp: data.player.hp, max_hp: data.player.max_hp };
|
||||||
// Store player XP to apply when xp_gain message is processed
|
// Store player XP to apply when xp_gain message is processed
|
||||||
pendingPlayerXpRef.current = { xp: data.player.xp, level: data.player.level };
|
pendingPlayerXpRef.current = { xp: data.player.xp, level: data.player.level };
|
||||||
refreshCharacters();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.equipment) {
|
||||||
|
pendingEquipmentRef.current = data.equipment;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshCharacters();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -709,12 +810,17 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
npcMaxHp: data.combat.npc_max_hp,
|
npcMaxHp: data.combat.npc_max_hp,
|
||||||
turn: data.combat.turn,
|
turn: data.combat.turn,
|
||||||
round: data.combat.round,
|
round: data.combat.round,
|
||||||
npcName: resolveName(data.combat.npc_name) || prev.npcName
|
npcName: resolveName(data.combat.npc_name) || prev.npcName,
|
||||||
|
npcEffects: data.combat.npc_effects || [],
|
||||||
|
playerEffects: (data as any).player_effects || [],
|
||||||
|
npcIntent: data.combat.npc_intent
|
||||||
}));
|
}));
|
||||||
} else if (data.combat_over && data.player_won) {
|
} else if (data.combat_over && data.player_won === true) {
|
||||||
setLocalCombatState(prev => ({
|
setLocalCombatState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
npcHp: 0
|
npcHp: 0,
|
||||||
|
npcEffects: [],
|
||||||
|
playerEffects: []
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,6 +846,7 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
onClose={handleCloseWrapper}
|
onClose={handleCloseWrapper}
|
||||||
onShowSupplies={() => setShowSuppliesModal(true)}
|
onShowSupplies={() => setShowSuppliesModal(true)}
|
||||||
isProcessing={isProcessingQueue}
|
isProcessing={isProcessingQueue}
|
||||||
|
playerStamina={playerState?.stamina || 0}
|
||||||
combatResult={combatResult}
|
combatResult={combatResult}
|
||||||
equipment={_equipment}
|
equipment={_equipment}
|
||||||
playerName={profile?.name}
|
playerName={profile?.name}
|
||||||
|
|||||||
@@ -558,3 +558,35 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
transition: width 0.3s ease-out;
|
transition: width 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Combat Status Effect Badges */
|
||||||
|
.combat-effects-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-effect-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
clip-path: var(--game-clip-path-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-effect-badge.effect-buff {
|
||||||
|
background: rgba(76, 175, 80, 0.3);
|
||||||
|
border: 1px solid rgba(76, 175, 80, 0.5);
|
||||||
|
color: #81c784;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-effect-badge.effect-debuff {
|
||||||
|
background: rgba(220, 53, 69, 0.3);
|
||||||
|
border: 1px solid rgba(220, 53, 69, 0.5);
|
||||||
|
color: #ef9a9a;
|
||||||
|
}
|
||||||
@@ -15,6 +15,14 @@ export interface FloatingText {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CombatEffect {
|
||||||
|
name: string | Record<string, string>;
|
||||||
|
icon: string;
|
||||||
|
ticks_remaining: number;
|
||||||
|
type?: string; // 'buff', 'debuff', 'damage'
|
||||||
|
description?: string | Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CombatState {
|
export interface CombatState {
|
||||||
inCombat: boolean;
|
inCombat: boolean;
|
||||||
turn: 'player' | 'enemy' | 'attacker' | 'defender';
|
turn: 'player' | 'enemy' | 'attacker' | 'defender';
|
||||||
@@ -31,6 +39,9 @@ export interface CombatState {
|
|||||||
round: number;
|
round: number;
|
||||||
isPvP?: boolean;
|
isPvP?: boolean;
|
||||||
opponentName?: string;
|
opponentName?: string;
|
||||||
|
npcEffects?: CombatEffect[];
|
||||||
|
playerEffects?: CombatEffect[];
|
||||||
|
npcIntent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CombatActionResponse {
|
export interface CombatActionResponse {
|
||||||
@@ -47,6 +58,7 @@ export interface CombatActionResponse {
|
|||||||
level: number;
|
level: number;
|
||||||
};
|
};
|
||||||
winner_id?: string;
|
winner_id?: string;
|
||||||
|
equipment?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnimationState {
|
export interface AnimationState {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils';
|
import { getTranslatedText } from '../../utils/i18nUtils';
|
||||||
import { useAudio } from '../../contexts/AudioContext';
|
import { useAudio } from '../../contexts/AudioContext';
|
||||||
@@ -7,6 +7,9 @@ import { Equipment } from './types';
|
|||||||
import './CombatEffects.css';
|
import './CombatEffects.css';
|
||||||
import { GameProgressBar } from '../common/GameProgressBar';
|
import { GameProgressBar } from '../common/GameProgressBar';
|
||||||
import { GameButton } from '../common/GameButton';
|
import { GameButton } from '../common/GameButton';
|
||||||
|
import { GameDropdown } from '../common/GameDropdown';
|
||||||
|
import { GameTooltip } from '../common/GameTooltip';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
interface CombatViewProps {
|
interface CombatViewProps {
|
||||||
state: CombatState;
|
state: CombatState;
|
||||||
@@ -16,6 +19,7 @@ interface CombatViewProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onShowSupplies: () => void;
|
onShowSupplies: () => void;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
|
playerStamina: number;
|
||||||
combatResult: 'victory' | 'defeat' | 'fled' | null;
|
combatResult: 'victory' | 'defeat' | 'fled' | null;
|
||||||
equipment?: Equipment | any;
|
equipment?: Equipment | any;
|
||||||
playerName?: string;
|
playerName?: string;
|
||||||
@@ -30,6 +34,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onShowSupplies,
|
onShowSupplies,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
|
playerStamina,
|
||||||
combatResult,
|
combatResult,
|
||||||
equipment,
|
equipment,
|
||||||
playerName,
|
playerName,
|
||||||
@@ -118,6 +123,20 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
}
|
}
|
||||||
}, [state.messages]);
|
}, [state.messages]);
|
||||||
|
|
||||||
|
const getIntentDisplay = (intent: string) => {
|
||||||
|
switch (intent) {
|
||||||
|
case 'defend': return { icon: '🛡️', text: t('combat.intents.defend', 'Defending') };
|
||||||
|
case 'flee': return { icon: '🏃', text: t('combat.intents.flee', 'Fleeing') };
|
||||||
|
case 'buff': return { icon: '✨', text: t('combat.intents.buff', 'Buffing') };
|
||||||
|
case 'attack': return { icon: '⚔️', text: t('combat.intents.attack', 'Attacking') };
|
||||||
|
case 'charging_attack': return { icon: '⚠️', text: t('combat.intents.charging', 'Charging Attack!') };
|
||||||
|
default:
|
||||||
|
// For skills like bandage_self etc.
|
||||||
|
const skillName = intent.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
return { icon: '🌀', text: t(`combat.intents.${intent}`, skillName) };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="combat-container">
|
<div className="combat-container">
|
||||||
|
|
||||||
@@ -227,6 +246,31 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
height="10px"
|
height="10px"
|
||||||
labelAlignment="right"
|
labelAlignment="right"
|
||||||
/>
|
/>
|
||||||
|
{/* Enemy Intent */}
|
||||||
|
{!state.isPvP && state.npcIntent && !combatResult && (
|
||||||
|
<div style={{ marginTop: '4px', fontSize: '0.85rem', color: '#ffcc00', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: '4px', fontStyle: 'italic' }}>
|
||||||
|
<span>{getIntentDisplay(state.npcIntent).icon}</span>
|
||||||
|
<span>{t('combat.intents.label', 'Next move:')} {getIntentDisplay(state.npcIntent).text}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Enemy Status Effects */}
|
||||||
|
{state.npcEffects && state.npcEffects.length > 0 && (
|
||||||
|
<div className="combat-effects-row">
|
||||||
|
{state.npcEffects.map((eff, i) => (
|
||||||
|
<GameTooltip key={i} content={
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: '2px' }}>{eff.icon} {getTranslatedText(eff.name)}</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#aaa' }}>{getTranslatedText(eff.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#888', marginTop: '2px' }}>{t('combat.log.turns_remaining', { turns: eff.ticks_remaining })}</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<span className="combat-effect-badge effect-debuff">
|
||||||
|
{eff.icon} {eff.ticks_remaining}
|
||||||
|
</span>
|
||||||
|
</GameTooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Player HP (Right) */}
|
{/* Player HP (Right) */}
|
||||||
@@ -241,6 +285,24 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
align="right"
|
align="right"
|
||||||
labelAlignment="left"
|
labelAlignment="left"
|
||||||
/>
|
/>
|
||||||
|
{/* Player Active Buffs/Effects */}
|
||||||
|
{state.playerEffects && state.playerEffects.length > 0 && (
|
||||||
|
<div className="combat-effects-row" style={{ justifyContent: 'flex-end' }}>
|
||||||
|
{state.playerEffects.map((eff, i) => (
|
||||||
|
<GameTooltip key={i} content={
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: '2px' }}>{eff.icon} {getTranslatedText(eff.name)}</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#aaa' }}>{getTranslatedText(eff.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#888', marginTop: '2px' }}>{t('combat.log.turns_remaining', { turns: eff.ticks_remaining })}</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<span className={`combat-effect-badge ${eff.type === 'damage' ? 'effect-debuff' : 'effect-buff'}`}>
|
||||||
|
{eff.icon} {eff.ticks_remaining}
|
||||||
|
</span>
|
||||||
|
</GameTooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -257,9 +319,10 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!combatResult && (
|
{!combatResult && (
|
||||||
<div className="combat-actions-group" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem', width: '100%', maxWidth: '400px', margin: '0 auto' }}>
|
<div className="combat-actions-group" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.5rem', width: '100%', maxWidth: '500px', margin: '0 auto' }}>
|
||||||
<GameButton
|
<GameButton
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
|
||||||
onClick={() => onAction('attack')}
|
onClick={() => onAction('attack')}
|
||||||
disabled={isProcessing || !state.yourTurn}
|
disabled={isProcessing || !state.yourTurn}
|
||||||
>
|
>
|
||||||
@@ -267,13 +330,20 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
</GameButton>
|
</GameButton>
|
||||||
|
|
||||||
<GameButton
|
<GameButton
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
onClick={() => onAction('defend')}
|
onClick={() => onAction('defend')}
|
||||||
disabled={isProcessing || !state.yourTurn}
|
disabled={isProcessing || !state.yourTurn}
|
||||||
>
|
>
|
||||||
🛡️ {t('combat.actions.defend')}
|
🛡️ {t('combat.actions.defend')}
|
||||||
</GameButton>
|
</GameButton>
|
||||||
|
|
||||||
|
<AbilitiesDropdown
|
||||||
|
onAction={onAction}
|
||||||
|
|
||||||
|
disabled={isProcessing || !state.yourTurn}
|
||||||
|
playerStamina={playerStamina}
|
||||||
|
/>
|
||||||
|
|
||||||
<GameButton
|
<GameButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={onShowSupplies}
|
onClick={onShowSupplies}
|
||||||
@@ -323,10 +393,13 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
case 'enemy_miss': text = t('combat.log.enemy_miss'); break;
|
case 'enemy_miss': text = t('combat.log.enemy_miss'); break;
|
||||||
case 'victory': text = t('combat.victory'); className += " text-success bold"; break;
|
case 'victory': text = t('combat.victory'); className += " text-success bold"; break;
|
||||||
case 'player_defeated': text = t('combat.defeat'); className += " text-danger bold"; break;
|
case 'player_defeated': text = t('combat.defeat'); className += " text-danger bold"; break;
|
||||||
case 'flee_success': text = t('combat.flee.success'); break;
|
case 'flee_success': text = t('combat.log.flee_success'); break;
|
||||||
case 'flee_fail': text = t('combat.flee.fail'); break;
|
case 'flee_fail':
|
||||||
case 'item_broken': text = t('combat.item_broken', { item: getTranslatedText(msg.data?.item_name) }); break;
|
text = t('combat.log.flee_fail');
|
||||||
case 'xp_gain': text = t('combat.log.xp_gain', { xp: msg.data?.xp }); className += " text-warning"; break;
|
className += " text-danger";
|
||||||
|
break;
|
||||||
|
case 'item_broken': text = t('combat.log.item_broken', { item: getTranslatedText(msg.data?.item_name), emoji: msg.data?.emoji || '' }); break;
|
||||||
|
case 'xp_gain': text = t('combat.log.xp_gain', { amount: msg.data?.amount }); className += " text-warning"; break;
|
||||||
case 'damage':
|
case 'damage':
|
||||||
if (msg.origin === 'enemy') {
|
if (msg.origin === 'enemy') {
|
||||||
text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 });
|
text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 });
|
||||||
@@ -338,7 +411,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
case 'text': text = getTranslatedText(msg.data?.text) || ""; break;
|
case 'text': text = getTranslatedText(msg.data?.text) || ""; break;
|
||||||
case 'item_used':
|
case 'item_used':
|
||||||
text = t('combat.log.item_used', { item: getTranslatedText(msg.data?.item_name) || '' });
|
text = t('combat.log.item_used', { item: getTranslatedText(msg.data?.item_name) || '' });
|
||||||
if (msg.data?.effects) text += getTranslatedText(msg.data.effects); // Append effects string if backend still sends it
|
if (msg.data?.effects) text += getTranslatedText(msg.data.effects);
|
||||||
className += " text-info";
|
className += " text-info";
|
||||||
break;
|
break;
|
||||||
case 'effect_applied':
|
case 'effect_applied':
|
||||||
@@ -348,7 +421,121 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
});
|
});
|
||||||
className += " text-warning";
|
className += " text-warning";
|
||||||
break;
|
break;
|
||||||
default: text = msg.type;
|
// ── Skill messages ──
|
||||||
|
case 'skill_attack': {
|
||||||
|
const hitsText = msg.data?.hits > 1 ? ` (x${msg.data.hits})` : '';
|
||||||
|
text = t('combat.log.skill_attack', {
|
||||||
|
skill_icon: msg.data?.skill_icon || '⚔️',
|
||||||
|
skill_name: getTranslatedText(msg.data?.skill_name) || '',
|
||||||
|
damage: msg.data?.damage || 0,
|
||||||
|
hits_text: hitsText
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'skill_heal':
|
||||||
|
text = t('combat.log.skill_heal', {
|
||||||
|
skill_icon: msg.data?.skill_icon || '💚',
|
||||||
|
skill_name: getTranslatedText(msg.data?.skill_name) || '',
|
||||||
|
heal: msg.data?.heal || 0
|
||||||
|
});
|
||||||
|
className += " text-success";
|
||||||
|
break;
|
||||||
|
case 'skill_buff':
|
||||||
|
text = t('combat.log.skill_buff', {
|
||||||
|
skill_icon: msg.data?.skill_icon || '🛡️',
|
||||||
|
skill_name: getTranslatedText(msg.data?.skill_name) || ''
|
||||||
|
});
|
||||||
|
className += " text-info";
|
||||||
|
break;
|
||||||
|
case 'skill_effect':
|
||||||
|
text = msg.data?.message || '';
|
||||||
|
className += " text-info";
|
||||||
|
break;
|
||||||
|
case 'skill_analyze':
|
||||||
|
text = t('combat.log.skill_analyze', { skill_icon: msg.data?.skill_icon || '🔍' });
|
||||||
|
className += " text-info";
|
||||||
|
break;
|
||||||
|
// ── Combat reactions ──
|
||||||
|
case 'combat_crit':
|
||||||
|
text = t('combat.log.combat_crit');
|
||||||
|
className += " text-warning bold";
|
||||||
|
break;
|
||||||
|
case 'combat_dodge':
|
||||||
|
text = t('combat.log.combat_dodge');
|
||||||
|
className += " text-success";
|
||||||
|
break;
|
||||||
|
case 'combat_block':
|
||||||
|
text = t('combat.log.combat_block');
|
||||||
|
className += " text-success";
|
||||||
|
break;
|
||||||
|
case 'damage_reduced':
|
||||||
|
text = t('combat.log.damage_reduced', { reduction: msg.data?.reduction || 0 });
|
||||||
|
className += " text-info";
|
||||||
|
break;
|
||||||
|
case 'player_defend':
|
||||||
|
text = t('combat.log.defend');
|
||||||
|
className += " text-info bold";
|
||||||
|
break;
|
||||||
|
// ── Enemy actions ──
|
||||||
|
case 'enemy_enraged':
|
||||||
|
text = t('combat.log.enemy_enraged', { npc_name: getTranslatedText(msg.data?.npc_name) || t('common.enemy') });
|
||||||
|
className += " text-danger bold";
|
||||||
|
break;
|
||||||
|
case 'enemy_defend':
|
||||||
|
text = t('combat.log.enemy_defend', { heal: msg.data?.heal || 0 });
|
||||||
|
className += " text-danger";
|
||||||
|
break;
|
||||||
|
case 'enemy_special':
|
||||||
|
text = t('combat.log.enemy_special', { damage: msg.data?.damage || 0 });
|
||||||
|
className += " text-danger bold";
|
||||||
|
break;
|
||||||
|
// ── Status effects ──
|
||||||
|
case 'effect_damage':
|
||||||
|
if (msg.origin === 'enemy') {
|
||||||
|
text = t('combat.log.effect_damage_npc', { damage: msg.data?.damage || 0 });
|
||||||
|
} else {
|
||||||
|
text = t('combat.log.effect_damage', { damage: msg.data?.damage || 0 });
|
||||||
|
}
|
||||||
|
className += " text-danger";
|
||||||
|
break;
|
||||||
|
case 'effect_bleeding':
|
||||||
|
text = t('combat.log.effect_bleeding', { damage: msg.data?.damage || 0 });
|
||||||
|
className += " text-danger";
|
||||||
|
break;
|
||||||
|
case 'effect_heal':
|
||||||
|
text = t('combat.log.effect_heal', { heal: msg.data?.heal || 0 });
|
||||||
|
className += " text-success";
|
||||||
|
break;
|
||||||
|
// ── Items ──
|
||||||
|
case 'weapon_broke':
|
||||||
|
text = t('combat.log.weapon_broke', { item_name: getTranslatedText(msg.data?.item_name) || '' });
|
||||||
|
className += " text-danger";
|
||||||
|
break;
|
||||||
|
case 'item_heal':
|
||||||
|
text = t('combat.log.item_heal', { heal: msg.data?.heal || 0 });
|
||||||
|
className += " text-success";
|
||||||
|
break;
|
||||||
|
case 'item_restore':
|
||||||
|
text = t('combat.log.item_restore', { amount: msg.data?.amount || 0, stat: msg.data?.stat || '' });
|
||||||
|
className += " text-info";
|
||||||
|
break;
|
||||||
|
case 'item_damage':
|
||||||
|
text = t('combat.log.item_damage', { item: getTranslatedText(msg.data?.item_name) || '', damage: msg.data?.damage || 0 });
|
||||||
|
break;
|
||||||
|
// ── Outcomes ──
|
||||||
|
case 'level_up':
|
||||||
|
text = t('combat.log.level_up', { new_level: msg.data?.new_level || 0 });
|
||||||
|
className += " text-warning bold";
|
||||||
|
break;
|
||||||
|
case 'died':
|
||||||
|
text = t('combat.log.died');
|
||||||
|
className += " text-danger bold";
|
||||||
|
break;
|
||||||
|
case 'quest_update':
|
||||||
|
text = msg.data?.message || '';
|
||||||
|
className += " text-info";
|
||||||
|
break;
|
||||||
|
default: text = msg.data?.message || msg.type;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const time = msg.timestamp || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
const time = msg.timestamp || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
@@ -377,3 +564,119 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
</div >
|
</div >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Abilities Dropdown ───
|
||||||
|
interface SkillInfo {
|
||||||
|
id: string;
|
||||||
|
name: any;
|
||||||
|
description: any;
|
||||||
|
icon: string;
|
||||||
|
stamina_cost: number;
|
||||||
|
cooldown: number;
|
||||||
|
current_cooldown?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AbilitiesDropdown: React.FC<{
|
||||||
|
onAction: (action: string) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
playerStamina?: number;
|
||||||
|
}> = ({ onAction, disabled, playerStamina }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
const loadSkills = async () => {
|
||||||
|
if (open) {
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.get('/api/game/available-skills');
|
||||||
|
setSkills(res.data.skills || []);
|
||||||
|
setLoaded(true);
|
||||||
|
setOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load skills:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUse = (skillId: string) => {
|
||||||
|
setOpen(false);
|
||||||
|
onAction(`skill:${skillId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<GameButton
|
||||||
|
variant="info"
|
||||||
|
onClick={loadSkills}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
⚔️ {t('combat.actions.abilities')}
|
||||||
|
</GameButton>
|
||||||
|
{open && skills.length > 0 && (
|
||||||
|
<GameDropdown
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
width="250px"
|
||||||
|
>
|
||||||
|
{skills.map(s => {
|
||||||
|
const onCooldown = (s.current_cooldown || 0) > 0;
|
||||||
|
const notEnoughStamina = playerStamina !== undefined && playerStamina < s.stamina_cost;
|
||||||
|
const isSkillDisabled = disabled || onCooldown || notEnoughStamina;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameTooltip key={s.id} content={
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: '3px' }}>{s.icon} {getTranslatedText(s.name)}</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#ccc', marginBottom: '4px' }}>{getTranslatedText(s.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#f0c040', display: 'flex', gap: '8px' }}>
|
||||||
|
<span>⚡ {s.stamina_cost} {t('combat.stamina', 'Stamina')}</span>
|
||||||
|
<span>⏳ {t('combat.cooldown_turns', { turns: s.cooldown })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<GameButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleUse(s.id)}
|
||||||
|
disabled={isSkillDisabled}
|
||||||
|
style={{ width: '100%', justifyContent: 'flex-start', marginBottom: '4px' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: '0.4rem', filter: isSkillDisabled ? 'grayscale(100%)' : 'none' }}>
|
||||||
|
<span style={{ fontSize: '1rem' }}>{s.icon}</span>
|
||||||
|
<span style={{ flex: 1, textAlign: 'left', color: isSkillDisabled ? '#808090' : '#d0d0e0' }}>{getTranslatedText(s.name)}</span>
|
||||||
|
{onCooldown ? (
|
||||||
|
<span style={{ color: '#ff9f43', fontSize: '0.7rem', fontWeight: 'bold' }}>⏳ {s.current_cooldown}</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: notEnoughStamina ? '#e53e3e' : '#f0c040', fontSize: '0.7rem', fontWeight: 600 }}>⚡{s.stamina_cost}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GameButton>
|
||||||
|
</GameTooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</GameDropdown>
|
||||||
|
)}
|
||||||
|
{open && skills.length === 0 && loaded && (
|
||||||
|
<GameDropdown
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
width="200px"
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
padding: '0.5rem',
|
||||||
|
color: '#8a8a9a',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
{t('combat.noSkills', 'No skills available')}
|
||||||
|
</div>
|
||||||
|
</GameDropdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const EffectBadge: React.FC<EffectBadgeProps> = ({ effect }) => {
|
|||||||
: getTranslatedText(effect.name);
|
: getTranslatedText(effect.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`stat-badge ${badgeClass}`}>
|
<span className={`stat-badge ${badgeClass}`} style={{ padding: '2px 6px', fontSize: '0.75rem', lineHeight: '1' }}>
|
||||||
{effect.icon}
|
{effect.icon}
|
||||||
{effect.damage_per_tick ? (
|
{effect.damage_per_tick ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.game-modal-title {
|
.game-modal-title {
|
||||||
|
flex: 1;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -105,3 +106,21 @@
|
|||||||
.game-modal-container.entity-show-all-modal {
|
.game-modal-container.entity-show-all-modal {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Globally standard wide modals
|
||||||
|
* Character Sheet, Quest Journal, Workbench, Trade, Inventory
|
||||||
|
*/
|
||||||
|
.game-modal-container.character-sheet-modal,
|
||||||
|
.game-modal-container.quest-journal-modal,
|
||||||
|
.game-modal-container.workbench-modal,
|
||||||
|
.game-modal-container.inventory-modal-redesign,
|
||||||
|
.game-modal-container.trade-modal {
|
||||||
|
width: 95vw !important;
|
||||||
|
max-width: 1400px !important;
|
||||||
|
height: 90% !important;
|
||||||
|
max-height: 90% !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import React, { ReactNode } from 'react';
|
|||||||
import './GameModal.css';
|
import './GameModal.css';
|
||||||
|
|
||||||
interface GameModalProps {
|
interface GameModalProps {
|
||||||
title?: string;
|
title?: ReactNode;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string; // For specific styling overrides
|
className?: string; // For specific styling overrides
|
||||||
@@ -16,7 +16,7 @@ export const GameModal: React.FC<GameModalProps> = ({ title, onClose, children,
|
|||||||
}}>
|
}}>
|
||||||
<div className={`game-modal-container ${className}`}>
|
<div className={`game-modal-container ${className}`}>
|
||||||
<div className="game-modal-header">
|
<div className="game-modal-header">
|
||||||
<h2 className="game-modal-title">{title}</h2>
|
<div className="game-modal-title">{title}</div>
|
||||||
<button className="game-modal-close-btn" onClick={onClose}>×</button>
|
<button className="game-modal-close-btn" onClick={onClose}>×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ interface LocationViewProps {
|
|||||||
onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void
|
onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void
|
||||||
failedActionItemId: string | number | null
|
failedActionItemId: string | number | null
|
||||||
quests: { active: any[], available: any[] }
|
quests: { active: any[], available: any[] }
|
||||||
|
craftedItemResult: any | null
|
||||||
|
onCloseCraftedItemResult: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function LocationView({
|
function LocationView({
|
||||||
@@ -90,6 +92,8 @@ function LocationView({
|
|||||||
craftCategoryFilter,
|
craftCategoryFilter,
|
||||||
profile,
|
profile,
|
||||||
quests,
|
quests,
|
||||||
|
craftedItemResult,
|
||||||
|
onCloseCraftedItemResult,
|
||||||
|
|
||||||
onInitiateCombat,
|
onInitiateCombat,
|
||||||
onInitiatePvP,
|
onInitiatePvP,
|
||||||
@@ -810,6 +814,8 @@ function LocationView({
|
|||||||
onCraft={onCraft}
|
onCraft={onCraft}
|
||||||
onRepair={onRepair}
|
onRepair={onRepair}
|
||||||
onUncraft={onUncraft}
|
onUncraft={onUncraft}
|
||||||
|
craftedItemResult={craftedItemResult}
|
||||||
|
onCloseCraftedItemResult={onCloseCraftedItemResult}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { GameButton } from '../common/GameButton'
|
|||||||
import { GameItemCard } from '../common/GameItemCard'
|
import { GameItemCard } from '../common/GameItemCard'
|
||||||
import { GameDropdown } from '../common/GameDropdown'
|
import { GameDropdown } from '../common/GameDropdown'
|
||||||
import { useAudio } from '../../contexts/AudioContext'
|
import { useAudio } from '../../contexts/AudioContext'
|
||||||
|
import { EffectBadge } from './EffectBadge'
|
||||||
|
|
||||||
interface PlayerSidebarProps {
|
interface PlayerSidebarProps {
|
||||||
playerState: PlayerState
|
playerState: PlayerState
|
||||||
@@ -27,6 +28,7 @@ interface PlayerSidebarProps {
|
|||||||
onDropItem: (itemId: number, invId: number, quantity: number) => void
|
onDropItem: (itemId: number, invId: number, quantity: number) => void
|
||||||
onSpendPoint: (stat: string) => void
|
onSpendPoint: (stat: string) => void
|
||||||
onOpenQuestJournal: () => void
|
onOpenQuestJournal: () => void
|
||||||
|
onOpenCharacterSheet: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlayerSidebar({
|
function PlayerSidebar({
|
||||||
@@ -43,7 +45,8 @@ function PlayerSidebar({
|
|||||||
onUnequipItem,
|
onUnequipItem,
|
||||||
onDropItem,
|
onDropItem,
|
||||||
onSpendPoint,
|
onSpendPoint,
|
||||||
onOpenQuestJournal
|
onOpenQuestJournal,
|
||||||
|
onOpenCharacterSheet
|
||||||
}: PlayerSidebarProps) {
|
}: PlayerSidebarProps) {
|
||||||
const [showInventory, setShowInventory] = useState(false)
|
const [showInventory, setShowInventory] = useState(false)
|
||||||
const [activeSlot, setActiveSlot] = useState<string | null>(null)
|
const [activeSlot, setActiveSlot] = useState<string | null>(null)
|
||||||
@@ -138,14 +141,18 @@ function PlayerSidebar({
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
{t('stats.hp')}
|
{t('stats.hp')}
|
||||||
<div className="status-indicators" style={{ display: 'flex', gap: '5px' }}>
|
<div className="status-indicators" style={{ display: 'flex', gap: '5px' }}>
|
||||||
{playerState.status_effects?.filter((e: any) => e.damage_per_tick !== 0).map((e: any) => (
|
{playerState.status_effects?.map((e: any) => (
|
||||||
<span key={e.id} className={`stat-indicator ${e.damage_per_tick > 0 ? 'negative' : 'positive'}`} style={{
|
<GameTooltip key={e.effect_name || e.id} content={`${getTranslatedText(e.description, { interval: state?.combatState?.inCombat ? t('stats.interval_turn') : t('stats.interval_minute'), intervals_plural: state?.combatState?.inCombat ? t('stats.intervals_turn') : t('stats.intervals_minute') })} (${e.ticks_remaining} ${t('game.ticksRemaining', 'ticks left')})`}>
|
||||||
color: e.damage_per_tick > 0 ? '#ff6b6b' : '#4caf50',
|
<div style={{ display: 'inline-block' }}>
|
||||||
fontSize: '0.85rem',
|
<EffectBadge effect={{
|
||||||
fontWeight: 'bold'
|
name: e.name || e.effect_name,
|
||||||
}}>
|
icon: e.icon,
|
||||||
{e.damage_per_tick > 0 ? `-${e.damage_per_tick}` : `+${Math.abs(e.damage_per_tick)}`}/t ({e.ticks_remaining})
|
type: e.type || (e.damage_per_tick > 0 ? 'damage' : 'buff'),
|
||||||
</span>
|
damage_per_tick: e.damage_per_tick,
|
||||||
|
ticks: e.ticks_remaining
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</GameTooltip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,6 +270,16 @@ function PlayerSidebar({
|
|||||||
{t('game.inventory')}
|
{t('game.inventory')}
|
||||||
</GameButton>
|
</GameButton>
|
||||||
|
|
||||||
|
<GameButton
|
||||||
|
className="quest-journal-btn"
|
||||||
|
variant="info"
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpenCharacterSheet}
|
||||||
|
style={{ flex: 1, justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
<span>📊</span> {t('common.characterSheet', 'Stats')}
|
||||||
|
</GameButton>
|
||||||
|
|
||||||
<GameButton
|
<GameButton
|
||||||
className={`quest-journal-btn ${hasReadyQuests ? 'pulse-gold' : ''}`}
|
className={`quest-journal-btn ${hasReadyQuests ? 'pulse-gold' : ''}`}
|
||||||
variant={hasReadyQuests ? 'warning' : 'secondary'}
|
variant={hasReadyQuests ? 'warning' : 'secondary'}
|
||||||
@@ -270,7 +287,7 @@ function PlayerSidebar({
|
|||||||
onClick={onOpenQuestJournal}
|
onClick={onOpenQuestJournal}
|
||||||
style={{ flex: 1, justifyContent: 'center' }}
|
style={{ flex: 1, justifyContent: 'center' }}
|
||||||
>
|
>
|
||||||
{hasReadyQuests ? '❗ ' : '📜 '}{t('common.quests')}
|
<span>{hasReadyQuests ? '❗' : '📜'}</span> {t('common.quests')}
|
||||||
</GameButton>
|
</GameButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
.quest-journal-modal {
|
.quest-journal-modal {
|
||||||
width: 90vw;
|
display: flex;
|
||||||
max-width: 1200px;
|
flex-direction: column;
|
||||||
height: 95%;
|
width: 95vw;
|
||||||
|
max-width: 1400px;
|
||||||
|
height: 90%;
|
||||||
|
max-height: 90%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quest-journal-modal .game-modal-content {
|
.quest-journal-modal .game-modal-content {
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export const QuestJournal: React.FC<QuestJournalProps> = ({ onClose }) => {
|
|||||||
>
|
>
|
||||||
<div className="game-modal-content" style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 0 }}>
|
<div className="game-modal-content" style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 0 }}>
|
||||||
{/* Header / Tabs */}
|
{/* Header / Tabs */}
|
||||||
<div style={{ padding: '10px 20px', background: 'rgba(0,0,0,0.5)', borderBottom: '1px solid #333' }}>
|
<div style={{ padding: '10px 20px', background: 'var(--game-bg-panel)', borderBottom: '1px solid var(--game-border-color)' }}>
|
||||||
<div className="tab-container">
|
<div className="tab-container">
|
||||||
<button
|
<button
|
||||||
className={`journal-tab ${activeTab === 'active' ? 'active' : ''}`}
|
className={`journal-tab ${activeTab === 'active' ? 'active' : ''}`}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ interface WorkbenchProps {
|
|||||||
onCraft: (itemId: number) => void
|
onCraft: (itemId: number) => void
|
||||||
onRepair: (uniqueItemId: string, inventoryId: number) => void
|
onRepair: (uniqueItemId: string, inventoryId: number) => void
|
||||||
onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void
|
onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void
|
||||||
|
craftedItemResult: any | null
|
||||||
|
onCloseCraftedItemResult: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function Workbench({
|
function Workbench({
|
||||||
@@ -50,12 +52,15 @@ function Workbench({
|
|||||||
onSetCraftCategoryFilter,
|
onSetCraftCategoryFilter,
|
||||||
onCraft,
|
onCraft,
|
||||||
onRepair,
|
onRepair,
|
||||||
onUncraft
|
onUncraft,
|
||||||
|
craftedItemResult,
|
||||||
|
onCloseCraftedItemResult
|
||||||
}: WorkbenchProps) {
|
}: WorkbenchProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [selectedItem, setSelectedItem] = useState<any>(null)
|
const [selectedItem, setSelectedItem] = useState<any>(null)
|
||||||
const [salvageQuantity, setSalvageQuantity] = useState<number>(1)
|
const [salvageQuantity, setSalvageQuantity] = useState<number>(1)
|
||||||
|
const [showSalvageModal, setShowSalvageModal] = useState<boolean>(false)
|
||||||
|
|
||||||
// Reset selection when tab changes
|
// Reset selection when tab changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -448,10 +453,7 @@ function Workbench({
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={(profile?.stamina || 0) < ((item.stamina_cost || 1) * salvageQuantity)}
|
disabled={(profile?.stamina || 0) < ((item.stamina_cost || 1) * salvageQuantity)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const confirmMsg = t('crafting.confirmSalvage', { name: getTranslatedText(item.name) })
|
setShowSalvageModal(true)
|
||||||
if (window.confirm(`${confirmMsg} (x${salvageQuantity})`)) {
|
|
||||||
onUncraft(item.unique_item_id, item.inventory_id, salvageQuantity)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
>
|
>
|
||||||
@@ -677,6 +679,99 @@ function Workbench({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showSalvageModal && selectedItem && (
|
||||||
|
<GameModal
|
||||||
|
title={`♻️ ${t('game.salvage')}`}
|
||||||
|
onClose={() => setShowSalvageModal(false)}
|
||||||
|
className="salvage-confirm-modal"
|
||||||
|
>
|
||||||
|
<div style={{ padding: '1rem', textAlign: 'center' }}>
|
||||||
|
<p>{t('crafting.confirmSalvage', { name: getTranslatedText(selectedItem.name) })} (x{salvageQuantity})</p>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', marginTop: '2rem', justifyContent: 'center' }}>
|
||||||
|
<GameButton variant="secondary" onClick={() => setShowSalvageModal(false)}>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</GameButton>
|
||||||
|
<GameButton variant="danger" onClick={() => {
|
||||||
|
onUncraft(selectedItem.unique_item_id, selectedItem.inventory_id, salvageQuantity)
|
||||||
|
setShowSalvageModal(false)
|
||||||
|
}}>
|
||||||
|
{t('common.confirm', 'Confirm')}
|
||||||
|
</GameButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GameModal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Crafted Item Feedback Modal */}
|
||||||
|
{craftedItemResult && (
|
||||||
|
<GameModal
|
||||||
|
title={`✨ ${t('crafting.successTitle', 'Crafting Successful!')}`}
|
||||||
|
onClose={onCloseCraftedItemResult}
|
||||||
|
className="crafted-item-modal"
|
||||||
|
>
|
||||||
|
<div style={{ padding: '1rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
|
<div className="item-image-thumb" style={{ width: '80px', height: '80px', marginBottom: '1rem' }}>
|
||||||
|
{craftedItemResult.image_path ? (
|
||||||
|
<img
|
||||||
|
src={getAssetPath(craftedItemResult.image_path)}
|
||||||
|
alt={getTranslatedText(craftedItemResult.name)}
|
||||||
|
className="item-thumb-img"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
const icon = (e.target as HTMLImageElement).nextElementSibling;
|
||||||
|
if (icon) icon.classList.remove('hidden');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className={`item-thumb-emoji ${craftedItemResult.image_path ? 'hidden' : ''}`} style={{ fontSize: '3rem' }}>
|
||||||
|
{craftedItemResult.emoji || '📦'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style={{ margin: '0 0 0.5rem 0', color: '#ecc94b' }}>
|
||||||
|
{getTranslatedText(craftedItemResult.name)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{craftedItemResult.tier && (
|
||||||
|
<span className={`text-tier-${craftedItemResult.tier}`} style={{ marginBottom: '1rem', fontWeight: 'bold' }}>
|
||||||
|
Tier {craftedItemResult.tier}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap', width: '100%' }}>
|
||||||
|
{Object.entries(craftedItemResult.unique_item_data?.unique_stats ?? craftedItemResult.unique_item_data ?? craftedItemResult.base_stats ?? craftedItemResult.stats ?? {})
|
||||||
|
.filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k))
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
weight_capacity: `⚖️ ${t('game.weight')}`,
|
||||||
|
volume_capacity: `📦 ${t('game.volume')}`,
|
||||||
|
armor: `🛡️ ${t('stats.armor')}`,
|
||||||
|
hp_max: `❤️ ${t('stats.maxHp')}`,
|
||||||
|
stamina_max: `⚡ ${t('stats.maxStamina')}`,
|
||||||
|
damage_min: `⚔️ ${t('stats.damage')} Min`,
|
||||||
|
damage_max: `⚔️ ${t('stats.damage')} Max`
|
||||||
|
}
|
||||||
|
const label = icons[key] || key.replace('_', ' ')
|
||||||
|
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
|
||||||
|
return (
|
||||||
|
<div key={key} className="stat-badge" style={{ background: 'rgba(0,0,0,0.3)', padding: '0.5rem 1rem', borderRadius: '4px', fontSize: '1rem', color: '#ccc' }}>
|
||||||
|
<span style={{ color: '#aaa' }}>{label}:</span> <span style={{ color: '#fff', fontWeight: 'bold' }}>+{Math.round(Number(value))}{unit}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '2rem' }}>
|
||||||
|
<GameButton variant="primary" onClick={onCloseCraftedItemResult}>
|
||||||
|
{t('common.continue', 'Continue')}
|
||||||
|
</GameButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GameModal>
|
||||||
|
)}
|
||||||
|
|
||||||
</GameModal>
|
</GameModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface GameEngineState {
|
|||||||
uncraftFilter: string
|
uncraftFilter: string
|
||||||
inventoryFilter: string
|
inventoryFilter: string
|
||||||
inventoryCategoryFilter: string
|
inventoryCategoryFilter: string
|
||||||
|
craftedItemResult: any | null
|
||||||
|
|
||||||
// PvP state
|
// PvP state
|
||||||
lastSeenPvPAction: string | null
|
lastSeenPvPAction: string | null
|
||||||
@@ -130,6 +131,7 @@ export interface GameEngineActions {
|
|||||||
setInventoryFilter: (filter: string) => void
|
setInventoryFilter: (filter: string) => void
|
||||||
setInventoryCategoryFilter: (filter: string) => void
|
setInventoryCategoryFilter: (filter: string) => void
|
||||||
toggleCategoryCollapse: (category: string) => void
|
toggleCategoryCollapse: (category: string) => void
|
||||||
|
setCraftedItemResult: (result: any) => void
|
||||||
|
|
||||||
// WebSocket helpers
|
// WebSocket helpers
|
||||||
refreshLocation: () => Promise<void>
|
refreshLocation: () => Promise<void>
|
||||||
@@ -142,6 +144,7 @@ export interface GameEngineActions {
|
|||||||
addNPCToLocation: (npc: any) => void
|
addNPCToLocation: (npc: any) => void
|
||||||
removeNPCFromLocation: (enemyId: string) => void
|
removeNPCFromLocation: (enemyId: string) => void
|
||||||
updateStatusEffect: (effectName: string | any, remainingTicks: number) => void
|
updateStatusEffect: (effectName: string | any, remainingTicks: number) => void
|
||||||
|
updateEquipment: (equipmentData: any) => void
|
||||||
|
|
||||||
// Quests
|
// Quests
|
||||||
updateQuests: (active: any[], available: any[]) => void
|
updateQuests: (active: any[], available: any[]) => void
|
||||||
@@ -186,6 +189,7 @@ export function useGameEngine(
|
|||||||
const [uncraftableItems, setUncraftableItems] = useState<any[]>([])
|
const [uncraftableItems, setUncraftableItems] = useState<any[]>([])
|
||||||
const [inventoryFilter, setInventoryFilter] = useState<string>('')
|
const [inventoryFilter, setInventoryFilter] = useState<string>('')
|
||||||
const [inventoryCategoryFilter, setInventoryCategoryFilter] = useState<string>('all')
|
const [inventoryCategoryFilter, setInventoryCategoryFilter] = useState<string>('all')
|
||||||
|
const [craftedItemResult, setCraftedItemResult] = useState<any | null>(null)
|
||||||
const [lastSeenPvPAction, setLastSeenPvPAction] = useState<string | null>(null)
|
const [lastSeenPvPAction, setLastSeenPvPAction] = useState<string | null>(null)
|
||||||
const [_pvpTimeRemaining, _setPvpTimeRemaining] = useState<number | null>(null)
|
const [_pvpTimeRemaining, _setPvpTimeRemaining] = useState<number | null>(null)
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState<MobileMenuState>('none')
|
const [mobileMenuOpen, setMobileMenuOpen] = useState<MobileMenuState>('none')
|
||||||
@@ -483,7 +487,8 @@ export function useGameEngine(
|
|||||||
in_combat: true,
|
in_combat: true,
|
||||||
combat_over: false,
|
combat_over: false,
|
||||||
player_won: false,
|
player_won: false,
|
||||||
combat: encounter.combat
|
combat: encounter.combat,
|
||||||
|
player_effects: encounter.player_effects || []
|
||||||
})
|
})
|
||||||
|
|
||||||
setCombatLog([])
|
setCombatLog([])
|
||||||
@@ -663,7 +668,8 @@ export function useGameEngine(
|
|||||||
mobileHeaderOpen,
|
mobileHeaderOpen,
|
||||||
locationMessages,
|
locationMessages,
|
||||||
interactableCooldowns,
|
interactableCooldowns,
|
||||||
forceUpdate: _forceUpdate
|
forceUpdate: _forceUpdate,
|
||||||
|
craftedItemResult
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUseItem = async (itemId: string) => {
|
const handleUseItem = async (itemId: string) => {
|
||||||
@@ -779,6 +785,9 @@ export function useGameEngine(
|
|||||||
// setMessage('Crafting...') // Loading state ok to keep specific or remove? Let's remove to avoid spam
|
// setMessage('Crafting...') // Loading state ok to keep specific or remove? Let's remove to avoid spam
|
||||||
const response = await api.post('/api/game/craft_item', { item_id: itemId })
|
const response = await api.post('/api/game/craft_item', { item_id: itemId })
|
||||||
addLocationMessage(response.data.message || 'Item crafted!')
|
addLocationMessage(response.data.message || 'Item crafted!')
|
||||||
|
if (response.data.item) {
|
||||||
|
setCraftedItemResult(response.data.item)
|
||||||
|
}
|
||||||
await refreshWorkbenchData()
|
await refreshWorkbenchData()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
addLocationMessage(error.response?.data?.detail || 'Failed to craft item')
|
addLocationMessage(error.response?.data?.detail || 'Failed to craft item')
|
||||||
@@ -870,7 +879,8 @@ export function useGameEngine(
|
|||||||
in_combat: true,
|
in_combat: true,
|
||||||
combat_over: false,
|
combat_over: false,
|
||||||
player_won: false,
|
player_won: false,
|
||||||
combat: response.data.combat
|
combat: response.data.combat,
|
||||||
|
player_effects: response.data.player_effects || []
|
||||||
})
|
})
|
||||||
|
|
||||||
setEnemyName(response.data.combat.npc_name)
|
setEnemyName(response.data.combat.npc_name)
|
||||||
@@ -895,7 +905,9 @@ export function useGameEngine(
|
|||||||
const handleCombatAction = async (action: string) => {
|
const handleCombatAction = async (action: string) => {
|
||||||
try {
|
try {
|
||||||
let payload: any = { action }
|
let payload: any = { action }
|
||||||
if (action.includes(':')) {
|
if (action.startsWith('skill:')) {
|
||||||
|
payload = { action: 'skill', skill_id: action.substring(6) }
|
||||||
|
} else if (action.includes(':')) {
|
||||||
const [act, itemId] = action.split(':')
|
const [act, itemId] = action.split(':')
|
||||||
payload = { action: act, item_id: itemId }
|
payload = { action: act, item_id: itemId }
|
||||||
}
|
}
|
||||||
@@ -906,6 +918,10 @@ export function useGameEngine(
|
|||||||
response.data.quest_updates.forEach((q: any) => handleQuestUpdate(q))
|
response.data.quest_updates.forEach((q: any) => handleQuestUpdate(q))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if (response.data.equipment) {
|
||||||
|
// setEquipment(response.data.equipment)
|
||||||
|
// }
|
||||||
|
|
||||||
return response.data
|
return response.data
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setMessage(error.response?.data?.detail || 'Combat action failed')
|
setMessage(error.response?.data?.detail || 'Combat action failed')
|
||||||
@@ -939,7 +955,9 @@ export function useGameEngine(
|
|||||||
const handlePvPAction = async (action: string, _targetId: number) => {
|
const handlePvPAction = async (action: string, _targetId: number) => {
|
||||||
try {
|
try {
|
||||||
let payload: any = { action }
|
let payload: any = { action }
|
||||||
if (action.includes(':')) {
|
if (action.startsWith('skill:')) {
|
||||||
|
payload = { action: 'skill', skill_id: action.substring(6) }
|
||||||
|
} else if (action.includes(':')) {
|
||||||
const [act, itemId] = action.split(':')
|
const [act, itemId] = action.split(':')
|
||||||
payload = { action: act, item_id: itemId }
|
payload = { action: act, item_id: itemId }
|
||||||
}
|
}
|
||||||
@@ -1079,7 +1097,8 @@ export function useGameEngine(
|
|||||||
setCombatState({
|
setCombatState({
|
||||||
in_combat: true,
|
in_combat: true,
|
||||||
combat_over: false,
|
combat_over: false,
|
||||||
combat: combatRes.data.combat
|
combat: combatRes.data.combat,
|
||||||
|
player_effects: combatRes.data.player_effects || []
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update enemy name/image state
|
// Update enemy name/image state
|
||||||
@@ -1116,6 +1135,12 @@ export function useGameEngine(
|
|||||||
if (playerData.max_stamina !== undefined) {
|
if (playerData.max_stamina !== undefined) {
|
||||||
mappedData.max_stamina = playerData.max_stamina
|
mappedData.max_stamina = playerData.max_stamina
|
||||||
}
|
}
|
||||||
|
if (playerData.status_effects !== undefined) {
|
||||||
|
mappedData.status_effects = playerData.status_effects
|
||||||
|
}
|
||||||
|
if (playerData.equipment !== undefined) {
|
||||||
|
setEquipment(playerData.equipment)
|
||||||
|
}
|
||||||
|
|
||||||
// Update playerState with mapped fields
|
// Update playerState with mapped fields
|
||||||
if (Object.keys(mappedData).length > 0) {
|
if (Object.keys(mappedData).length > 0) {
|
||||||
@@ -1257,6 +1282,8 @@ export function useGameEngine(
|
|||||||
setUncraftFilter,
|
setUncraftFilter,
|
||||||
setInventoryFilter,
|
setInventoryFilter,
|
||||||
setInventoryCategoryFilter,
|
setInventoryCategoryFilter,
|
||||||
|
setCraftedItemResult,
|
||||||
|
updateEquipment: (data: any) => setEquipment(data),
|
||||||
// WebSocket helper functions
|
// WebSocket helper functions
|
||||||
refreshLocation,
|
refreshLocation,
|
||||||
refreshCombat,
|
refreshCombat,
|
||||||
|
|||||||
59
pwa/src/contexts/NotificationContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -257,7 +257,11 @@
|
|||||||
"agi": "AGI",
|
"agi": "AGI",
|
||||||
"end": "END",
|
"end": "END",
|
||||||
"hpMax": "HP max",
|
"hpMax": "HP max",
|
||||||
"stmMax": "Stm max"
|
"stmMax": "Stm max",
|
||||||
|
"interval_turn": "turn",
|
||||||
|
"intervals_turn": "turns",
|
||||||
|
"interval_minute": "minute",
|
||||||
|
"intervals_minute": "minutes"
|
||||||
},
|
},
|
||||||
"combat": {
|
"combat": {
|
||||||
"title": "Combat",
|
"title": "Combat",
|
||||||
@@ -285,6 +289,14 @@
|
|||||||
"yourTurnTimer": "Your Turn ({{time}})",
|
"yourTurnTimer": "Your Turn ({{time}})",
|
||||||
"enemyTurnTimer": "Enemy Turn",
|
"enemyTurnTimer": "Enemy Turn",
|
||||||
"waiting": "Waiting for opponent...",
|
"waiting": "Waiting for opponent...",
|
||||||
|
"intents": {
|
||||||
|
"label": "Next move:",
|
||||||
|
"defend": "Defending",
|
||||||
|
"flee": "Fleeing",
|
||||||
|
"buff": "Buffing",
|
||||||
|
"attack": "Attacking",
|
||||||
|
"charging": "Charging Attack!"
|
||||||
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"combat_start": "Combat started with {{enemy}}!",
|
"combat_start": "Combat started with {{enemy}}!",
|
||||||
"player_attack": "You attack for {{damage}} damage!",
|
"player_attack": "You attack for {{damage}} damage!",
|
||||||
@@ -298,8 +310,11 @@
|
|||||||
"defend": "Defend",
|
"defend": "Defend",
|
||||||
"flee": "Flee",
|
"flee": "Flee",
|
||||||
"supplies": "Supplies",
|
"supplies": "Supplies",
|
||||||
"useItem": "Use Item"
|
"useItem": "Use Item",
|
||||||
|
"abilities": "Abilities"
|
||||||
},
|
},
|
||||||
|
"stamina": "Stamina",
|
||||||
|
"cooldown_turns": "{{turns}} turn cooldown",
|
||||||
"status": {
|
"status": {
|
||||||
"attacking": "Attacking...",
|
"attacking": "Attacking...",
|
||||||
"defending": "Bracing for impact...",
|
"defending": "Bracing for impact...",
|
||||||
@@ -329,14 +344,36 @@
|
|||||||
"enemy_attack": "Enemy hits for {{damage}} damage",
|
"enemy_attack": "Enemy hits for {{damage}} damage",
|
||||||
"player_miss": "You missed!",
|
"player_miss": "You missed!",
|
||||||
"enemy_miss": "Enemy missed!",
|
"enemy_miss": "Enemy missed!",
|
||||||
"item_broken": "Your {{item}} broke!",
|
"weapon_broke": "Your {{item_name}} broke!",
|
||||||
"xp_gain": "You gained {{xp}} XP!",
|
"item_broken": "Your {{emoji}} {{item_name}} broke!",
|
||||||
|
"combat_crit": "CRITICAL HIT!",
|
||||||
|
"combat_dodge": "You dodged the attack!",
|
||||||
|
"combat_block": "You blocked the attack!",
|
||||||
|
"xp_gain": "Gained {{amount}} XP",
|
||||||
"flee_success": "You managed to escape!",
|
"flee_success": "You managed to escape!",
|
||||||
|
"flee_fail": "Failed to escape!",
|
||||||
"defend": "You brace for impact!",
|
"defend": "You brace for impact!",
|
||||||
"item_used": "Used {{item}}",
|
"item_used": "Used {{item}}",
|
||||||
"effect_applied": "Applied {{effect}} to {{target}}",
|
"effect_applied": "Applied {{effect}} to {{target}}",
|
||||||
"item_damage": "{{item}} deals {{damage}} damage!",
|
"item_damage": "{{item}} deals {{damage}} damage!",
|
||||||
"damage_reduced": "Damage reduced by {{reduction}}%"
|
"damage_reduced": "Damage reduced by {{reduction}}%",
|
||||||
|
"skill_attack": "{{skill_icon}} {{skill_name}} hits for {{damage}} damage{{hits_text}}",
|
||||||
|
"skill_heal": "{{skill_icon}} {{skill_name}} heals for {{heal}} HP",
|
||||||
|
"skill_buff": "{{skill_icon}} {{skill_name}} activated",
|
||||||
|
"skill_effect": "{{message}}",
|
||||||
|
"skill_analyze": "{{skill_icon}} Target analyzed!",
|
||||||
|
"enemy_enraged": "{{npc_name}} is enraged!",
|
||||||
|
"enemy_defend": "Enemy recovers {{heal}} HP",
|
||||||
|
"enemy_special": "Enemy uses a special attack for {{damage}} damage!",
|
||||||
|
"effect_bleeding": "Bleeding for {{damage}} damage",
|
||||||
|
"effect_heal": "Recovered {{heal}} HP",
|
||||||
|
"effect_damage": "Took {{damage}} damage from status effects",
|
||||||
|
"effect_damage_npc": "The enemy took {{damage}} damage from status effects",
|
||||||
|
"level_up": "Level up! You are now level {{new_level}}!",
|
||||||
|
"item_heal": "Healed for {{heal}} HP",
|
||||||
|
"item_restore": "Restored {{amount}} {{stat}}",
|
||||||
|
"died": "You have been defeated!",
|
||||||
|
"turns_remaining": "{{turns}} turns remaining"
|
||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"supplies_title": "Combat Supplies",
|
"supplies_title": "Combat Supplies",
|
||||||
@@ -377,6 +414,30 @@
|
|||||||
"potentialBaseStats": "Potential base stats. Actual stats may vary.",
|
"potentialBaseStats": "Potential base stats. Actual stats may vary.",
|
||||||
"confirmSalvage": "Are you sure you want to salvage {{name}}? This cannot be undone."
|
"confirmSalvage": "Are you sure you want to salvage {{name}}? This cannot be undone."
|
||||||
},
|
},
|
||||||
|
"characterSheet": {
|
||||||
|
"title": "Character Sheet",
|
||||||
|
"statsTab": "Stats",
|
||||||
|
"skillsTab": "Skills",
|
||||||
|
"perksTab": "Perks",
|
||||||
|
"pointsAvailable": "points available",
|
||||||
|
"baseStats": "Base Stats",
|
||||||
|
"derivedStats": "Derived Stats",
|
||||||
|
"attackPower": "Attack Power",
|
||||||
|
"critChance": "Crit Chance",
|
||||||
|
"critDamage": "Crit Damage",
|
||||||
|
"dodgeChance": "Dodge Chance",
|
||||||
|
"fleeChance": "Flee Chance",
|
||||||
|
"maxHp": "Max HP",
|
||||||
|
"maxStamina": "Max Stamina",
|
||||||
|
"armor": "Armor",
|
||||||
|
"blockChance": "Block Chance",
|
||||||
|
"statusResist": "Status Resist",
|
||||||
|
"itemEffect": "Item Effectiveness",
|
||||||
|
"xpBonus": "XP Bonus",
|
||||||
|
"lootQuality": "Loot Quality",
|
||||||
|
"craftBonus": "Craft Bonus",
|
||||||
|
"carryWeight": "Base Weight Capacity"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"all": "All Items",
|
"all": "All Items",
|
||||||
"weapon": "Weapons",
|
"weapon": "Weapons",
|
||||||
|
|||||||
@@ -254,8 +254,12 @@
|
|||||||
"str": "FUE",
|
"str": "FUE",
|
||||||
"agi": "AGI",
|
"agi": "AGI",
|
||||||
"end": "RES",
|
"end": "RES",
|
||||||
"hpMax": "Vida máx",
|
"hpMax": "PS máx",
|
||||||
"stmMax": "Agua. máx"
|
"stmMax": "Ag máx",
|
||||||
|
"interval_turn": "turno",
|
||||||
|
"intervals_turn": "turnos",
|
||||||
|
"interval_minute": "minuto",
|
||||||
|
"intervals_minute": "minutos"
|
||||||
},
|
},
|
||||||
"combat": {
|
"combat": {
|
||||||
"title": "Combate",
|
"title": "Combate",
|
||||||
@@ -284,6 +288,14 @@
|
|||||||
"yourTurnTimer": "Tu Turno ({{time}})",
|
"yourTurnTimer": "Tu Turno ({{time}})",
|
||||||
"enemyTurnTimer": "Turno del Enemigo",
|
"enemyTurnTimer": "Turno del Enemigo",
|
||||||
"waiting": "Esperando al oponente...",
|
"waiting": "Esperando al oponente...",
|
||||||
|
"intents": {
|
||||||
|
"label": "Próximo movimiento:",
|
||||||
|
"defend": "Defendiendo",
|
||||||
|
"flee": "Huyendo",
|
||||||
|
"buff": "Potenciándose",
|
||||||
|
"attack": "Atacando",
|
||||||
|
"charging": "¡Ataque Cargado!"
|
||||||
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"combat_start": "¡Combate iniciado con {{enemy}}!",
|
"combat_start": "¡Combate iniciado con {{enemy}}!",
|
||||||
"player_attack": "¡Atacas por {{damage}} de daño!",
|
"player_attack": "¡Atacas por {{damage}} de daño!",
|
||||||
@@ -296,8 +308,11 @@
|
|||||||
"defend": "Defender",
|
"defend": "Defender",
|
||||||
"flee": "Huir",
|
"flee": "Huir",
|
||||||
"supplies": "Suministros",
|
"supplies": "Suministros",
|
||||||
"useItem": "Usar Objeto"
|
"useItem": "Usar Objeto",
|
||||||
|
"abilities": "Habilidades"
|
||||||
},
|
},
|
||||||
|
"stamina": "Aguante",
|
||||||
|
"cooldown_turns": "{{turns}} turnos de espera",
|
||||||
"status": {
|
"status": {
|
||||||
"attacking": "Atacando...",
|
"attacking": "Atacando...",
|
||||||
"defending": "Preparándose...",
|
"defending": "Preparándose...",
|
||||||
@@ -327,14 +342,36 @@
|
|||||||
"enemy_attack": "El enemigo golpea por {{damage}} de daño",
|
"enemy_attack": "El enemigo golpea por {{damage}} de daño",
|
||||||
"player_miss": "¡Fallaste!",
|
"player_miss": "¡Fallaste!",
|
||||||
"enemy_miss": "¡El enemigo falló!",
|
"enemy_miss": "¡El enemigo falló!",
|
||||||
"item_broken": "¡Tu {{item}} se rompió!",
|
"weapon_broke": "¡Tu {{item_name}} se ha roto!",
|
||||||
|
"item_broken": "¡Tu {{emoji}} {{item_name}} se rompió!",
|
||||||
|
"combat_crit": "¡GOLPE CRÍTICO!",
|
||||||
|
"combat_dodge": "¡Esquivaste el ataque!",
|
||||||
|
"combat_block": "¡Bloqueaste el ataque!",
|
||||||
|
"xp_gain": "Ganaste {{amount}} XP",
|
||||||
"flee_success": "¡Lograste escapar!",
|
"flee_success": "¡Lograste escapar!",
|
||||||
"flee_fail": "¡No pudiste escapar!",
|
"flee_fail": "¡No pudiste escapar!",
|
||||||
"defend": "¡Te preparas para el impacto!",
|
"defend": "¡Te preparas para el impacto!",
|
||||||
"item_used": "Usaste {{item}}",
|
"item_used": "Usaste {{item}}",
|
||||||
"effect_applied": "Aplicado {{effect}} a {{target}}",
|
"effect_applied": "Aplicado {{effect}} a {{target}}",
|
||||||
"item_damage": "{{item}} inflige {{damage}} de daño!",
|
"item_damage": "{{item}} inflige {{damage}} de daño!",
|
||||||
"damage_reduced": "Daño reducido en {{reduction}}%"
|
"damage_reduced": "Daño reducido en {{reduction}}%",
|
||||||
|
"skill_attack": "{{skill_icon}} {{skill_name}} golpea por {{damage}} de daño{{hits_text}}",
|
||||||
|
"skill_heal": "{{skill_icon}} {{skill_name}} cura {{heal}} PS",
|
||||||
|
"skill_buff": "{{skill_icon}} {{skill_name}} activado",
|
||||||
|
"skill_effect": "{{message}}",
|
||||||
|
"skill_analyze": "{{skill_icon}} ¡Objetivo analizado!",
|
||||||
|
"enemy_enraged": "¡{{npc_name}} está enfurecido!",
|
||||||
|
"enemy_defend": "El enemigo recupera {{heal}} PS",
|
||||||
|
"enemy_special": "¡El enemigo usa un ataque especial por {{damage}} de daño!",
|
||||||
|
"effect_bleeding": "Sangrado por {{damage}} de daño",
|
||||||
|
"effect_heal": "Recuperaste {{heal}} PS",
|
||||||
|
"effect_damage": "Recibiste {{damage}} de daño por efectos de estado",
|
||||||
|
"effect_damage_npc": "El enemigo recibió {{damage}} de daño por efectos de estado",
|
||||||
|
"level_up": "¡Subiste de nivel! ¡Ahora eres nivel {{new_level}}!",
|
||||||
|
"item_heal": "Curaste {{heal}} PS",
|
||||||
|
"item_restore": "Restauraste {{amount}} de {{stat}}",
|
||||||
|
"died": "¡Has sido derrotado!",
|
||||||
|
"turns_remaining": "{{turns}} turnos restantes"
|
||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"supplies_title": "Suministros de Combate",
|
"supplies_title": "Suministros de Combate",
|
||||||
@@ -375,6 +412,30 @@
|
|||||||
"potentialBaseStats": "Estadísticas base potenciales. Las estadísticas reales pueden variar.",
|
"potentialBaseStats": "Estadísticas base potenciales. Las estadísticas reales pueden variar.",
|
||||||
"confirmSalvage": "¿Estás seguro de que quieres desguazar {{name}}? Esto no se puede deshacer."
|
"confirmSalvage": "¿Estás seguro de que quieres desguazar {{name}}? Esto no se puede deshacer."
|
||||||
},
|
},
|
||||||
|
"characterSheet": {
|
||||||
|
"title": "Hoja de Personaje",
|
||||||
|
"statsTab": "Atributos",
|
||||||
|
"skillsTab": "Habilidades",
|
||||||
|
"perksTab": "Talentos",
|
||||||
|
"pointsAvailable": "puntos disponibles",
|
||||||
|
"baseStats": "Atributos Base",
|
||||||
|
"derivedStats": "Estadísticas Derivadas",
|
||||||
|
"attackPower": "Poder de Ataque",
|
||||||
|
"critChance": "Prob. Crítico",
|
||||||
|
"critDamage": "Daño Crítico",
|
||||||
|
"dodgeChance": "Prob. Esquivar",
|
||||||
|
"fleeChance": "Prob. Huir",
|
||||||
|
"maxHp": "Vida Máx.",
|
||||||
|
"maxStamina": "Aguante Máx.",
|
||||||
|
"armor": "Armadura",
|
||||||
|
"blockChance": "Prob. Bloqueo",
|
||||||
|
"statusResist": "Resistencia a Estados",
|
||||||
|
"itemEffect": "Eficacia de Objetos",
|
||||||
|
"xpBonus": "Bonus XP",
|
||||||
|
"lootQuality": "Calidad de Botín",
|
||||||
|
"craftBonus": "Bonus Fabricación",
|
||||||
|
"carryWeight": "Capacidad Base de Carga"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"all": "Todos los Objetos",
|
"all": "Todos los Objetos",
|
||||||
"weapon": "Armas",
|
"weapon": "Armas",
|
||||||
|
|||||||
@@ -21,24 +21,50 @@ if (!isElectron) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Twemoji after React renders
|
const twemojiOpts = {
|
||||||
const initTwemoji = () => {
|
|
||||||
twemoji.parse(document.body, {
|
|
||||||
folder: 'svg',
|
folder: 'svg',
|
||||||
ext: '.svg',
|
ext: '.svg',
|
||||||
base: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/'
|
base: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/'
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a wrapper component that initializes Twemoji
|
const initTwemoji = () => {
|
||||||
|
twemoji.parse(document.body, twemojiOpts);
|
||||||
|
};
|
||||||
|
|
||||||
const TwemojiWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
const TwemojiWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initial parse
|
// Initial parse of entire body
|
||||||
initTwemoji();
|
initTwemoji();
|
||||||
|
|
||||||
// Set up MutationObserver to re-parse when DOM changes
|
// Collect added nodes and parse them in batches AFTER React finishes
|
||||||
const observer = new MutationObserver(() => {
|
// its synchronous render cycle. Without deferral, Twemoji replaces
|
||||||
initTwemoji();
|
// emoji text nodes with <img> elements while React is still
|
||||||
|
// reconciling the DOM, causing NotFoundError on removeChild.
|
||||||
|
let pendingNodes = new Set<Node>();
|
||||||
|
let rafId: number | null = null;
|
||||||
|
|
||||||
|
const processPending = () => {
|
||||||
|
for (const node of pendingNodes) {
|
||||||
|
// Only parse nodes still in the document (React may have removed them)
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE && document.body.contains(node)) {
|
||||||
|
twemoji.parse(node as HTMLElement, twemojiOpts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingNodes.clear();
|
||||||
|
rafId = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
for (const node of mutation.addedNodes) {
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
pendingNodes.add(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pendingNodes.size > 0 && rafId === null) {
|
||||||
|
rafId = requestAnimationFrame(processPending);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(document.body, {
|
observer.observe(document.body, {
|
||||||
@@ -46,7 +72,10 @@ const TwemojiWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
|||||||
subtree: true,
|
subtree: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -7,25 +7,37 @@ export type I18nString = string | { [key: string]: string }
|
|||||||
* @param value The value to translate (string or object with language keys)
|
* @param value The value to translate (string or object with language keys)
|
||||||
* @returns The translated string for the current language, or fallback to English/first available
|
* @returns The translated string for the current language, or fallback to English/first available
|
||||||
*/
|
*/
|
||||||
export const getTranslatedText = (value: I18nString | undefined | null): string => {
|
export const getTranslatedText = (value: I18nString | undefined | null, vars?: Record<string, string | number>): string => {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
|
|
||||||
// If it's already a string, return it
|
let text = typeof value === 'string' ? value : '';
|
||||||
if (typeof value === 'string') return value
|
|
||||||
|
|
||||||
// If it's an object, try to get the current language
|
if (!text && typeof value === 'object') {
|
||||||
const currentLang = i18n.language || 'en'
|
const objValue = value as Record<string, string>;
|
||||||
|
const currentLang = i18n.language || 'en';
|
||||||
|
|
||||||
// 1. Try current language
|
// 1. Try current language
|
||||||
if (value[currentLang]) return value[currentLang]
|
if (objValue[currentLang]) {
|
||||||
|
text = objValue[currentLang];
|
||||||
// 2. Try English fallback
|
}
|
||||||
if (value['en']) return value['en']
|
// 2. Try English fallback
|
||||||
|
else if (objValue['en']) {
|
||||||
// 3. Return the first available key
|
text = objValue['en'];
|
||||||
const firstKey = Object.keys(value)[0]
|
}
|
||||||
if (firstKey) return value[firstKey]
|
// 3. Return the first available key
|
||||||
|
else {
|
||||||
// 4. Fallback empty
|
const firstKey = Object.keys(objValue)[0];
|
||||||
return ''
|
if (firstKey) text = objValue[firstKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
if (vars) {
|
||||||
|
Object.entries(vars).forEach(([k, v]) => {
|
||||||
|
text = text.replace(new RegExp(`{{${k}}}`, 'g'), String(v));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
|
|||||||
60
setup_boss.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
import time
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Connect to DB using sqlalchemy
|
||||||
|
url = "postgresql+asyncpg://admin:password@echoes_of_the_ashes_db:5432/echoesoftheashes"
|
||||||
|
engine = create_async_engine(url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
# Get Jocaru ID
|
||||||
|
res = await conn.execute(text("SELECT id, location_id FROM characters WHERE name ILIKE 'Jocaru'"))
|
||||||
|
row = res.first()
|
||||||
|
if not row:
|
||||||
|
print("Jocaru not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
pid, loc = row[0], row[1]
|
||||||
|
print(f"Player Jocaru found (ID {pid}) at {loc}")
|
||||||
|
|
||||||
|
# Buff to level 50
|
||||||
|
await conn.execute(text("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid"), {"pid": pid})
|
||||||
|
print("Set Jocaru to level 50 metrics.")
|
||||||
|
|
||||||
|
# Give items directly via SQL
|
||||||
|
items = [
|
||||||
|
('reinforced_pack', 1),
|
||||||
|
('reinforced_bat', 1),
|
||||||
|
('combat_knife', 1),
|
||||||
|
('first_aid_kit', 10),
|
||||||
|
('mystery_pills', 5),
|
||||||
|
('energy_bar', 10)
|
||||||
|
]
|
||||||
|
for iid, qty in items:
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO inventory (character_id, item_id, quantity) VALUES (:pid, :iid, :qty)"),
|
||||||
|
{"pid": pid, "iid": iid, "qty": qty}
|
||||||
|
)
|
||||||
|
print("Gave items to Jocaru.")
|
||||||
|
|
||||||
|
# Spawn enemies
|
||||||
|
now = time.time()
|
||||||
|
despawn = now + 86400
|
||||||
|
|
||||||
|
enemies = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2
|
||||||
|
for eid in enemies:
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)"),
|
||||||
|
{"nid": eid, "loc": loc, "start": now, "end": despawn}
|
||||||
|
)
|
||||||
|
print(f"Spawned {len(enemies)} enemies at {loc}.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error accessing DB natively: {e}")
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
45
setup_boss.sql
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
-- Buff player and get location into temporary variable
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
player_id INT;
|
||||||
|
loc_id VARCHAR;
|
||||||
|
start_ts FLOAT;
|
||||||
|
end_ts FLOAT;
|
||||||
|
BEGIN
|
||||||
|
SELECT id, location_id INTO player_id, loc_id FROM characters WHERE name ILIKE 'Jocaru';
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE NOTICE 'Player Jocaru not found';
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = player_id;
|
||||||
|
|
||||||
|
-- Give items
|
||||||
|
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'reinforced_pack', 1);
|
||||||
|
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'reinforced_bat', 1);
|
||||||
|
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'combat_knife', 1);
|
||||||
|
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'first_aid_kit', 10);
|
||||||
|
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'mystery_pills', 5);
|
||||||
|
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'energy_bar', 10);
|
||||||
|
|
||||||
|
-- Spawn enemies
|
||||||
|
start_ts := extract(epoch from now());
|
||||||
|
end_ts := start_ts + 86400;
|
||||||
|
|
||||||
|
-- 5 Raider Scouts
|
||||||
|
FOR i IN 1..5 LOOP
|
||||||
|
INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('raider_scout', loc_id, start_ts, end_ts);
|
||||||
|
END LOOP;
|
||||||
|
-- 5 Feral Dogs
|
||||||
|
FOR i IN 1..5 LOOP
|
||||||
|
INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('feral_dog', loc_id, start_ts, end_ts);
|
||||||
|
END LOOP;
|
||||||
|
-- 5 Mutant Rats
|
||||||
|
FOR i IN 1..5 LOOP
|
||||||
|
INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('mutant_rat', loc_id, start_ts, end_ts);
|
||||||
|
END LOOP;
|
||||||
|
-- 2 Test Bosses
|
||||||
|
FOR i IN 1..2 LOOP
|
||||||
|
INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('test_boss', loc_id, start_ts, end_ts);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
42
setup_boss_host.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
url = "postgresql+asyncpg://admin:password@localhost:5432/echoesoftheashes"
|
||||||
|
engine = create_async_engine(url)
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
res = await conn.execute(text("SELECT id, location_id FROM characters WHERE name ILIKE 'Jocaru'"))
|
||||||
|
row = res.first()
|
||||||
|
if not row:
|
||||||
|
print("Jocaru not found.")
|
||||||
|
return
|
||||||
|
pid, loc = row[0], row[1]
|
||||||
|
print(f"Player Jocaru found (ID {pid}) at {loc}")
|
||||||
|
|
||||||
|
await conn.execute(text("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid"), {"pid": pid})
|
||||||
|
|
||||||
|
items = [
|
||||||
|
('reinforced_pack', 1),
|
||||||
|
('reinforced_bat', 1),
|
||||||
|
('combat_knife', 1),
|
||||||
|
('first_aid_kit', 10),
|
||||||
|
('mystery_pills', 5),
|
||||||
|
('energy_bar', 10)
|
||||||
|
]
|
||||||
|
for iid, qty in items:
|
||||||
|
await conn.execute(text("INSERT INTO inventory (character_id, item_id, quantity) VALUES (:pid, :iid, :qty)"), {"pid": pid, "iid": iid, "qty": qty})
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
despawn = now + 86400
|
||||||
|
enemies = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2
|
||||||
|
for eid in enemies:
|
||||||
|
await conn.execute(text("INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)"),
|
||||||
|
{"nid": eid, "loc": loc, "start": now, "end": despawn})
|
||||||
|
print(f"Spawned {len(enemies)} enemies at {loc}.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"DB Error: {e}")
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
100
setup_test_env.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
from api.database import Database
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# 1. Update npcs.json to add a test boss
|
||||||
|
with open('gamedata/npcs.json', 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if 'test_boss' not in data['npcs']:
|
||||||
|
data['npcs']['test_boss'] = {
|
||||||
|
"name": {"en": "Level 50 Test Boss", "es": "Jefe de Prueba Nivel 50"},
|
||||||
|
"description": {"en": "A huge terrifying monster.", "es": "Un monstruo enorme y aterrador."},
|
||||||
|
"emoji": "👹",
|
||||||
|
"hp_min": 1000,
|
||||||
|
"hp_max": 1500,
|
||||||
|
"damage_min": 25,
|
||||||
|
"damage_max": 45,
|
||||||
|
"defense": 15,
|
||||||
|
"xp_reward": 500,
|
||||||
|
"loot_table": [],
|
||||||
|
"flee_chance": 0.0,
|
||||||
|
"status_inflict_chance": 0.5,
|
||||||
|
"death_message": {"en": "The boss is defeated.", "es": "El jefe ha sido derrotado."}
|
||||||
|
}
|
||||||
|
with open('gamedata/npcs.json', 'w') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
print("Added 'test_boss' to npcs.json")
|
||||||
|
|
||||||
|
db = Database()
|
||||||
|
await db.connect()
|
||||||
|
|
||||||
|
# 2. Get Jocaru
|
||||||
|
player = await db.fetch_one("SELECT * FROM characters WHERE name ILIKE 'Jocaru'")
|
||||||
|
if not player:
|
||||||
|
print("Player Jocaru not found!")
|
||||||
|
await db.disconnect()
|
||||||
|
return
|
||||||
|
|
||||||
|
pid = player['id']
|
||||||
|
ploc = player['location_id']
|
||||||
|
|
||||||
|
# 3. Give items
|
||||||
|
items_to_give = [
|
||||||
|
('reinforced_pack', 1),
|
||||||
|
('reinforced_bat', 1),
|
||||||
|
('knife', 1),
|
||||||
|
('first_aid_kit', 10),
|
||||||
|
('mystery_pills', 5),
|
||||||
|
('energy_bar', 10),
|
||||||
|
('molotov', 5)
|
||||||
|
]
|
||||||
|
for item_id, qty in items_to_give:
|
||||||
|
for _ in range(qty):
|
||||||
|
from utils.game_helpers import generate_unique_item_stats
|
||||||
|
from api.items import ITEMS_MANAGER
|
||||||
|
item_def = ITEMS_MANAGER.get_item(item_id)
|
||||||
|
if hasattr(item_def, 'durability') and item_def.durability:
|
||||||
|
tier = item_def.tier if hasattr(item_def, 'tier') else 1
|
||||||
|
stats = generate_unique_item_stats(item_id, item_def.durability, tier)
|
||||||
|
uid = await db.create_unique_item(
|
||||||
|
item_id=item_id,
|
||||||
|
tier=tier,
|
||||||
|
durability=stats['durability'],
|
||||||
|
max_durability=stats['durability'],
|
||||||
|
stats=json.dumps(stats.get('stats', {}))
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO inventory (character_id, item_id, quantity, unique_item_id) VALUES (:cid, :iid, 1, :uid)",
|
||||||
|
{"cid": pid, "iid": item_id, "uid": uid}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO inventory (character_id, item_id, quantity) VALUES (:cid, :iid, 1)",
|
||||||
|
{"cid": pid, "iid": item_id}
|
||||||
|
)
|
||||||
|
print("Granted test items and backpack.")
|
||||||
|
|
||||||
|
# 4. Give XP to reach lvl 50 if needed
|
||||||
|
await db.execute("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid", {"pid": pid})
|
||||||
|
print("Buffed Jocaru to lvl 50 manually.")
|
||||||
|
|
||||||
|
# 5. Spawn enemies at player's location
|
||||||
|
now = time.time()
|
||||||
|
despawn = now + 86400 # 1 day
|
||||||
|
|
||||||
|
enemies_to_spawn = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2
|
||||||
|
for eid in enemies_to_spawn:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)",
|
||||||
|
{"nid": eid, "loc": ploc, "start": now, "end": despawn}
|
||||||
|
)
|
||||||
|
print(f"Spawned {len(enemies_to_spawn)} enemies at {ploc}")
|
||||||
|
|
||||||
|
await db.disconnect()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
521
update_pvp.py
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
import sys
|
||||||
|
import re
|
||||||
|
|
||||||
|
with open('/opt/dockers/echoes_of_the_ashes/api/routers/combat.py', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# 1. Tick Player Effects for PvP
|
||||||
|
effect_tick_code = """
|
||||||
|
# Track the last action string for DB history
|
||||||
|
last_action_text = ""
|
||||||
|
|
||||||
|
# Process status effects (bleeding, poison, etc.) before action
|
||||||
|
active_effects = await db.tick_player_effects(current_player['id'])
|
||||||
|
|
||||||
|
if active_effects:
|
||||||
|
from ..game_logic import calculate_status_impact
|
||||||
|
total_impact = calculate_status_impact(active_effects)
|
||||||
|
|
||||||
|
if total_impact > 0:
|
||||||
|
damage = total_impact
|
||||||
|
new_hp = max(0, current_player['hp'] - damage)
|
||||||
|
await db.update_player(current_player['id'], hp=new_hp)
|
||||||
|
current_player['hp'] = new_hp
|
||||||
|
|
||||||
|
messages.append(create_combat_message(
|
||||||
|
"effect_damage",
|
||||||
|
origin="player",
|
||||||
|
damage=damage,
|
||||||
|
effect_name="status effects"
|
||||||
|
))
|
||||||
|
|
||||||
|
if new_hp <= 0:
|
||||||
|
messages.append(create_combat_message("died", origin="player", message="You died from status effects!"))
|
||||||
|
combat_over = True
|
||||||
|
winner_id = opponent['id']
|
||||||
|
|
||||||
|
# Update current player to dead state
|
||||||
|
await db.update_player(current_player['id'], hp=0, is_dead=True)
|
||||||
|
|
||||||
|
# Create corpse
|
||||||
|
import json
|
||||||
|
import time as time_module
|
||||||
|
inventory = await db.get_inventory(current_player['id'])
|
||||||
|
inventory_items = []
|
||||||
|
for inv_item in inventory:
|
||||||
|
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
||||||
|
inventory_items.append({
|
||||||
|
'item_id': inv_item['item_id'],
|
||||||
|
'name': item_def.name if item_def else inv_item['item_id'],
|
||||||
|
'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '📦',
|
||||||
|
'quantity': inv_item['quantity'],
|
||||||
|
'durability': inv_item.get('durability'),
|
||||||
|
'max_durability': inv_item.get('max_durability'),
|
||||||
|
'tier': inv_item.get('tier')
|
||||||
|
})
|
||||||
|
|
||||||
|
corpse_data = None
|
||||||
|
if inventory_items:
|
||||||
|
corpse_id = await db.create_player_corpse(
|
||||||
|
player_name=current_player['name'],
|
||||||
|
location_id=current_player['location_id'],
|
||||||
|
items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
|
||||||
|
)
|
||||||
|
await db.clear_inventory(current_player['id'])
|
||||||
|
corpse_data = {
|
||||||
|
"id": f"player_{corpse_id}",
|
||||||
|
"type": "player",
|
||||||
|
"name": f"{current_player['name']}'s Corpse",
|
||||||
|
"emoji": "⚰️",
|
||||||
|
"player_name": current_player['name'],
|
||||||
|
"loot_count": len(inventory_items),
|
||||||
|
"items": inventory_items,
|
||||||
|
"timestamp": time_module.time()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update PvP statistics for both players
|
||||||
|
await db.update_player_statistics(current_player['id'], pvp_deaths=1, pvp_combats_lost=1, increment=True)
|
||||||
|
await db.update_player_statistics(opponent['id'], players_killed=1, pvp_combats_won=1, increment=True)
|
||||||
|
|
||||||
|
# Broadcast corpse
|
||||||
|
broadcast_data = {
|
||||||
|
"message": get_game_message('pvp_defeat_broadcast', locale, opponent=current_player['name'], winner=opponent['name']),
|
||||||
|
"action": "player_died",
|
||||||
|
"player_id": current_player['id']
|
||||||
|
}
|
||||||
|
if corpse_data:
|
||||||
|
broadcast_data["corpse"] = corpse_data
|
||||||
|
|
||||||
|
await manager.send_to_location(
|
||||||
|
location_id=current_player['location_id'],
|
||||||
|
message={
|
||||||
|
"type": "location_update",
|
||||||
|
"data": broadcast_data,
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.end_pvp_combat(pvp_combat['id'])
|
||||||
|
|
||||||
|
elif total_impact < 0:
|
||||||
|
heal = abs(total_impact)
|
||||||
|
new_hp = min(current_player_stats.get('max_hp', current_player['max_hp']), current_player['hp'] + heal)
|
||||||
|
actual_heal = new_hp - current_player['hp']
|
||||||
|
|
||||||
|
if actual_heal > 0:
|
||||||
|
await db.update_player(current_player['id'], hp=new_hp)
|
||||||
|
current_player['hp'] = new_hp
|
||||||
|
messages.append(create_combat_message(
|
||||||
|
"effect_heal",
|
||||||
|
origin="player",
|
||||||
|
heal=actual_heal,
|
||||||
|
effect_name="status effects"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Stop processing action if player died from status effects
|
||||||
|
if not combat_over:
|
||||||
|
"""
|
||||||
|
|
||||||
|
content = content.replace(
|
||||||
|
' # Track the last action string for DB history\n last_action_text = ""',
|
||||||
|
effect_tick_code
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fix indentation of the actions block since it's now wrapped in `if not combat_over:`
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
# 2. Add Skill and Use Item logic for PvP
|
||||||
|
skill_and_item_code = """
|
||||||
|
elif req.action == 'skill':
|
||||||
|
if not req.item_id:
|
||||||
|
raise HTTPException(status_code=400, detail="skill_id (passed as item_id) required")
|
||||||
|
|
||||||
|
from ..services.skills import skills_manager
|
||||||
|
skill_id = req.item_id
|
||||||
|
skill = skills_manager.get_skill(skill_id)
|
||||||
|
if not skill:
|
||||||
|
raise HTTPException(status_code=404, detail="Skill not found")
|
||||||
|
|
||||||
|
# Check unlocked
|
||||||
|
stat_val = current_player.get(skill.stat_requirement, 0)
|
||||||
|
if stat_val < skill.stat_threshold or current_player['level'] < skill.level_requirement:
|
||||||
|
raise HTTPException(status_code=400, detail="Skill not unlocked")
|
||||||
|
|
||||||
|
# Check cooldown
|
||||||
|
active_effects = await db.get_player_effects(current_player['id'])
|
||||||
|
cd_source = f"cd:{skill.id}"
|
||||||
|
for eff in active_effects:
|
||||||
|
if eff.get('source') == cd_source and eff.get('ticks_remaining', 0) > 0:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Skill on cooldown ({eff['ticks_remaining']} turns)")
|
||||||
|
|
||||||
|
# Check stamina
|
||||||
|
if current_player['stamina'] < skill.stamina_cost:
|
||||||
|
raise HTTPException(status_code=400, detail="Not enough stamina")
|
||||||
|
|
||||||
|
# Deduct stamina
|
||||||
|
new_stamina = current_player['stamina'] - skill.stamina_cost
|
||||||
|
await db.update_player_stamina(current_player['id'], new_stamina)
|
||||||
|
current_player['stamina'] = new_stamina
|
||||||
|
|
||||||
|
# Add cooldown effect
|
||||||
|
if skill.cooldown > 0:
|
||||||
|
await db.add_effect(
|
||||||
|
player_id=current_player['id'],
|
||||||
|
effect_name=f"{skill.id}_cooldown",
|
||||||
|
effect_icon="⏳",
|
||||||
|
effect_type="cooldown",
|
||||||
|
value=0,
|
||||||
|
ticks_remaining=skill.cooldown,
|
||||||
|
persist_after_combat=False,
|
||||||
|
source=cd_source
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get weapon info
|
||||||
|
equipment = await db.get_all_equipment(current_player['id'])
|
||||||
|
weapon_damage = 0
|
||||||
|
weapon_inv_id = None
|
||||||
|
inv_item = None
|
||||||
|
weapon_def = None
|
||||||
|
if equipment.get('weapon') and equipment['weapon']:
|
||||||
|
weapon_slot = equipment['weapon']
|
||||||
|
inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id'])
|
||||||
|
if inv_item:
|
||||||
|
weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
||||||
|
if weapon_def and weapon_def.stats:
|
||||||
|
weapon_damage = random.randint(
|
||||||
|
weapon_def.stats.get('damage_min', 0),
|
||||||
|
weapon_def.stats.get('damage_max', 0)
|
||||||
|
)
|
||||||
|
weapon_inv_id = inv_item['id']
|
||||||
|
|
||||||
|
effects = skill.effects
|
||||||
|
new_opponent_hp = opponent['hp']
|
||||||
|
damage_done = 0
|
||||||
|
actual_damage = 0
|
||||||
|
armor_absorbed = 0
|
||||||
|
|
||||||
|
# Damage skills
|
||||||
|
if 'damage_multiplier' in effects:
|
||||||
|
base_damage = 5
|
||||||
|
strength_bonus = int(current_player['strength'] * 1.5)
|
||||||
|
level_bonus = current_player['level']
|
||||||
|
variance = random.randint(-2, 2)
|
||||||
|
raw_damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
|
||||||
|
|
||||||
|
multiplier = effects['damage_multiplier']
|
||||||
|
|
||||||
|
if 'execute_threshold' in effects:
|
||||||
|
opponent_hp_pct = opponent['hp'] / opponent['max_hp'] if opponent['max_hp'] > 0 else 1
|
||||||
|
if opponent_hp_pct <= effects['execute_threshold']:
|
||||||
|
multiplier = effects.get('execute_multiplier', multiplier)
|
||||||
|
|
||||||
|
damage = max(1, int(raw_damage * multiplier))
|
||||||
|
if effects.get('guaranteed_crit'):
|
||||||
|
damage = int(damage * 1.5)
|
||||||
|
|
||||||
|
num_hits = effects.get('hits', 1)
|
||||||
|
|
||||||
|
for hit in range(num_hits):
|
||||||
|
hit_dmg = damage if hit == 0 else max(1, int(raw_damage * multiplier))
|
||||||
|
absorbed, broken_armor = await reduce_armor_durability(opponent['id'], hit_dmg)
|
||||||
|
armor_absorbed += absorbed
|
||||||
|
|
||||||
|
for broken in broken_armor:
|
||||||
|
messages.append(create_combat_message(
|
||||||
|
"item_broken",
|
||||||
|
origin="enemy",
|
||||||
|
item_name=broken['name'],
|
||||||
|
emoji=broken['emoji']
|
||||||
|
))
|
||||||
|
last_action_text += f"\\n💔 {opponent['name']}'s {broken['emoji']} {broken['name']} broke!"
|
||||||
|
|
||||||
|
actual_hit = max(1, hit_dmg - absorbed)
|
||||||
|
damage_done += actual_hit
|
||||||
|
new_opponent_hp = max(0, new_opponent_hp - actual_hit)
|
||||||
|
|
||||||
|
actual_damage = damage_done
|
||||||
|
|
||||||
|
messages.append(create_combat_message(
|
||||||
|
"skill_attack",
|
||||||
|
origin="player",
|
||||||
|
damage=damage_done,
|
||||||
|
skill_name=skill.name,
|
||||||
|
skill_icon=skill.icon,
|
||||||
|
hits=num_hits
|
||||||
|
))
|
||||||
|
last_action_text = f"{current_player['name']} used {skill.name} on {opponent['name']} for {damage_done} damage! (Armor absorbed {armor_absorbed})"
|
||||||
|
|
||||||
|
# Lifesteal
|
||||||
|
if 'lifesteal' in effects:
|
||||||
|
heal_amount = int(damage_done * effects['lifesteal'])
|
||||||
|
new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount)
|
||||||
|
if new_hp > current_player['hp']:
|
||||||
|
await db.update_player(current_player['id'], hp=new_hp)
|
||||||
|
current_player['hp'] = new_hp
|
||||||
|
messages.append(create_combat_message(
|
||||||
|
"skill_heal", origin="player", heal=heal_amount, skill_icon="🩸"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Poison DoT
|
||||||
|
if 'poison_damage' in effects:
|
||||||
|
await db.add_effect(
|
||||||
|
player_id=opponent['id'],
|
||||||
|
effect_name="Poison",
|
||||||
|
effect_icon="🧪",
|
||||||
|
effect_type="damage",
|
||||||
|
damage_per_tick=effects['poison_damage'],
|
||||||
|
ticks_remaining=effects['poison_duration'],
|
||||||
|
persist_after_combat=True,
|
||||||
|
source=f"skill_poison:{skill.id}"
|
||||||
|
)
|
||||||
|
messages.append(create_combat_message(
|
||||||
|
"skill_effect", origin="player", message=f"🧪 Poisoned! ({effects['poison_damage']} dmg/turn)"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Stun chance
|
||||||
|
if 'stun_chance' in effects and random.random() < effects['stun_chance']:
|
||||||
|
# Stun in PvP can be modeled as taking away a turn
|
||||||
|
await db.add_effect(
|
||||||
|
player_id=opponent['id'],
|
||||||
|
effect_name="Stunned",
|
||||||
|
effect_icon="💫",
|
||||||
|
effect_type="debuff",
|
||||||
|
ticks_remaining=1,
|
||||||
|
persist_after_combat=False,
|
||||||
|
source="skill_stun"
|
||||||
|
)
|
||||||
|
messages.append(create_combat_message(
|
||||||
|
"skill_effect", origin="player", message="💫 Stunned! (Currently skip effect)"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Weapon durability
|
||||||
|
if weapon_inv_id and inv_item and inv_item.get('unique_item_id'):
|
||||||
|
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
|
||||||
|
if new_durability is None:
|
||||||
|
messages.append(create_combat_message("weapon_broke", origin="player", item_name=weapon_def.name if weapon_def else "weapon"))
|
||||||
|
await db.unequip_item(current_player['id'], 'weapon')
|
||||||
|
|
||||||
|
# Heal skills
|
||||||
|
if 'heal_percent' in effects:
|
||||||
|
heal_amount = int(current_player['max_hp'] * effects['heal_percent'])
|
||||||
|
new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount)
|
||||||
|
actual_heal = new_hp - current_player['hp']
|
||||||
|
if actual_heal > 0:
|
||||||
|
await db.update_player(current_player['id'], hp=new_hp)
|
||||||
|
current_player['hp'] = new_hp
|
||||||
|
messages.append(create_combat_message(
|
||||||
|
"skill_heal", origin="player", heal=actual_heal, skill_icon=skill.icon
|
||||||
|
))
|
||||||
|
last_action_text = f"{current_player['name']} used {skill.name} and healed for {actual_heal} HP!"
|
||||||
|
|
||||||
|
# Fortify
|
||||||
|
if 'armor_boost' in effects:
|
||||||
|
await db.add_effect(
|
||||||
|
player_id=current_player['id'],
|
||||||
|
effect_name="Fortify",
|
||||||
|
effect_icon="🛡️",
|
||||||
|
effect_type="buff",
|
||||||
|
value=effects['armor_boost'],
|
||||||
|
ticks_remaining=effects['duration'],
|
||||||
|
persist_after_combat=False,
|
||||||
|
source=f"skill_fortify:{skill.id}"
|
||||||
|
)
|
||||||
|
messages.append(create_combat_message(
|
||||||
|
"skill_effect", origin="player", message=f"🛡️ Fortified! (+{effects['armor_boost']} Armor)"
|
||||||
|
))
|
||||||
|
last_action_text = f"{current_player['name']} used {skill.name} and bolstered their defenses!"
|
||||||
|
|
||||||
|
# Process opponent HP if damage done
|
||||||
|
if damage_done > 0:
|
||||||
|
await db.update_player(opponent['id'], hp=new_opponent_hp)
|
||||||
|
if new_opponent_hp <= 0:
|
||||||
|
last_action_text += f"\\n🏆 {current_player['name']} has defeated {opponent['name']}!"
|
||||||
|
messages.append(create_combat_message("victory", origin="neutral", npc_name=opponent['name']))
|
||||||
|
combat_over = True
|
||||||
|
winner_id = current_player['id']
|
||||||
|
|
||||||
|
await db.update_player(opponent['id'], hp=0, is_dead=True)
|
||||||
|
|
||||||
|
# Create corpse
|
||||||
|
import json
|
||||||
|
import time as time_module
|
||||||
|
inventory = await db.get_inventory(opponent['id'])
|
||||||
|
inventory_items = []
|
||||||
|
for inv_item in inventory:
|
||||||
|
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
||||||
|
inventory_items.append({
|
||||||
|
'item_id': inv_item['item_id'],
|
||||||
|
'name': item_def.name if item_def else inv_item['item_id'],
|
||||||
|
'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '📦',
|
||||||
|
'quantity': inv_item['quantity'],
|
||||||
|
'durability': inv_item.get('durability'),
|
||||||
|
'max_durability': inv_item.get('max_durability'),
|
||||||
|
'tier': inv_item.get('tier')
|
||||||
|
})
|
||||||
|
|
||||||
|
corpse_data = None
|
||||||
|
if inventory_items:
|
||||||
|
corpse_id = await db.create_player_corpse(
|
||||||
|
player_name=opponent['name'],
|
||||||
|
location_id=opponent['location_id'],
|
||||||
|
items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
|
||||||
|
)
|
||||||
|
await db.clear_inventory(opponent['id'])
|
||||||
|
corpse_data = {
|
||||||
|
"id": f"player_{corpse_id}",
|
||||||
|
"type": "player",
|
||||||
|
"name": f"{opponent['name']}'s Corpse",
|
||||||
|
"emoji": "⚰️",
|
||||||
|
"player_name": opponent['name'],
|
||||||
|
"loot_count": len(inventory_items),
|
||||||
|
"items": inventory_items,
|
||||||
|
"timestamp": time_module.time()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update statistics
|
||||||
|
await db.update_player_statistics(opponent['id'], pvp_deaths=1, pvp_combats_lost=1, pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True)
|
||||||
|
await db.update_player_statistics(current_player['id'], players_killed=1, pvp_combats_won=1, pvp_damage_dealt=actual_damage, pvp_attacks_landed=1, increment=True)
|
||||||
|
|
||||||
|
# Broadcast corpse
|
||||||
|
broadcast_data = {
|
||||||
|
"message": get_game_message('pvp_defeat_broadcast', locale, opponent=opponent['name'], winner=current_player['name']),
|
||||||
|
"action": "player_died",
|
||||||
|
"player_id": opponent['id']
|
||||||
|
}
|
||||||
|
if corpse_data:
|
||||||
|
broadcast_data["corpse"] = corpse_data
|
||||||
|
|
||||||
|
await manager.send_to_location(
|
||||||
|
location_id=opponent['location_id'],
|
||||||
|
message={
|
||||||
|
"type": "location_update",
|
||||||
|
"data": broadcast_data,
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.end_pvp_combat(pvp_combat['id'])
|
||||||
|
else:
|
||||||
|
await db.update_player_statistics(current_player['id'], pvp_damage_dealt=actual_damage, pvp_attacks_landed=1, increment=True)
|
||||||
|
await db.update_player_statistics(opponent['id'], pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True)
|
||||||
|
|
||||||
|
# End of turn swap
|
||||||
|
updates = {
|
||||||
|
'turn': 'defender' if is_attacker else 'attacker',
|
||||||
|
'turn_started_at': time.time(),
|
||||||
|
'last_action': f"{last_action_text}|{time.time()}"
|
||||||
|
}
|
||||||
|
await db.update_pvp_combat(pvp_combat['id'], updates)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Skill didn't do damage, but turn still ends
|
||||||
|
updates = {
|
||||||
|
'turn': 'defender' if is_attacker else 'attacker',
|
||||||
|
'turn_started_at': time.time(),
|
||||||
|
'last_action': f"{last_action_text}|{time.time()}"
|
||||||
|
}
|
||||||
|
await db.update_pvp_combat(pvp_combat['id'], updates)
|
||||||
|
|
||||||
|
elif req.action == 'use_item':
|
||||||
|
if not req.item_id:
|
||||||
|
raise HTTPException(status_code=400, detail="item_id required for use_item action")
|
||||||
|
|
||||||
|
player_inventory = await db.get_inventory(current_player['id'])
|
||||||
|
inv_item = next((item for item in player_inventory if item['item_id'] == req.item_id), None)
|
||||||
|
|
||||||
|
if not inv_item:
|
||||||
|
raise HTTPException(status_code=400, detail="Item not found in inventory")
|
||||||
|
|
||||||
|
item_def = ITEMS_MANAGER.get_item(req.item_id)
|
||||||
|
if not item_def or not item_def.combat_usable:
|
||||||
|
raise HTTPException(status_code=400, detail="This item cannot be used in combat")
|
||||||
|
|
||||||
|
item_name = get_locale_string(item_def.name, locale)
|
||||||
|
effects_applied = []
|
||||||
|
|
||||||
|
if item_def.effects.get('status_effect'):
|
||||||
|
status_data = item_def.effects['status_effect']
|
||||||
|
await db.add_effect(
|
||||||
|
player_id=current_player['id'],
|
||||||
|
effect_name=status_data['name'],
|
||||||
|
effect_icon=status_data.get('icon', '✨'),
|
||||||
|
effect_type=status_data.get('type', 'buff'),
|
||||||
|
damage_per_tick=status_data.get('damage_per_tick', 0),
|
||||||
|
value=status_data.get('value', 0),
|
||||||
|
ticks_remaining=status_data.get('ticks', 3),
|
||||||
|
persist_after_combat=True,
|
||||||
|
source=f"item:{item_def.id}"
|
||||||
|
)
|
||||||
|
effects_applied.append(f"Applied {status_data['name']}")
|
||||||
|
|
||||||
|
if item_def.effects.get('cures'):
|
||||||
|
for cure_effect in item_def.effects['cures']:
|
||||||
|
if await db.remove_effect(current_player['id'], cure_effect):
|
||||||
|
effects_applied.append(f"Cured {cure_effect}")
|
||||||
|
|
||||||
|
if item_def.effects.get('hp_restore') and item_def.effects['hp_restore'] > 0:
|
||||||
|
item_effectiveness = current_player_stats.get('item_effectiveness', 1.0)
|
||||||
|
restore_amount = int(item_def.effects['hp_restore'] * item_effectiveness)
|
||||||
|
new_hp = min(current_player['max_hp'], current_player['hp'] + restore_amount)
|
||||||
|
actual_heal = new_hp - current_player['hp']
|
||||||
|
if actual_heal > 0:
|
||||||
|
await db.update_player(current_player['id'], hp=new_hp)
|
||||||
|
current_player['hp'] = new_hp
|
||||||
|
effects_applied.append(get_game_message('healed_amount_text', locale, amount=actual_heal))
|
||||||
|
messages.append(create_combat_message("item_heal", origin="player", heal=actual_heal))
|
||||||
|
|
||||||
|
if item_def.effects.get('stamina_restore') and item_def.effects['stamina_restore'] > 0:
|
||||||
|
item_effectiveness = current_player_stats.get('item_effectiveness', 1.0)
|
||||||
|
restore_amount = int(item_def.effects['stamina_restore'] * item_effectiveness)
|
||||||
|
new_stamina = min(current_player['max_stamina'], current_player['stamina'] + restore_amount)
|
||||||
|
actual_restore = new_stamina - current_player['stamina']
|
||||||
|
if actual_restore > 0:
|
||||||
|
await db.update_player_stamina(current_player['id'], new_stamina)
|
||||||
|
effects_applied.append(f"Restored {actual_restore} stamina")
|
||||||
|
messages.append(create_combat_message("item_restore", origin="player", stat="stamina", amount=actual_restore))
|
||||||
|
|
||||||
|
if inv_item['quantity'] > 1:
|
||||||
|
await db.update_inventory_quantity(inv_item['id'], inv_item['quantity'] - 1)
|
||||||
|
else:
|
||||||
|
await db.remove_from_inventory(inv_item['id'])
|
||||||
|
|
||||||
|
messages.append(create_combat_message(
|
||||||
|
"use_item", origin="player", item_name=item_name,
|
||||||
|
message=get_game_message('used_item_text', locale, name=item_name) + " (" + ", ".join(effects_applied) + ")"
|
||||||
|
))
|
||||||
|
last_action_text = f"{current_player['name']} used {item_name}!"
|
||||||
|
|
||||||
|
# End of turn swap
|
||||||
|
updates = {
|
||||||
|
'turn': 'defender' if is_attacker else 'attacker',
|
||||||
|
'turn_started_at': time.time(),
|
||||||
|
'last_action': f"{last_action_text}|{time.time()}"
|
||||||
|
}
|
||||||
|
await db.update_pvp_combat(pvp_combat['id'], updates)
|
||||||
|
"""
|
||||||
|
|
||||||
|
content = content.replace(
|
||||||
|
" elif req.action == 'flee':",
|
||||||
|
skill_and_item_code + "\n elif req.action == 'flee':"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indent the blocks bounded by the combat_over condition
|
||||||
|
lines = content.split('\\n')
|
||||||
|
inside_combat_over = False
|
||||||
|
new_lines = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
# Since doing line manipulation might be tricky via replace string,
|
||||||
|
# we need to be very precise. We will apply the wrapper around attack and flee logic inside the Python script by using Regex.
|
||||||
|
# Well, wait, I can just write out the fully rewritten function or use `re` substitution for indenting.
|
||||||
|
# It is simpler to just ensure all attack and flee actions are under `if not combat_over:`
|
||||||
|
# by indenting the whole block manually in the script.
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open('/opt/dockers/echoes_of_the_ashes/api/routers/combat.py', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
print("Script ran. Patched combat.py.")
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||