Compare commits
42 Commits
a0635499a7
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 70b2b0f124 | |||
| 805d04cf4e | |||
| f9e463bfc6 | |||
| 06ef8f6f0b | |||
| 72cf92021e | |||
| df26abd207 | |||
| e7e329e6ed | |||
| 8bd494a52f | |||
| 32af625d14 | |||
| 8784fbaa88 | |||
| a8767b34e2 | |||
| d9bc46adc1 | |||
| 45cfa25911 | |||
| 7c0e257540 | |||
| 6d3fb63355 | |||
| dd92cf5991 | |||
| 94c4ca9e95 | |||
| 19b537d8b0 | |||
| 58f0c1b8f6 | |||
| 29b4853c84 | |||
| fdd48034e4 | |||
| a38906b445 | |||
| 4ced1b04df | |||
| 76f67c4a22 | |||
| 185be7fee0 | |||
| f3ac0c8647 | |||
| 03ab783eeb | |||
| 30c3b800e6 | |||
| d789b5df65 | |||
| e6e7cdb7b7 | |||
| 98bb6ab589 | |||
| 1b21465dc4 | |||
| 77d913fe50 | |||
| 4d26c43d1d | |||
| 51f6041ee4 | |||
| 19808dd44c | |||
| 61a42d3a77 | |||
| 0a7156504f | |||
| 8312cfe13f | |||
| 16171dc34a | |||
| 52b199ff10 | |||
| 8675f9bf75 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,3 +35,4 @@ Thumbs.db
|
||||
logs/
|
||||
app/logs/
|
||||
*.log
|
||||
CLAUDE.md
|
||||
|
||||
@@ -169,8 +169,31 @@ def register_blueprints(app: Flask) -> None:
|
||||
app.register_blueprint(chat_bp)
|
||||
logger.info("Chat API blueprint registered")
|
||||
|
||||
# Import and register Combat API blueprint
|
||||
from app.api.combat import combat_bp
|
||||
app.register_blueprint(combat_bp)
|
||||
logger.info("Combat API blueprint registered")
|
||||
|
||||
# Import and register Inventory API blueprint
|
||||
from app.api.inventory import inventory_bp
|
||||
app.register_blueprint(inventory_bp)
|
||||
logger.info("Inventory API blueprint registered")
|
||||
|
||||
# Import and register Shop API blueprint
|
||||
from app.api.shop import shop_bp
|
||||
app.register_blueprint(shop_bp)
|
||||
logger.info("Shop API blueprint registered")
|
||||
|
||||
# Import and register Quests API blueprint
|
||||
from app.api.quests import quests_bp
|
||||
app.register_blueprint(quests_bp)
|
||||
logger.info("Quests API blueprint registered")
|
||||
|
||||
# Import and register Abilities API blueprint
|
||||
from app.api.abilities import abilities_bp
|
||||
app.register_blueprint(abilities_bp)
|
||||
logger.info("Abilities API blueprint registered")
|
||||
|
||||
# TODO: Register additional blueprints as they are created
|
||||
# from app.api import combat, marketplace, shop
|
||||
# app.register_blueprint(combat.bp, url_prefix='/api/v1/combat')
|
||||
# from app.api import marketplace
|
||||
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')
|
||||
# app.register_blueprint(shop.bp, url_prefix='/api/v1/shop')
|
||||
|
||||
@@ -447,7 +447,10 @@ class NarrativeGenerator:
|
||||
user_tier: UserTier,
|
||||
npc_relationship: str | None = None,
|
||||
previous_dialogue: list[dict[str, Any]] | None = None,
|
||||
npc_knowledge: list[str] | None = None
|
||||
npc_knowledge: list[str] | None = None,
|
||||
quest_offering_context: dict[str, Any] | None = None,
|
||||
quest_ineligibility_context: dict[str, Any] | None = None,
|
||||
player_asking_for_quests: bool = False
|
||||
) -> NarrativeResponse:
|
||||
"""
|
||||
Generate NPC dialogue in response to player conversation.
|
||||
@@ -461,6 +464,9 @@ class NarrativeGenerator:
|
||||
npc_relationship: Optional description of relationship with NPC.
|
||||
previous_dialogue: Optional list of previous exchanges.
|
||||
npc_knowledge: Optional list of things this NPC knows about.
|
||||
quest_offering_context: Optional quest offer context from QuestEligibilityService.
|
||||
quest_ineligibility_context: Optional context explaining why player can't take a quest.
|
||||
player_asking_for_quests: Whether the player is explicitly asking for quests/work.
|
||||
|
||||
Returns:
|
||||
NarrativeResponse with NPC dialogue.
|
||||
@@ -500,6 +506,9 @@ class NarrativeGenerator:
|
||||
npc_relationship=npc_relationship,
|
||||
previous_dialogue=previous_dialogue or [],
|
||||
npc_knowledge=npc_knowledge or [],
|
||||
quest_offering_context=quest_offering_context,
|
||||
quest_ineligibility_context=quest_ineligibility_context,
|
||||
player_asking_for_quests=player_asking_for_quests,
|
||||
max_tokens=model_config.max_tokens
|
||||
)
|
||||
except PromptTemplateError as e:
|
||||
|
||||
@@ -4,8 +4,11 @@ Response parser for AI narrative responses.
|
||||
This module handles AI response parsing. Game state changes (items, gold, XP)
|
||||
are now handled exclusively through predetermined dice check outcomes in
|
||||
action templates, not through AI-generated JSON.
|
||||
|
||||
Quest offers are extracted from NPC dialogue using [QUEST_OFFER:quest_id] markers.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -158,3 +161,83 @@ def _parse_game_actions(data: dict[str, Any]) -> GameStateChanges:
|
||||
changes.location_change = data.get("location_change")
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NPC Dialogue Parsing
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class ParsedNPCDialogue:
|
||||
"""
|
||||
Parsed NPC dialogue response with quest offer extraction.
|
||||
|
||||
When NPCs offer quests during conversation, they include a
|
||||
[QUEST_OFFER:quest_id] marker that signals the UI to show
|
||||
a quest accept/decline modal.
|
||||
|
||||
Attributes:
|
||||
dialogue: The cleaned dialogue text (marker removed)
|
||||
quest_offered: Quest ID if a quest was offered, None otherwise
|
||||
raw_response: The original response text
|
||||
"""
|
||||
|
||||
dialogue: str
|
||||
quest_offered: Optional[str] = None
|
||||
raw_response: str = ""
|
||||
|
||||
|
||||
# Regex pattern for quest offer markers
|
||||
# Matches: [QUEST_OFFER:quest_id] or [QUEST_OFFER: quest_id]
|
||||
QUEST_OFFER_PATTERN = re.compile(r'\[QUEST_OFFER:\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\]')
|
||||
|
||||
|
||||
def parse_npc_dialogue(response_text: str) -> ParsedNPCDialogue:
|
||||
"""
|
||||
Parse an NPC dialogue response, extracting quest offer markers.
|
||||
|
||||
The AI is instructed to include [QUEST_OFFER:quest_id] on its own line
|
||||
when offering a quest. This function extracts the marker and returns
|
||||
the cleaned dialogue.
|
||||
|
||||
Args:
|
||||
response_text: The raw AI dialogue response
|
||||
|
||||
Returns:
|
||||
ParsedNPCDialogue with cleaned dialogue and optional quest_offered
|
||||
|
||||
Example:
|
||||
>>> response = '''*leans in* Got a problem, friend.
|
||||
... [QUEST_OFFER:quest_cellar_rats]
|
||||
... Giant rats in me cellar.'''
|
||||
>>> result = parse_npc_dialogue(response)
|
||||
>>> result.quest_offered
|
||||
'quest_cellar_rats'
|
||||
>>> '[QUEST_OFFER' in result.dialogue
|
||||
False
|
||||
"""
|
||||
logger.debug("Parsing NPC dialogue", response_length=len(response_text))
|
||||
|
||||
quest_offered = None
|
||||
dialogue = response_text.strip()
|
||||
|
||||
# Search for quest offer marker
|
||||
match = QUEST_OFFER_PATTERN.search(dialogue)
|
||||
if match:
|
||||
quest_offered = match.group(1)
|
||||
# Remove the marker from the dialogue
|
||||
dialogue = QUEST_OFFER_PATTERN.sub('', dialogue)
|
||||
# Clean up any extra whitespace/newlines left behind
|
||||
dialogue = re.sub(r'\n\s*\n', '\n\n', dialogue)
|
||||
dialogue = dialogue.strip()
|
||||
|
||||
logger.info(
|
||||
"Quest offer extracted from dialogue",
|
||||
quest_id=quest_offered,
|
||||
)
|
||||
|
||||
return ParsedNPCDialogue(
|
||||
dialogue=dialogue,
|
||||
quest_offered=quest_offered,
|
||||
raw_response=response_text,
|
||||
)
|
||||
|
||||
@@ -92,6 +92,107 @@ Work these into the dialogue naturally - don't dump all information at once.
|
||||
Make it feel earned, like the NPC is opening up to someone they trust.
|
||||
{% endif %}
|
||||
|
||||
{% if quest_offering_context and quest_offering_context.should_offer %}
|
||||
## QUEST TO OFFER
|
||||
The NPC has a quest to offer to the player.
|
||||
|
||||
**Quest:** {{ quest_offering_context.quest_name }}
|
||||
**Quest ID:** {{ quest_offering_context.quest_id }}
|
||||
|
||||
{% if quest_offering_context.offer_dialogue %}
|
||||
**How the NPC Would Present It:**
|
||||
{{ quest_offering_context.offer_dialogue }}
|
||||
{% endif %}
|
||||
|
||||
{% if quest_offering_context.npc_quest_knowledge %}
|
||||
**What the NPC Knows About This Quest:**
|
||||
{% for fact in quest_offering_context.npc_quest_knowledge %}
|
||||
- {{ fact }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if quest_offering_context.narrative_hooks %}
|
||||
**Narrative Hooks (use 1-2 naturally):**
|
||||
{% for hook in quest_offering_context.narrative_hooks %}
|
||||
- The NPC {{ hook }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if player_asking_for_quests %}
|
||||
**CRITICAL: The player is explicitly asking for quests/work. You MUST offer this quest NOW.**
|
||||
In your response:
|
||||
1. Describe the quest situation naturally in your dialogue
|
||||
2. End your response with the quest offer marker on its own line: [QUEST_OFFER:{{ quest_offering_context.quest_id }}]
|
||||
|
||||
The marker MUST appear - it triggers the UI to show accept/decline buttons.
|
||||
{% else %}
|
||||
**Quest Offering Guidelines:**
|
||||
- Weave the quest naturally into conversation
|
||||
- If the player shows interest, include the marker: [QUEST_OFFER:{{ quest_offering_context.quest_id }}]
|
||||
- The marker signals the UI to show quest accept/decline options
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if quest_ineligibility_context and player_asking_for_quests %}
|
||||
## QUEST UNAVAILABLE - EXPLAIN WHY
|
||||
The player is asking about quests, but they don't meet the requirements. Explain this in character.
|
||||
|
||||
{% if quest_ineligibility_context.reason_type == "level_too_low" %}
|
||||
**Reason:** The player (level {{ quest_ineligibility_context.current_level }}) isn't experienced enough. They need to be level {{ quest_ineligibility_context.required_level }}.
|
||||
**How to convey this:** The NPC should politely but firmly indicate the task is too dangerous for someone of their current skill level. Suggest they gain more experience first. Be encouraging but realistic - don't offer false hope.
|
||||
**Example tone:** "I appreciate your enthusiasm, but this task requires someone with more experience. The bandits we're dealing with are seasoned fighters. Come back when you've proven yourself in a few more battles."
|
||||
{% elif quest_ineligibility_context.reason_type == "level_too_high" %}
|
||||
**Reason:** The player is too experienced for available tasks.
|
||||
**How to convey this:** The NPC should indicate they have nothing worthy of such an accomplished adventurer right now.
|
||||
{% elif quest_ineligibility_context.reason_type == "prerequisite_missing" %}
|
||||
**Reason:** The player needs to complete other tasks first.
|
||||
**How to convey this:** Hint that there's something else they should do first, or that circumstances aren't right yet.
|
||||
{% elif quest_ineligibility_context.reason_type == "relationship_too_low" %}
|
||||
**Reason:** The NPC doesn't trust the player enough yet.
|
||||
**How to convey this:** Be guarded. Hint that you might have work, but you need to know you can trust them first.
|
||||
{% elif quest_ineligibility_context.reason_type == "quest_already_active" %}
|
||||
**Reason:** The player is already working on this quest.
|
||||
**How to convey this:** Remind them they already accepted this task and should focus on completing it.
|
||||
{% elif quest_ineligibility_context.reason_type == "quest_already_completed" %}
|
||||
**Reason:** The player already completed this quest.
|
||||
**How to convey this:** Thank them again for their previous help, mention you have nothing else right now.
|
||||
{% elif quest_ineligibility_context.reason_type == "too_many_quests" %}
|
||||
**Reason:** The player has too many active quests.
|
||||
**How to convey this:** Suggest they finish some of their current commitments before taking on more.
|
||||
{% else %}
|
||||
**Reason:** {{ quest_ineligibility_context.message }}
|
||||
**How to convey this:** Politely decline, staying in character.
|
||||
{% endif %}
|
||||
|
||||
**IMPORTANT:** Do NOT offer the quest. Explain the situation naturally in dialogue.
|
||||
{% endif %}
|
||||
|
||||
{% if lore_context and lore_context.has_content %}
|
||||
## Relevant World Knowledge
|
||||
The NPC may reference this lore if contextually appropriate:
|
||||
|
||||
{% if lore_context.quest %}
|
||||
**Quest Background:**
|
||||
{% for entry in lore_context.quest[:2] %}
|
||||
- {{ entry.content | truncate_text(150) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if lore_context.regional %}
|
||||
**Local Knowledge:**
|
||||
{% for entry in lore_context.regional[:3] %}
|
||||
- {{ entry.content | truncate_text(100) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if lore_context.world %}
|
||||
**Historical Knowledge (if NPC would know):**
|
||||
{% for entry in lore_context.world[:2] %}
|
||||
- {{ entry.content | truncate_text(100) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if npc.relationships %}
|
||||
## NPC Relationships (for context)
|
||||
{% for rel in npc.relationships %}
|
||||
|
||||
129
api/app/api/abilities.py
Normal file
129
api/app/api/abilities.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Abilities API Blueprint
|
||||
|
||||
This module provides API endpoints for fetching ability information:
|
||||
- List all available abilities
|
||||
- Get details for a specific ability
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from app.models.abilities import AbilityLoader
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
not_found_response,
|
||||
)
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
abilities_bp = Blueprint('abilities', __name__, url_prefix='/api/v1/abilities')
|
||||
|
||||
# Initialize ability loader (singleton pattern)
|
||||
_ability_loader = None
|
||||
|
||||
|
||||
def get_ability_loader() -> AbilityLoader:
|
||||
"""
|
||||
Get the singleton AbilityLoader instance.
|
||||
|
||||
Returns:
|
||||
AbilityLoader: The ability loader instance
|
||||
"""
|
||||
global _ability_loader
|
||||
if _ability_loader is None:
|
||||
_ability_loader = AbilityLoader()
|
||||
return _ability_loader
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ability Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@abilities_bp.route('', methods=['GET'])
|
||||
def list_abilities():
|
||||
"""
|
||||
List all available abilities.
|
||||
|
||||
Returns all abilities defined in the system with their full details.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"abilities": [
|
||||
{
|
||||
"ability_id": "smite",
|
||||
"name": "Smite",
|
||||
"description": "Call down holy light...",
|
||||
"ability_type": "spell",
|
||||
"base_power": 20,
|
||||
"damage_type": "holy",
|
||||
"mana_cost": 10,
|
||||
"cooldown": 0,
|
||||
...
|
||||
},
|
||||
...
|
||||
],
|
||||
"count": 5
|
||||
}
|
||||
"""
|
||||
logger.info("Listing all abilities")
|
||||
|
||||
loader = get_ability_loader()
|
||||
abilities = loader.load_all_abilities()
|
||||
|
||||
# Convert to list of dicts for JSON serialization
|
||||
abilities_list = [ability.to_dict() for ability in abilities.values()]
|
||||
|
||||
logger.info("Abilities listed", count=len(abilities_list))
|
||||
|
||||
return success_response({
|
||||
"abilities": abilities_list,
|
||||
"count": len(abilities_list)
|
||||
})
|
||||
|
||||
|
||||
@abilities_bp.route('/<ability_id>', methods=['GET'])
|
||||
def get_ability(ability_id: str):
|
||||
"""
|
||||
Get details for a specific ability.
|
||||
|
||||
Args:
|
||||
ability_id: The unique identifier for the ability (e.g., "smite")
|
||||
|
||||
Returns:
|
||||
{
|
||||
"ability_id": "smite",
|
||||
"name": "Smite",
|
||||
"description": "Call down holy light to smite your enemies",
|
||||
"ability_type": "spell",
|
||||
"base_power": 20,
|
||||
"damage_type": "holy",
|
||||
"scaling_stat": "wisdom",
|
||||
"scaling_factor": 0.5,
|
||||
"mana_cost": 10,
|
||||
"cooldown": 0,
|
||||
"effects_applied": [],
|
||||
"is_aoe": false,
|
||||
"target_count": 1
|
||||
}
|
||||
|
||||
Errors:
|
||||
404: Ability not found
|
||||
"""
|
||||
logger.info("Getting ability", ability_id=ability_id)
|
||||
|
||||
loader = get_ability_loader()
|
||||
ability = loader.load_ability(ability_id)
|
||||
|
||||
if ability is None:
|
||||
logger.warning("Ability not found", ability_id=ability_id)
|
||||
return not_found_response(
|
||||
message=f"Ability '{ability_id}' not found"
|
||||
)
|
||||
|
||||
logger.info("Ability retrieved", ability_id=ability_id, name=ability.name)
|
||||
|
||||
return success_response(ability.to_dict())
|
||||
@@ -15,6 +15,7 @@ from flask import Blueprint, request, make_response, render_template, redirect,
|
||||
from appwrite.exception import AppwriteException
|
||||
|
||||
from app.services.appwrite_service import AppwriteService
|
||||
from app.services.session_cache_service import SessionCacheService
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
created_response,
|
||||
@@ -305,7 +306,11 @@ def api_logout():
|
||||
if not token:
|
||||
return unauthorized_response(message="No active session")
|
||||
|
||||
# Logout user
|
||||
# Invalidate session cache before Appwrite logout
|
||||
cache = SessionCacheService()
|
||||
cache.invalidate_token(token)
|
||||
|
||||
# Logout user from Appwrite
|
||||
appwrite = AppwriteService()
|
||||
appwrite.logout_user(session_id=token)
|
||||
|
||||
@@ -340,6 +345,36 @@ def api_logout():
|
||||
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/me', methods=['GET'])
|
||||
@require_auth
|
||||
def api_get_current_user():
|
||||
"""
|
||||
Get the currently authenticated user's data.
|
||||
|
||||
This endpoint is lightweight and uses cached session data when available,
|
||||
making it suitable for frequent use (e.g., checking user tier, verifying
|
||||
session is still valid).
|
||||
|
||||
Returns:
|
||||
200: User data
|
||||
401: Not authenticated
|
||||
"""
|
||||
user = get_current_user()
|
||||
|
||||
if not user:
|
||||
return unauthorized_response(message="Not authenticated")
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"email_verified": user.email_verified,
|
||||
"tier": user.tier
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/verify-email', methods=['GET'])
|
||||
def api_verify_email():
|
||||
"""
|
||||
@@ -480,6 +515,10 @@ def api_reset_password():
|
||||
appwrite = AppwriteService()
|
||||
appwrite.confirm_password_reset(user_id=user_id, secret=secret, password=password)
|
||||
|
||||
# Invalidate all cached sessions for this user (security: password changed)
|
||||
cache = SessionCacheService()
|
||||
cache.invalidate_user(user_id)
|
||||
|
||||
logger.info("Password reset successfully", user_id=user_id)
|
||||
|
||||
return success_response(
|
||||
|
||||
1093
api/app/api/combat.py
Normal file
1093
api/app/api/combat.py
Normal file
File diff suppressed because it is too large
Load Diff
639
api/app/api/inventory.py
Normal file
639
api/app/api/inventory.py
Normal file
@@ -0,0 +1,639 @@
|
||||
"""
|
||||
Inventory API Blueprint
|
||||
|
||||
Endpoints for managing character inventory and equipment.
|
||||
All endpoints require authentication and enforce ownership validation.
|
||||
|
||||
Endpoints:
|
||||
- GET /api/v1/characters/<id>/inventory - Get character inventory and equipped items
|
||||
- POST /api/v1/characters/<id>/inventory/equip - Equip an item
|
||||
- POST /api/v1/characters/<id>/inventory/unequip - Unequip an item
|
||||
- POST /api/v1/characters/<id>/inventory/use - Use a consumable item
|
||||
- DELETE /api/v1/characters/<id>/inventory/<item_id> - Drop an item
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.inventory_service import (
|
||||
get_inventory_service,
|
||||
ItemNotFoundError,
|
||||
CannotEquipError,
|
||||
InvalidSlotError,
|
||||
CannotUseItemError,
|
||||
InventoryFullError,
|
||||
VALID_SLOTS,
|
||||
MAX_INVENTORY_SIZE,
|
||||
)
|
||||
from app.services.character_service import (
|
||||
get_character_service,
|
||||
CharacterNotFound,
|
||||
)
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
error_response,
|
||||
not_found_response,
|
||||
validation_error_response,
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
inventory_bp = Blueprint('inventory', __name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@inventory_bp.route('/api/v1/characters/<character_id>/inventory', methods=['GET'])
|
||||
@require_auth
|
||||
def get_inventory(character_id: str):
|
||||
"""
|
||||
Get character inventory and equipped items.
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Returns:
|
||||
200: Inventory and equipment data
|
||||
401: Not authenticated
|
||||
404: Character not found or not owned by user
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"inventory": [
|
||||
{
|
||||
"item_id": "gen_abc123",
|
||||
"name": "Flaming Dagger",
|
||||
"item_type": "weapon",
|
||||
"rarity": "rare",
|
||||
...
|
||||
}
|
||||
],
|
||||
"equipped": {
|
||||
"weapon": {...},
|
||||
"helmet": null,
|
||||
...
|
||||
},
|
||||
"inventory_count": 5,
|
||||
"max_inventory": 100
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
logger.info("Getting inventory",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
# Get character (validates ownership)
|
||||
char_service = get_character_service()
|
||||
character = char_service.get_character(character_id, user.id)
|
||||
|
||||
# Get inventory service
|
||||
inventory_service = get_inventory_service()
|
||||
|
||||
# Get inventory items
|
||||
inventory_items = inventory_service.get_inventory(character)
|
||||
|
||||
# Get equipped items
|
||||
equipped_items = inventory_service.get_equipped_items(character)
|
||||
|
||||
# Build equipped dict with all slots (None for empty slots)
|
||||
equipped_response = {}
|
||||
for slot in VALID_SLOTS:
|
||||
item = equipped_items.get(slot)
|
||||
equipped_response[slot] = item.to_dict() if item else None
|
||||
|
||||
logger.info("Inventory retrieved successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
item_count=len(inventory_items))
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"inventory": [item.to_dict() for item in inventory_items],
|
||||
"equipped": equipped_response,
|
||||
"inventory_count": len(inventory_items),
|
||||
"max_inventory": MAX_INVENTORY_SIZE,
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get inventory",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="INVENTORY_GET_ERROR",
|
||||
message="Failed to retrieve inventory",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/equip', methods=['POST'])
|
||||
@require_auth
|
||||
def equip_item(character_id: str):
|
||||
"""
|
||||
Equip an item from inventory to a specified slot.
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"item_id": "gen_abc123",
|
||||
"slot": "weapon"
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Item equipped successfully
|
||||
400: Cannot equip item (wrong type, level requirement, etc.)
|
||||
401: Not authenticated
|
||||
404: Character or item not found
|
||||
422: Validation error (invalid slot)
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"message": "Equipped Flaming Dagger to weapon slot",
|
||||
"equipped": {...},
|
||||
"unequipped_item": null
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response(
|
||||
message="Request body is required",
|
||||
details={"error": "Request body is required"}
|
||||
)
|
||||
|
||||
item_id = data.get('item_id', '').strip()
|
||||
slot = data.get('slot', '').strip().lower()
|
||||
|
||||
# Validate required fields
|
||||
validation_errors = {}
|
||||
|
||||
if not item_id:
|
||||
validation_errors['item_id'] = "item_id is required"
|
||||
|
||||
if not slot:
|
||||
validation_errors['slot'] = "slot is required"
|
||||
|
||||
if validation_errors:
|
||||
return validation_error_response(
|
||||
message="Validation failed",
|
||||
details=validation_errors
|
||||
)
|
||||
|
||||
logger.info("Equipping item",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
item_id=item_id,
|
||||
slot=slot)
|
||||
|
||||
# Get character (validates ownership)
|
||||
char_service = get_character_service()
|
||||
character = char_service.get_character(character_id, user.id)
|
||||
|
||||
# Get inventory service
|
||||
inventory_service = get_inventory_service()
|
||||
|
||||
# Equip item
|
||||
previous_item = inventory_service.equip_item(
|
||||
character, item_id, slot, user.id
|
||||
)
|
||||
|
||||
# Get item name for message
|
||||
equipped_item = character.equipped.get(slot)
|
||||
item_name = equipped_item.get_display_name() if equipped_item else item_id
|
||||
|
||||
# Build equipped response
|
||||
equipped_response = {}
|
||||
for s in VALID_SLOTS:
|
||||
item = character.equipped.get(s)
|
||||
equipped_response[s] = item.to_dict() if item else None
|
||||
|
||||
logger.info("Item equipped successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
item_id=item_id,
|
||||
slot=slot,
|
||||
previous_item=previous_item.item_id if previous_item else None)
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"message": f"Equipped {item_name} to {slot} slot",
|
||||
"equipped": equipped_response,
|
||||
"unequipped_item": previous_item.to_dict() if previous_item else None,
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for equip",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except ItemNotFoundError as e:
|
||||
logger.warning("Item not found for equip",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
item_id=item_id if 'item_id' in locals() else 'unknown',
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except InvalidSlotError as e:
|
||||
logger.warning("Invalid slot for equip",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
slot=slot if 'slot' in locals() else 'unknown',
|
||||
error=str(e))
|
||||
return validation_error_response(
|
||||
message=str(e),
|
||||
details={"slot": str(e)}
|
||||
)
|
||||
|
||||
except CannotEquipError as e:
|
||||
logger.warning("Cannot equip item",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
item_id=item_id if 'item_id' in locals() else 'unknown',
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CANNOT_EQUIP",
|
||||
message=str(e),
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to equip item",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="EQUIP_ERROR",
|
||||
message="Failed to equip item",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/unequip', methods=['POST'])
|
||||
@require_auth
|
||||
def unequip_item(character_id: str):
|
||||
"""
|
||||
Unequip an item from a specified slot (returns to inventory).
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"slot": "weapon"
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Item unequipped successfully (or slot was empty)
|
||||
400: Inventory full, cannot unequip
|
||||
401: Not authenticated
|
||||
404: Character not found
|
||||
422: Validation error (invalid slot)
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"message": "Unequipped Flaming Dagger from weapon slot",
|
||||
"unequipped_item": {...},
|
||||
"equipped": {...}
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response(
|
||||
message="Request body is required",
|
||||
details={"error": "Request body is required"}
|
||||
)
|
||||
|
||||
slot = data.get('slot', '').strip().lower()
|
||||
|
||||
if not slot:
|
||||
return validation_error_response(
|
||||
message="Validation failed",
|
||||
details={"slot": "slot is required"}
|
||||
)
|
||||
|
||||
logger.info("Unequipping item",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
slot=slot)
|
||||
|
||||
# Get character (validates ownership)
|
||||
char_service = get_character_service()
|
||||
character = char_service.get_character(character_id, user.id)
|
||||
|
||||
# Get inventory service
|
||||
inventory_service = get_inventory_service()
|
||||
|
||||
# Unequip item
|
||||
unequipped_item = inventory_service.unequip_item(character, slot, user.id)
|
||||
|
||||
# Build equipped response
|
||||
equipped_response = {}
|
||||
for s in VALID_SLOTS:
|
||||
item = character.equipped.get(s)
|
||||
equipped_response[s] = item.to_dict() if item else None
|
||||
|
||||
# Build message
|
||||
if unequipped_item:
|
||||
message = f"Unequipped {unequipped_item.get_display_name()} from {slot} slot"
|
||||
else:
|
||||
message = f"Slot '{slot}' was already empty"
|
||||
|
||||
logger.info("Item unequipped",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
slot=slot,
|
||||
unequipped_item=unequipped_item.item_id if unequipped_item else None)
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"message": message,
|
||||
"unequipped_item": unequipped_item.to_dict() if unequipped_item else None,
|
||||
"equipped": equipped_response,
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for unequip",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except InvalidSlotError as e:
|
||||
logger.warning("Invalid slot for unequip",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
slot=slot if 'slot' in locals() else 'unknown',
|
||||
error=str(e))
|
||||
return validation_error_response(
|
||||
message=str(e),
|
||||
details={"slot": str(e)}
|
||||
)
|
||||
|
||||
except InventoryFullError as e:
|
||||
logger.warning("Inventory full, cannot unequip",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="INVENTORY_FULL",
|
||||
message=str(e),
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to unequip item",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="UNEQUIP_ERROR",
|
||||
message="Failed to unequip item",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/use', methods=['POST'])
|
||||
@require_auth
|
||||
def use_item(character_id: str):
|
||||
"""
|
||||
Use a consumable item from inventory.
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"item_id": "health_potion_small"
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Item used successfully
|
||||
400: Cannot use item (not consumable)
|
||||
401: Not authenticated
|
||||
404: Character or item not found
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"item_used": "Small Health Potion",
|
||||
"effects_applied": [
|
||||
{
|
||||
"effect_name": "Healing",
|
||||
"effect_type": "hot",
|
||||
"value": 25,
|
||||
"message": "Restored 25 HP"
|
||||
}
|
||||
],
|
||||
"hp_restored": 25,
|
||||
"mp_restored": 0,
|
||||
"message": "Used Small Health Potion: Restored 25 HP"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response(
|
||||
message="Request body is required",
|
||||
details={"error": "Request body is required"}
|
||||
)
|
||||
|
||||
item_id = data.get('item_id', '').strip()
|
||||
|
||||
if not item_id:
|
||||
return validation_error_response(
|
||||
message="Validation failed",
|
||||
details={"item_id": "item_id is required"}
|
||||
)
|
||||
|
||||
logger.info("Using item",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
item_id=item_id)
|
||||
|
||||
# Get character (validates ownership)
|
||||
char_service = get_character_service()
|
||||
character = char_service.get_character(character_id, user.id)
|
||||
|
||||
# Get inventory service
|
||||
inventory_service = get_inventory_service()
|
||||
|
||||
# Use consumable
|
||||
result = inventory_service.use_consumable(character, item_id, user.id)
|
||||
|
||||
logger.info("Item used successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
item_id=item_id,
|
||||
hp_restored=result.hp_restored,
|
||||
mp_restored=result.mp_restored)
|
||||
|
||||
return success_response(result=result.to_dict())
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for use item",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except ItemNotFoundError as e:
|
||||
logger.warning("Item not found for use",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
item_id=item_id if 'item_id' in locals() else 'unknown',
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except CannotUseItemError as e:
|
||||
logger.warning("Cannot use item",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
item_id=item_id if 'item_id' in locals() else 'unknown',
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CANNOT_USE_ITEM",
|
||||
message=str(e),
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to use item",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="USE_ITEM_ERROR",
|
||||
message="Failed to use item",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/<item_id>', methods=['DELETE'])
|
||||
@require_auth
|
||||
def drop_item(character_id: str, item_id: str):
|
||||
"""
|
||||
Drop (remove) an item from inventory.
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
item_id: Item ID to drop
|
||||
|
||||
Returns:
|
||||
200: Item dropped successfully
|
||||
401: Not authenticated
|
||||
404: Character or item not found
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"message": "Dropped Rusty Sword",
|
||||
"dropped_item": {...},
|
||||
"inventory_count": 4
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
|
||||
logger.info("Dropping item",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
item_id=item_id)
|
||||
|
||||
# Get character (validates ownership)
|
||||
char_service = get_character_service()
|
||||
character = char_service.get_character(character_id, user.id)
|
||||
|
||||
# Get inventory service
|
||||
inventory_service = get_inventory_service()
|
||||
|
||||
# Drop item
|
||||
dropped_item = inventory_service.drop_item(character, item_id, user.id)
|
||||
|
||||
logger.info("Item dropped successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
item_id=item_id,
|
||||
item_name=dropped_item.get_display_name())
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"message": f"Dropped {dropped_item.get_display_name()}",
|
||||
"dropped_item": dropped_item.to_dict(),
|
||||
"inventory_count": len(character.inventory),
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for drop",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except ItemNotFoundError as e:
|
||||
logger.warning("Item not found for drop",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
item_id=item_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to drop item",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id,
|
||||
item_id=item_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="DROP_ITEM_ERROR",
|
||||
message="Failed to drop item",
|
||||
status=500
|
||||
)
|
||||
@@ -16,6 +16,7 @@ from app.services.session_service import get_session_service, SessionNotFound
|
||||
from app.services.character_service import get_character_service, CharacterNotFound
|
||||
from app.services.npc_loader import get_npc_loader
|
||||
from app.services.location_loader import get_location_loader
|
||||
from app.services.quest_eligibility_service import get_quest_eligibility_service
|
||||
from app.tasks.ai_tasks import enqueue_ai_task, TaskType
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
@@ -192,6 +193,57 @@ def talk_to_npc(npc_id: str):
|
||||
interaction
|
||||
)
|
||||
|
||||
# Check for quest eligibility
|
||||
quest_offering_context = None
|
||||
quest_ineligibility_context = None # For explaining why player can't take a quest
|
||||
player_asking_for_quests = _is_player_asking_for_quests(topic)
|
||||
try:
|
||||
quest_eligibility_service = get_quest_eligibility_service()
|
||||
location_type = _get_location_type(session.game_state.current_location)
|
||||
|
||||
# If player is explicitly asking about quests, bypass probability roll
|
||||
force_probability = 1.0 if player_asking_for_quests else None
|
||||
|
||||
eligibility_result = quest_eligibility_service.check_eligibility(
|
||||
npc_id=npc_id,
|
||||
character=character,
|
||||
location_type=location_type,
|
||||
location_id=session.game_state.current_location,
|
||||
force_probability=force_probability
|
||||
)
|
||||
|
||||
if eligibility_result.should_offer_quest and eligibility_result.selected_quest_context:
|
||||
quest_offering_context = eligibility_result.selected_quest_context.to_dict()
|
||||
# Add should_offer flag for template conditional check
|
||||
quest_offering_context['should_offer'] = True
|
||||
logger.debug(
|
||||
"Quest eligible for offering",
|
||||
npc_id=npc_id,
|
||||
quest_id=quest_offering_context.get("quest_id"),
|
||||
character_id=character.character_id
|
||||
)
|
||||
elif player_asking_for_quests and eligibility_result.blocking_reasons:
|
||||
# Player asked for quests but isn't eligible - tell them why
|
||||
quest_ineligibility_context = _build_ineligibility_context(
|
||||
eligibility_result.blocking_reasons,
|
||||
character.level,
|
||||
npc_id
|
||||
)
|
||||
if quest_ineligibility_context:
|
||||
logger.debug(
|
||||
"Quest ineligible - providing reason to AI",
|
||||
npc_id=npc_id,
|
||||
reason=quest_ineligibility_context.get("reason_type"),
|
||||
character_level=character.level
|
||||
)
|
||||
except Exception as e:
|
||||
# Don't fail the conversation if quest eligibility check fails
|
||||
logger.warning(
|
||||
"Quest eligibility check failed",
|
||||
npc_id=npc_id,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
# Build NPC knowledge for AI context
|
||||
npc_knowledge = []
|
||||
if npc.knowledge:
|
||||
@@ -220,6 +272,9 @@ def talk_to_npc(npc_id: str):
|
||||
"interaction_count": interaction["interaction_count"],
|
||||
"relationship_level": interaction.get("relationship_level", 50),
|
||||
"previous_dialogue": previous_dialogue, # Pass conversation history
|
||||
"quest_offering_context": quest_offering_context, # Quest offer if eligible
|
||||
"quest_ineligibility_context": quest_ineligibility_context, # Why player can't take quest
|
||||
"player_asking_for_quests": player_asking_for_quests, # Player explicitly asking for work
|
||||
}
|
||||
|
||||
# Enqueue AI task
|
||||
@@ -428,3 +483,163 @@ def set_npc_flag(npc_id: str):
|
||||
npc_id=npc_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to set flag", 500)
|
||||
|
||||
|
||||
def _get_location_type(location_id: str) -> str:
|
||||
"""
|
||||
Extract location type from location_id for quest probability calculation.
|
||||
|
||||
Args:
|
||||
location_id: The location identifier string
|
||||
|
||||
Returns:
|
||||
Location type string (tavern, shop, wilderness, dungeon, or town)
|
||||
"""
|
||||
location_lower = location_id.lower()
|
||||
|
||||
if "tavern" in location_lower or "inn" in location_lower:
|
||||
return "tavern"
|
||||
elif "shop" in location_lower or "market" in location_lower or "store" in location_lower:
|
||||
return "shop"
|
||||
elif "wilderness" in location_lower or "forest" in location_lower or "road" in location_lower:
|
||||
return "wilderness"
|
||||
elif "dungeon" in location_lower or "cave" in location_lower or "mine" in location_lower:
|
||||
return "dungeon"
|
||||
|
||||
return "town" # Default for town centers, squares, etc.
|
||||
|
||||
|
||||
def _build_ineligibility_context(
|
||||
blocking_reasons: dict[str, str],
|
||||
character_level: int,
|
||||
npc_id: str
|
||||
) -> dict | None:
|
||||
"""
|
||||
Build context explaining why a player can't take a quest.
|
||||
|
||||
Parses the blocking reasons and creates a structured context
|
||||
that the AI can use to explain to the player why they can't
|
||||
help with the quest yet.
|
||||
|
||||
Args:
|
||||
blocking_reasons: Dict of quest_id -> reason string
|
||||
character_level: Player's current level
|
||||
npc_id: The NPC they're talking to
|
||||
|
||||
Returns:
|
||||
Dict with reason_type, message, and details, or None if no relevant reason
|
||||
"""
|
||||
if not blocking_reasons:
|
||||
return None
|
||||
|
||||
# Look through blocking reasons for level-related issues
|
||||
for quest_id, reason in blocking_reasons.items():
|
||||
if "level too low" in reason.lower():
|
||||
# Extract required level from reason string like "Character level too low (need 3)"
|
||||
import re
|
||||
match = re.search(r'need (\d+)', reason)
|
||||
required_level = int(match.group(1)) if match else character_level + 1
|
||||
|
||||
return {
|
||||
"reason_type": "level_too_low",
|
||||
"current_level": character_level,
|
||||
"required_level": required_level,
|
||||
"message": f"The player is level {character_level} but needs to be level {required_level}",
|
||||
"quest_id": quest_id,
|
||||
}
|
||||
|
||||
if "level too high" in reason.lower():
|
||||
return {
|
||||
"reason_type": "level_too_high",
|
||||
"current_level": character_level,
|
||||
"message": "The player is too experienced for this task",
|
||||
"quest_id": quest_id,
|
||||
}
|
||||
|
||||
if "prerequisite" in reason.lower():
|
||||
return {
|
||||
"reason_type": "prerequisite_missing",
|
||||
"message": "The player hasn't completed a required earlier task",
|
||||
"quest_id": quest_id,
|
||||
}
|
||||
|
||||
if "already active" in reason.lower():
|
||||
return {
|
||||
"reason_type": "quest_already_active",
|
||||
"message": "The player is already working on this quest",
|
||||
"quest_id": quest_id,
|
||||
}
|
||||
|
||||
if "already completed" in reason.lower():
|
||||
return {
|
||||
"reason_type": "quest_already_completed",
|
||||
"message": "The player has already completed this quest",
|
||||
"quest_id": quest_id,
|
||||
}
|
||||
|
||||
if "relationship" in reason.lower():
|
||||
return {
|
||||
"reason_type": "relationship_too_low",
|
||||
"message": "The NPC doesn't trust the player enough yet",
|
||||
"quest_id": quest_id,
|
||||
}
|
||||
|
||||
if "max" in reason.lower() and "quest" in reason.lower():
|
||||
return {
|
||||
"reason_type": "too_many_quests",
|
||||
"message": "The player already has too many active quests",
|
||||
"quest_id": quest_id,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _is_player_asking_for_quests(topic: str) -> bool:
|
||||
"""
|
||||
Detect if the player is explicitly asking about quests or work.
|
||||
|
||||
This is used to bypass the probability roll when the player
|
||||
clearly intends to find quests.
|
||||
|
||||
Args:
|
||||
topic: The player's message/conversation topic
|
||||
|
||||
Returns:
|
||||
True if player is asking about quests, False otherwise
|
||||
"""
|
||||
topic_lower = topic.lower()
|
||||
|
||||
# Quest-related keywords
|
||||
quest_keywords = [
|
||||
"quest",
|
||||
"quests",
|
||||
"any work",
|
||||
"work for me",
|
||||
"job",
|
||||
"jobs",
|
||||
"task",
|
||||
"tasks",
|
||||
"help you",
|
||||
"help with",
|
||||
"need help",
|
||||
"anything i can do",
|
||||
"can i help",
|
||||
"how can i help",
|
||||
"i'd love to help",
|
||||
"i would love to help",
|
||||
"want to help",
|
||||
"like to help",
|
||||
"offer my services",
|
||||
"hire me",
|
||||
"bounty",
|
||||
"bounties",
|
||||
"adventure",
|
||||
"mission",
|
||||
"missions",
|
||||
]
|
||||
|
||||
for keyword in quest_keywords:
|
||||
if keyword in topic_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
724
api/app/api/quests.py
Normal file
724
api/app/api/quests.py
Normal file
@@ -0,0 +1,724 @@
|
||||
"""
|
||||
Quest API endpoints for quest management.
|
||||
|
||||
This module provides REST endpoints for:
|
||||
- Accepting offered quests
|
||||
- Declining offered quests
|
||||
- Getting quest details
|
||||
- Listing character quests
|
||||
- Completing quests (internal use)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from flask import Blueprint, request, jsonify
|
||||
import structlog
|
||||
|
||||
from app.utils.response import api_response, error_response
|
||||
from app.utils.auth import require_auth
|
||||
from app.services.quest_service import get_quest_service
|
||||
from app.services.quest_eligibility_service import get_quest_eligibility_service
|
||||
from app.services.character_service import get_character_service
|
||||
from app.models.quest import QuestStatus, CharacterQuestState
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
quests_bp = Blueprint('quests', __name__, url_prefix='/api/v1/quests')
|
||||
|
||||
|
||||
@quests_bp.route('/<quest_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_quest(user_id: str, quest_id: str):
|
||||
"""
|
||||
Get details for a specific quest.
|
||||
|
||||
Args:
|
||||
quest_id: Quest identifier
|
||||
|
||||
Returns:
|
||||
Quest details or 404 if not found
|
||||
"""
|
||||
quest_service = get_quest_service()
|
||||
quest = quest_service.load_quest(quest_id)
|
||||
|
||||
if not quest:
|
||||
return error_response(
|
||||
message=f"Quest not found: {quest_id}",
|
||||
status=404,
|
||||
code="QUEST_NOT_FOUND",
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data=quest.to_offer_dict(),
|
||||
message="Quest details retrieved",
|
||||
)
|
||||
|
||||
|
||||
@quests_bp.route('/accept', methods=['POST'])
|
||||
@require_auth
|
||||
def accept_quest(user_id: str):
|
||||
"""
|
||||
Accept an offered quest.
|
||||
|
||||
Expected JSON body:
|
||||
{
|
||||
"character_id": "char_123",
|
||||
"quest_id": "quest_cellar_rats",
|
||||
"npc_id": "npc_grom_ironbeard" # Optional: NPC who offered the quest
|
||||
}
|
||||
|
||||
Returns:
|
||||
Updated character quest state
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(
|
||||
message="Request body is required",
|
||||
status=400,
|
||||
code="MISSING_BODY",
|
||||
)
|
||||
|
||||
character_id = data.get('character_id')
|
||||
quest_id = data.get('quest_id')
|
||||
npc_id = data.get('npc_id')
|
||||
|
||||
if not character_id or not quest_id:
|
||||
return error_response(
|
||||
message="character_id and quest_id are required",
|
||||
status=400,
|
||||
code="MISSING_FIELDS",
|
||||
)
|
||||
|
||||
# Get character
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if not character:
|
||||
return error_response(
|
||||
message=f"Character not found: {character_id}",
|
||||
status=404,
|
||||
code="CHARACTER_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Verify character belongs to user
|
||||
if character.user_id != user_id:
|
||||
return error_response(
|
||||
message="Character does not belong to this user",
|
||||
status=403,
|
||||
code="ACCESS_DENIED",
|
||||
)
|
||||
|
||||
# Get quest
|
||||
quest_service = get_quest_service()
|
||||
quest = quest_service.load_quest(quest_id)
|
||||
|
||||
if not quest:
|
||||
return error_response(
|
||||
message=f"Quest not found: {quest_id}",
|
||||
status=404,
|
||||
code="QUEST_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Check if already at max quests
|
||||
if len(character.active_quests) >= 2:
|
||||
return error_response(
|
||||
message="Maximum active quests reached (2)",
|
||||
status=400,
|
||||
code="MAX_QUESTS_REACHED",
|
||||
)
|
||||
|
||||
# Check if quest is already active
|
||||
if quest_id in character.active_quests:
|
||||
return error_response(
|
||||
message="Quest is already active",
|
||||
status=400,
|
||||
code="QUEST_ALREADY_ACTIVE",
|
||||
)
|
||||
|
||||
# Check if quest is already completed
|
||||
completed_quests = getattr(character, 'completed_quests', [])
|
||||
if quest_id in completed_quests:
|
||||
return error_response(
|
||||
message="Quest has already been completed",
|
||||
status=400,
|
||||
code="QUEST_ALREADY_COMPLETED",
|
||||
)
|
||||
|
||||
# Add quest to active quests
|
||||
character.active_quests.append(quest_id)
|
||||
|
||||
# Create quest state tracking
|
||||
quest_state = CharacterQuestState(
|
||||
quest_id=quest_id,
|
||||
status=QuestStatus.ACTIVE,
|
||||
accepted_at=datetime.now(timezone.utc).isoformat(),
|
||||
objectives_progress={
|
||||
obj.objective_id: 0 for obj in quest.objectives
|
||||
},
|
||||
)
|
||||
|
||||
# Store quest state in character (would normally go to database)
|
||||
if not hasattr(character, 'quest_states'):
|
||||
character.quest_states = {}
|
||||
character.quest_states[quest_id] = quest_state.to_dict()
|
||||
|
||||
# Update NPC relationship if NPC provided
|
||||
if npc_id and npc_id in character.npc_interactions:
|
||||
npc_interaction = character.npc_interactions[npc_id]
|
||||
current_relationship = npc_interaction.get('relationship_level', 50)
|
||||
npc_interaction['relationship_level'] = min(100, current_relationship + 5)
|
||||
# Set accepted flag
|
||||
if 'custom_flags' not in npc_interaction:
|
||||
npc_interaction['custom_flags'] = {}
|
||||
npc_interaction['custom_flags'][f'accepted_{quest_id}'] = True
|
||||
|
||||
# Save character
|
||||
character_service.update_character(character)
|
||||
|
||||
logger.info(
|
||||
"Quest accepted",
|
||||
character_id=character_id,
|
||||
quest_id=quest_id,
|
||||
npc_id=npc_id,
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"quest_id": quest_id,
|
||||
"quest_name": quest.name,
|
||||
"active_quests": character.active_quests,
|
||||
"quest_state": quest_state.to_dict(),
|
||||
},
|
||||
message=f"Quest accepted: {quest.name}",
|
||||
)
|
||||
|
||||
|
||||
@quests_bp.route('/decline', methods=['POST'])
|
||||
@require_auth
|
||||
def decline_quest(user_id: str):
|
||||
"""
|
||||
Decline an offered quest.
|
||||
|
||||
Sets a flag on the NPC interaction to prevent immediate re-offering.
|
||||
|
||||
Expected JSON body:
|
||||
{
|
||||
"character_id": "char_123",
|
||||
"quest_id": "quest_cellar_rats",
|
||||
"npc_id": "npc_grom_ironbeard"
|
||||
}
|
||||
|
||||
Returns:
|
||||
Confirmation of decline
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(
|
||||
message="Request body is required",
|
||||
status=400,
|
||||
code="MISSING_BODY",
|
||||
)
|
||||
|
||||
character_id = data.get('character_id')
|
||||
quest_id = data.get('quest_id')
|
||||
npc_id = data.get('npc_id')
|
||||
|
||||
if not character_id or not quest_id:
|
||||
return error_response(
|
||||
message="character_id and quest_id are required",
|
||||
status=400,
|
||||
code="MISSING_FIELDS",
|
||||
)
|
||||
|
||||
# Get character
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if not character:
|
||||
return error_response(
|
||||
message=f"Character not found: {character_id}",
|
||||
status=404,
|
||||
code="CHARACTER_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Verify character belongs to user
|
||||
if character.user_id != user_id:
|
||||
return error_response(
|
||||
message="Character does not belong to this user",
|
||||
status=403,
|
||||
code="ACCESS_DENIED",
|
||||
)
|
||||
|
||||
# Set declined flag on NPC interaction
|
||||
if npc_id:
|
||||
if npc_id not in character.npc_interactions:
|
||||
character.npc_interactions[npc_id] = {
|
||||
'npc_id': npc_id,
|
||||
'relationship_level': 50,
|
||||
'custom_flags': {},
|
||||
}
|
||||
|
||||
npc_interaction = character.npc_interactions[npc_id]
|
||||
if 'custom_flags' not in npc_interaction:
|
||||
npc_interaction['custom_flags'] = {}
|
||||
|
||||
# Set declined flag - this will be checked by quest eligibility
|
||||
npc_interaction['custom_flags'][f'declined_{quest_id}'] = True
|
||||
|
||||
# Save character
|
||||
character_service.update_character(character)
|
||||
|
||||
logger.info(
|
||||
"Quest declined",
|
||||
character_id=character_id,
|
||||
quest_id=quest_id,
|
||||
npc_id=npc_id,
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"quest_id": quest_id,
|
||||
"declined": True,
|
||||
},
|
||||
message="Quest declined",
|
||||
)
|
||||
|
||||
|
||||
@quests_bp.route('/characters/<character_id>/quests', methods=['GET'])
|
||||
@require_auth
|
||||
def get_character_quests(user_id: str, character_id: str):
|
||||
"""
|
||||
Get a character's active and completed quests.
|
||||
|
||||
Args:
|
||||
character_id: Character identifier
|
||||
|
||||
Returns:
|
||||
Lists of active and completed quests with details
|
||||
"""
|
||||
# Get character
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if not character:
|
||||
return error_response(
|
||||
message=f"Character not found: {character_id}",
|
||||
status=404,
|
||||
code="CHARACTER_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Verify character belongs to user
|
||||
if character.user_id != user_id:
|
||||
return error_response(
|
||||
message="Character does not belong to this user",
|
||||
status=403,
|
||||
code="ACCESS_DENIED",
|
||||
)
|
||||
|
||||
# Get quest details for active quests
|
||||
quest_service = get_quest_service()
|
||||
active_quests = []
|
||||
for quest_id in character.active_quests:
|
||||
quest = quest_service.load_quest(quest_id)
|
||||
if quest:
|
||||
quest_data = quest.to_offer_dict()
|
||||
# Add progress if available
|
||||
quest_states = getattr(character, 'quest_states', {})
|
||||
if quest_id in quest_states:
|
||||
quest_data['progress'] = quest_states[quest_id]
|
||||
active_quests.append(quest_data)
|
||||
|
||||
# Get completed quest IDs
|
||||
completed_quests = getattr(character, 'completed_quests', [])
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"active_quests": active_quests,
|
||||
"completed_quest_ids": completed_quests,
|
||||
"active_count": len(active_quests),
|
||||
"completed_count": len(completed_quests),
|
||||
},
|
||||
message="Character quests retrieved",
|
||||
)
|
||||
|
||||
|
||||
@quests_bp.route('/complete', methods=['POST'])
|
||||
@require_auth
|
||||
def complete_quest(user_id: str):
|
||||
"""
|
||||
Complete a quest and grant rewards.
|
||||
|
||||
This endpoint is typically called by the game system when all
|
||||
objectives are completed, not directly by the player.
|
||||
|
||||
Expected JSON body:
|
||||
{
|
||||
"character_id": "char_123",
|
||||
"quest_id": "quest_cellar_rats",
|
||||
"npc_id": "npc_grom_ironbeard" # Optional: for reward dialogue
|
||||
}
|
||||
|
||||
Returns:
|
||||
Rewards granted and updated character state
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(
|
||||
message="Request body is required",
|
||||
status=400,
|
||||
code="MISSING_BODY",
|
||||
)
|
||||
|
||||
character_id = data.get('character_id')
|
||||
quest_id = data.get('quest_id')
|
||||
npc_id = data.get('npc_id')
|
||||
|
||||
if not character_id or not quest_id:
|
||||
return error_response(
|
||||
message="character_id and quest_id are required",
|
||||
status=400,
|
||||
code="MISSING_FIELDS",
|
||||
)
|
||||
|
||||
# Get character
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if not character:
|
||||
return error_response(
|
||||
message=f"Character not found: {character_id}",
|
||||
status=404,
|
||||
code="CHARACTER_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Verify character belongs to user
|
||||
if character.user_id != user_id:
|
||||
return error_response(
|
||||
message="Character does not belong to this user",
|
||||
status=403,
|
||||
code="ACCESS_DENIED",
|
||||
)
|
||||
|
||||
# Check quest is active
|
||||
if quest_id not in character.active_quests:
|
||||
return error_response(
|
||||
message="Quest is not active for this character",
|
||||
status=400,
|
||||
code="QUEST_NOT_ACTIVE",
|
||||
)
|
||||
|
||||
# Get quest for rewards
|
||||
quest_service = get_quest_service()
|
||||
quest = quest_service.load_quest(quest_id)
|
||||
|
||||
if not quest:
|
||||
return error_response(
|
||||
message=f"Quest not found: {quest_id}",
|
||||
status=404,
|
||||
code="QUEST_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Remove from active quests
|
||||
character.active_quests.remove(quest_id)
|
||||
|
||||
# Add to completed quests
|
||||
if not hasattr(character, 'completed_quests'):
|
||||
character.completed_quests = []
|
||||
character.completed_quests.append(quest_id)
|
||||
|
||||
# Grant rewards
|
||||
rewards = quest.rewards
|
||||
character.gold += rewards.gold
|
||||
leveled_up = character.add_experience(rewards.experience)
|
||||
|
||||
# Apply relationship bonuses
|
||||
for bonus_npc_id, bonus_amount in rewards.relationship_bonuses.items():
|
||||
if bonus_npc_id in character.npc_interactions:
|
||||
npc_interaction = character.npc_interactions[bonus_npc_id]
|
||||
current_relationship = npc_interaction.get('relationship_level', 50)
|
||||
npc_interaction['relationship_level'] = min(100, current_relationship + bonus_amount)
|
||||
|
||||
# Reveal locations
|
||||
for location_id in rewards.reveals_locations:
|
||||
if location_id not in character.discovered_locations:
|
||||
character.discovered_locations.append(location_id)
|
||||
|
||||
# Update quest state
|
||||
quest_states = getattr(character, 'quest_states', {})
|
||||
if quest_id in quest_states:
|
||||
quest_states[quest_id]['status'] = QuestStatus.COMPLETED.value
|
||||
quest_states[quest_id]['completed_at'] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Save character
|
||||
character_service.update_character(character)
|
||||
|
||||
# Get completion dialogue if NPC provided
|
||||
completion_dialogue = ""
|
||||
if npc_id:
|
||||
completion_dialogue = quest.get_completion_dialogue(npc_id)
|
||||
|
||||
logger.info(
|
||||
"Quest completed",
|
||||
character_id=character_id,
|
||||
quest_id=quest_id,
|
||||
gold_granted=rewards.gold,
|
||||
xp_granted=rewards.experience,
|
||||
leveled_up=leveled_up,
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"quest_id": quest_id,
|
||||
"quest_name": quest.name,
|
||||
"rewards": {
|
||||
"gold": rewards.gold,
|
||||
"experience": rewards.experience,
|
||||
"items": rewards.items,
|
||||
"relationship_bonuses": rewards.relationship_bonuses,
|
||||
"reveals_locations": rewards.reveals_locations,
|
||||
},
|
||||
"leveled_up": leveled_up,
|
||||
"new_level": character.level if leveled_up else None,
|
||||
"completion_dialogue": completion_dialogue,
|
||||
},
|
||||
message=f"Quest completed: {quest.name}",
|
||||
)
|
||||
|
||||
|
||||
@quests_bp.route('/progress', methods=['POST'])
|
||||
@require_auth
|
||||
def update_quest_progress(user_id: str):
|
||||
"""
|
||||
Update progress on a quest objective.
|
||||
|
||||
This endpoint is called when a player completes actions that contribute
|
||||
to quest objectives (kills enemies, collects items, etc.).
|
||||
|
||||
Expected JSON body:
|
||||
{
|
||||
"character_id": "char_123",
|
||||
"quest_id": "quest_cellar_rats",
|
||||
"objective_id": "kill_rats",
|
||||
"amount": 1 # Optional, defaults to 1
|
||||
}
|
||||
|
||||
Returns:
|
||||
Updated progress state with completion flags
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(
|
||||
message="Request body is required",
|
||||
status=400,
|
||||
code="MISSING_BODY",
|
||||
)
|
||||
|
||||
character_id = data.get('character_id')
|
||||
quest_id = data.get('quest_id')
|
||||
objective_id = data.get('objective_id')
|
||||
amount = data.get('amount', 1)
|
||||
|
||||
if not character_id or not quest_id or not objective_id:
|
||||
return error_response(
|
||||
message="character_id, quest_id, and objective_id are required",
|
||||
status=400,
|
||||
code="MISSING_FIELDS",
|
||||
)
|
||||
|
||||
# Get character
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if not character:
|
||||
return error_response(
|
||||
message=f"Character not found: {character_id}",
|
||||
status=404,
|
||||
code="CHARACTER_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Verify character belongs to user
|
||||
if character.user_id != user_id:
|
||||
return error_response(
|
||||
message="Character does not belong to this user",
|
||||
status=403,
|
||||
code="ACCESS_DENIED",
|
||||
)
|
||||
|
||||
# Check quest is active
|
||||
if quest_id not in character.active_quests:
|
||||
return error_response(
|
||||
message="Quest is not active for this character",
|
||||
status=400,
|
||||
code="QUEST_NOT_ACTIVE",
|
||||
)
|
||||
|
||||
# Get quest definition to validate objective and get required amount
|
||||
quest_service = get_quest_service()
|
||||
quest = quest_service.load_quest(quest_id)
|
||||
|
||||
if not quest:
|
||||
return error_response(
|
||||
message=f"Quest not found: {quest_id}",
|
||||
status=404,
|
||||
code="QUEST_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Find the objective
|
||||
objective = None
|
||||
for obj in quest.objectives:
|
||||
if obj.objective_id == objective_id:
|
||||
objective = obj
|
||||
break
|
||||
|
||||
if not objective:
|
||||
return error_response(
|
||||
message=f"Objective not found: {objective_id}",
|
||||
status=404,
|
||||
code="OBJECTIVE_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Get or create quest state
|
||||
quest_states = getattr(character, 'quest_states', {})
|
||||
if quest_id not in quest_states:
|
||||
# Initialize quest state if missing
|
||||
quest_states[quest_id] = {
|
||||
'quest_id': quest_id,
|
||||
'status': QuestStatus.ACTIVE.value,
|
||||
'accepted_at': datetime.now(timezone.utc).isoformat(),
|
||||
'objectives_progress': {obj.objective_id: 0 for obj in quest.objectives},
|
||||
'completed_at': None,
|
||||
}
|
||||
character.quest_states = quest_states
|
||||
|
||||
quest_state = quest_states[quest_id]
|
||||
objectives_progress = quest_state.get('objectives_progress', {})
|
||||
|
||||
# Update progress
|
||||
current_progress = objectives_progress.get(objective_id, 0)
|
||||
new_progress = min(current_progress + amount, objective.required_progress)
|
||||
objectives_progress[objective_id] = new_progress
|
||||
quest_state['objectives_progress'] = objectives_progress
|
||||
|
||||
# Check if this objective is complete
|
||||
objective_complete = new_progress >= objective.required_progress
|
||||
|
||||
# Check if entire quest is complete (all objectives met)
|
||||
quest_complete = True
|
||||
for obj in quest.objectives:
|
||||
obj_progress = objectives_progress.get(obj.objective_id, 0)
|
||||
if obj_progress < obj.required_progress:
|
||||
quest_complete = False
|
||||
break
|
||||
|
||||
# Save character
|
||||
character_service.update_character(character)
|
||||
|
||||
logger.info(
|
||||
"Quest progress updated",
|
||||
character_id=character_id,
|
||||
quest_id=quest_id,
|
||||
objective_id=objective_id,
|
||||
new_progress=new_progress,
|
||||
required=objective.required_progress,
|
||||
objective_complete=objective_complete,
|
||||
quest_complete=quest_complete,
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"quest_id": quest_id,
|
||||
"objective_id": objective_id,
|
||||
"new_progress": new_progress,
|
||||
"required": objective.required_progress,
|
||||
"objective_complete": objective_complete,
|
||||
"quest_complete": quest_complete,
|
||||
"all_progress": objectives_progress,
|
||||
},
|
||||
message=f"Progress updated: {new_progress}/{objective.required_progress}",
|
||||
)
|
||||
|
||||
|
||||
@quests_bp.route('/abandon', methods=['POST'])
|
||||
@require_auth
|
||||
def abandon_quest(user_id: str):
|
||||
"""
|
||||
Abandon an active quest.
|
||||
|
||||
Expected JSON body:
|
||||
{
|
||||
"character_id": "char_123",
|
||||
"quest_id": "quest_cellar_rats"
|
||||
}
|
||||
|
||||
Returns:
|
||||
Confirmation of abandonment
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(
|
||||
message="Request body is required",
|
||||
status=400,
|
||||
code="MISSING_BODY",
|
||||
)
|
||||
|
||||
character_id = data.get('character_id')
|
||||
quest_id = data.get('quest_id')
|
||||
|
||||
if not character_id or not quest_id:
|
||||
return error_response(
|
||||
message="character_id and quest_id are required",
|
||||
status=400,
|
||||
code="MISSING_FIELDS",
|
||||
)
|
||||
|
||||
# Get character
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if not character:
|
||||
return error_response(
|
||||
message=f"Character not found: {character_id}",
|
||||
status=404,
|
||||
code="CHARACTER_NOT_FOUND",
|
||||
)
|
||||
|
||||
# Verify character belongs to user
|
||||
if character.user_id != user_id:
|
||||
return error_response(
|
||||
message="Character does not belong to this user",
|
||||
status=403,
|
||||
code="ACCESS_DENIED",
|
||||
)
|
||||
|
||||
# Check quest is active
|
||||
if quest_id not in character.active_quests:
|
||||
return error_response(
|
||||
message="Quest is not active for this character",
|
||||
status=400,
|
||||
code="QUEST_NOT_ACTIVE",
|
||||
)
|
||||
|
||||
# Remove from active quests
|
||||
character.active_quests.remove(quest_id)
|
||||
|
||||
# Update quest state
|
||||
quest_states = getattr(character, 'quest_states', {})
|
||||
if quest_id in quest_states:
|
||||
quest_states[quest_id]['status'] = QuestStatus.FAILED.value
|
||||
|
||||
# Save character
|
||||
character_service.update_character(character)
|
||||
|
||||
logger.info(
|
||||
"Quest abandoned",
|
||||
character_id=character_id,
|
||||
quest_id=quest_id,
|
||||
)
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"quest_id": quest_id,
|
||||
"abandoned": True,
|
||||
"active_quests": character.active_quests,
|
||||
},
|
||||
message="Quest abandoned",
|
||||
)
|
||||
@@ -132,23 +132,44 @@ def list_sessions():
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
session_service = get_session_service()
|
||||
character_service = get_character_service()
|
||||
|
||||
# Get user's active sessions
|
||||
sessions = session_service.get_user_sessions(user_id, active_only=True)
|
||||
|
||||
# Build character name lookup for efficiency
|
||||
character_ids = [s.solo_character_id for s in sessions if s.solo_character_id]
|
||||
character_names = {}
|
||||
for char_id in character_ids:
|
||||
try:
|
||||
char = character_service.get_character(char_id, user_id)
|
||||
if char:
|
||||
character_names[char_id] = char.name
|
||||
except Exception:
|
||||
pass # Character may have been deleted
|
||||
|
||||
# Build response with basic session info
|
||||
sessions_list = []
|
||||
for session in sessions:
|
||||
# Get combat round if in combat
|
||||
combat_round = None
|
||||
if session.is_in_combat() and session.combat_encounter:
|
||||
combat_round = session.combat_encounter.round_number
|
||||
|
||||
sessions_list.append({
|
||||
'session_id': session.session_id,
|
||||
'character_id': session.solo_character_id,
|
||||
'character_name': character_names.get(session.solo_character_id),
|
||||
'turn_number': session.turn_number,
|
||||
'status': session.status.value,
|
||||
'created_at': session.created_at,
|
||||
'last_activity': session.last_activity,
|
||||
'in_combat': session.is_in_combat(),
|
||||
'game_state': {
|
||||
'current_location': session.game_state.current_location,
|
||||
'location_type': session.game_state.location_type.value
|
||||
'location_type': session.game_state.location_type.value,
|
||||
'in_combat': session.is_in_combat(),
|
||||
'combat_round': combat_round
|
||||
}
|
||||
})
|
||||
|
||||
@@ -235,7 +256,7 @@ def create_session():
|
||||
return error_response(
|
||||
status=409,
|
||||
code="SESSION_LIMIT_EXCEEDED",
|
||||
message="Maximum active sessions limit reached (5). Please end an existing session first."
|
||||
message=str(e)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -485,10 +506,12 @@ def get_session_state(session_id: str):
|
||||
"character_id": session.get_character_id(),
|
||||
"turn_number": session.turn_number,
|
||||
"status": session.status.value,
|
||||
"in_combat": session.is_in_combat(),
|
||||
"game_state": {
|
||||
"current_location": session.game_state.current_location,
|
||||
"location_type": session.game_state.location_type.value,
|
||||
"active_quests": session.game_state.active_quests
|
||||
"active_quests": session.game_state.active_quests,
|
||||
"in_combat": session.is_in_combat()
|
||||
},
|
||||
"available_actions": available_actions
|
||||
})
|
||||
@@ -602,3 +625,111 @@ def get_history(session_id: str):
|
||||
code="HISTORY_ERROR",
|
||||
message="Failed to get conversation history"
|
||||
)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions/<session_id>', methods=['DELETE'])
|
||||
@require_auth
|
||||
def delete_session(session_id: str):
|
||||
"""
|
||||
Permanently delete a game session.
|
||||
|
||||
This removes the session from the database entirely. The session cannot be
|
||||
recovered after deletion. Use this to free up session slots for users who
|
||||
have reached their tier limit.
|
||||
|
||||
Returns:
|
||||
200: Session deleted successfully
|
||||
401: Not authenticated
|
||||
404: Session not found or not owned by user
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Deleting session", session_id=session_id)
|
||||
|
||||
try:
|
||||
# Get current user
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
|
||||
# Delete session (validates ownership internally)
|
||||
session_service = get_session_service()
|
||||
session_service.delete_session(session_id, user_id)
|
||||
|
||||
logger.info("Session deleted successfully",
|
||||
session_id=session_id,
|
||||
user_id=user_id)
|
||||
|
||||
return success_response({
|
||||
"message": "Session deleted successfully",
|
||||
"session_id": session_id
|
||||
})
|
||||
|
||||
except SessionNotFound as e:
|
||||
logger.warning("Session not found for deletion",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
return not_found_response("Session not found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete session",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="SESSION_DELETE_ERROR",
|
||||
message="Failed to delete session"
|
||||
)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/usage', methods=['GET'])
|
||||
@require_auth
|
||||
def get_usage():
|
||||
"""
|
||||
Get user's daily usage information.
|
||||
|
||||
Returns the current daily turn usage, limit, remaining turns,
|
||||
and reset time. Limits are based on user's subscription tier.
|
||||
|
||||
Returns:
|
||||
200: Usage information
|
||||
{
|
||||
"user_id": "user_123",
|
||||
"user_tier": "free",
|
||||
"current_usage": 15,
|
||||
"daily_limit": 50,
|
||||
"remaining": 35,
|
||||
"reset_time": "2025-11-27T00:00:00+00:00",
|
||||
"is_limited": false,
|
||||
"is_unlimited": false
|
||||
}
|
||||
401: Not authenticated
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Getting usage info")
|
||||
|
||||
try:
|
||||
# Get current user and tier
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
user_tier = get_user_tier_from_user(user)
|
||||
|
||||
# Get usage info from rate limiter
|
||||
rate_limiter = RateLimiterService()
|
||||
usage_info = rate_limiter.get_usage_info(user_id, user_tier)
|
||||
|
||||
logger.debug("Usage info retrieved",
|
||||
user_id=user_id,
|
||||
current_usage=usage_info.get('current_usage'),
|
||||
remaining=usage_info.get('remaining'))
|
||||
|
||||
return success_response(usage_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get usage info",
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="USAGE_ERROR",
|
||||
message="Failed to get usage information"
|
||||
)
|
||||
|
||||
474
api/app/api/shop.py
Normal file
474
api/app/api/shop.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""
|
||||
Shop API Blueprint
|
||||
|
||||
Endpoints for browsing NPC shop inventory and making purchases/sales.
|
||||
All endpoints require authentication.
|
||||
|
||||
Endpoints:
|
||||
- GET /api/v1/shop/<shop_id>/inventory - Get shop inventory with character context
|
||||
- POST /api/v1/shop/<shop_id>/purchase - Purchase an item
|
||||
- POST /api/v1/shop/<shop_id>/sell - Sell an item back to the shop
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.shop_service import (
|
||||
get_shop_service,
|
||||
ShopNotFoundError,
|
||||
ItemNotInShopError,
|
||||
InsufficientGoldError,
|
||||
ItemNotOwnedError,
|
||||
)
|
||||
from app.services.character_service import (
|
||||
get_character_service,
|
||||
CharacterNotFound,
|
||||
)
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
error_response,
|
||||
not_found_response,
|
||||
validation_error_response,
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
shop_bp = Blueprint('shop', __name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@shop_bp.route('/api/v1/shop/<shop_id>/inventory', methods=['GET'])
|
||||
@require_auth
|
||||
def get_shop_inventory(shop_id: str):
|
||||
"""
|
||||
Get shop inventory with character context.
|
||||
|
||||
Query Parameters:
|
||||
character_id: Character ID to use for gold/affordability checks
|
||||
|
||||
Args:
|
||||
shop_id: Shop identifier
|
||||
|
||||
Returns:
|
||||
200: Shop inventory with enriched item data
|
||||
401: Not authenticated
|
||||
404: Shop or character not found
|
||||
422: Validation error (missing character_id)
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"shop": {
|
||||
"shop_id": "general_store",
|
||||
"shop_name": "General Store",
|
||||
"shopkeeper_name": "Merchant Guildmaster",
|
||||
"sell_rate": 0.5
|
||||
},
|
||||
"character": {
|
||||
"character_id": "char_abc",
|
||||
"gold": 500
|
||||
},
|
||||
"inventory": [
|
||||
{
|
||||
"item": {...},
|
||||
"shop_price": 25,
|
||||
"stock": -1,
|
||||
"can_afford": true,
|
||||
"item_id": "health_potion_small"
|
||||
}
|
||||
],
|
||||
"categories": ["consumable", "weapon", "armor"]
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
|
||||
# Get character_id from query params
|
||||
character_id = request.args.get('character_id', '').strip()
|
||||
|
||||
if not character_id:
|
||||
return validation_error_response(
|
||||
message="Validation failed",
|
||||
details={"character_id": "character_id query parameter is required"}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Getting shop inventory",
|
||||
user_id=user.id,
|
||||
shop_id=shop_id,
|
||||
character_id=character_id
|
||||
)
|
||||
|
||||
# Get character (validates ownership)
|
||||
char_service = get_character_service()
|
||||
character = char_service.get_character(character_id, user.id)
|
||||
|
||||
# Get enriched shop inventory
|
||||
shop_service = get_shop_service()
|
||||
result = shop_service.get_shop_inventory_for_character(shop_id, character)
|
||||
|
||||
logger.info(
|
||||
"Shop inventory retrieved",
|
||||
user_id=user.id,
|
||||
shop_id=shop_id,
|
||||
item_count=len(result["inventory"])
|
||||
)
|
||||
|
||||
return success_response(result=result)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning(
|
||||
"Character not found for shop",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id if 'character_id' in locals() else 'unknown',
|
||||
error=str(e)
|
||||
)
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except ShopNotFoundError as e:
|
||||
logger.warning(
|
||||
"Shop not found",
|
||||
shop_id=shop_id,
|
||||
error=str(e)
|
||||
)
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to get shop inventory",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
shop_id=shop_id,
|
||||
error=str(e)
|
||||
)
|
||||
return error_response(
|
||||
code="SHOP_INVENTORY_ERROR",
|
||||
message="Failed to retrieve shop inventory",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@shop_bp.route('/api/v1/shop/<shop_id>/purchase', methods=['POST'])
|
||||
@require_auth
|
||||
def purchase_item(shop_id: str):
|
||||
"""
|
||||
Purchase an item from the shop.
|
||||
|
||||
Args:
|
||||
shop_id: Shop identifier
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"character_id": "char_abc",
|
||||
"item_id": "health_potion_small",
|
||||
"quantity": 1,
|
||||
"session_id": "optional_session_id"
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Purchase successful
|
||||
400: Insufficient gold or invalid quantity
|
||||
401: Not authenticated
|
||||
404: Shop, character, or item not found
|
||||
422: Validation error
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"purchase": {
|
||||
"item_id": "health_potion_small",
|
||||
"quantity": 2,
|
||||
"total_cost": 50
|
||||
},
|
||||
"character": {
|
||||
"character_id": "char_abc",
|
||||
"gold": 450
|
||||
},
|
||||
"items_added": ["health_potion_small_abc123", "health_potion_small_def456"]
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response(
|
||||
message="Request body is required",
|
||||
details={"error": "Request body is required"}
|
||||
)
|
||||
|
||||
character_id = data.get('character_id', '').strip()
|
||||
item_id = data.get('item_id', '').strip()
|
||||
quantity = data.get('quantity', 1)
|
||||
session_id = data.get('session_id', '').strip() or None
|
||||
|
||||
# Validate required fields
|
||||
validation_errors = {}
|
||||
|
||||
if not character_id:
|
||||
validation_errors['character_id'] = "character_id is required"
|
||||
|
||||
if not item_id:
|
||||
validation_errors['item_id'] = "item_id is required"
|
||||
|
||||
if not isinstance(quantity, int) or quantity < 1:
|
||||
validation_errors['quantity'] = "quantity must be a positive integer"
|
||||
|
||||
if validation_errors:
|
||||
return validation_error_response(
|
||||
message="Validation failed",
|
||||
details=validation_errors
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Processing purchase",
|
||||
user_id=user.id,
|
||||
shop_id=shop_id,
|
||||
character_id=character_id,
|
||||
item_id=item_id,
|
||||
quantity=quantity
|
||||
)
|
||||
|
||||
# Get character (validates ownership)
|
||||
char_service = get_character_service()
|
||||
character = char_service.get_character(character_id, user.id)
|
||||
|
||||
# Process purchase
|
||||
shop_service = get_shop_service()
|
||||
result = shop_service.purchase_item(
|
||||
character=character,
|
||||
shop_id=shop_id,
|
||||
item_id=item_id,
|
||||
quantity=quantity,
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
# Save updated character
|
||||
char_service.update_character(character)
|
||||
|
||||
logger.info(
|
||||
"Purchase completed",
|
||||
user_id=user.id,
|
||||
shop_id=shop_id,
|
||||
character_id=character_id,
|
||||
item_id=item_id,
|
||||
quantity=quantity,
|
||||
total_cost=result["purchase"]["total_cost"]
|
||||
)
|
||||
|
||||
return success_response(result=result)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning(
|
||||
"Character not found for purchase",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id if 'character_id' in locals() else 'unknown',
|
||||
error=str(e)
|
||||
)
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except ShopNotFoundError as e:
|
||||
logger.warning(
|
||||
"Shop not found for purchase",
|
||||
shop_id=shop_id,
|
||||
error=str(e)
|
||||
)
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except ItemNotInShopError as e:
|
||||
logger.warning(
|
||||
"Item not in shop",
|
||||
shop_id=shop_id,
|
||||
item_id=item_id if 'item_id' in locals() else 'unknown',
|
||||
error=str(e)
|
||||
)
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except InsufficientGoldError as e:
|
||||
logger.warning(
|
||||
"Insufficient gold for purchase",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id if 'character_id' in locals() else 'unknown',
|
||||
error=str(e)
|
||||
)
|
||||
return error_response(
|
||||
code="INSUFFICIENT_GOLD",
|
||||
message=str(e),
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to process purchase",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
shop_id=shop_id,
|
||||
error=str(e)
|
||||
)
|
||||
return error_response(
|
||||
code="PURCHASE_ERROR",
|
||||
message="Failed to process purchase",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@shop_bp.route('/api/v1/shop/<shop_id>/sell', methods=['POST'])
|
||||
@require_auth
|
||||
def sell_item(shop_id: str):
|
||||
"""
|
||||
Sell an item back to the shop.
|
||||
|
||||
Args:
|
||||
shop_id: Shop identifier
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"character_id": "char_abc",
|
||||
"item_instance_id": "health_potion_small_abc123",
|
||||
"quantity": 1,
|
||||
"session_id": "optional_session_id"
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Sale successful
|
||||
401: Not authenticated
|
||||
404: Shop, character, or item not found
|
||||
422: Validation error
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"sale": {
|
||||
"item_id": "health_potion_small_abc123",
|
||||
"item_name": "Small Health Potion",
|
||||
"quantity": 1,
|
||||
"total_earned": 12
|
||||
},
|
||||
"character": {
|
||||
"character_id": "char_abc",
|
||||
"gold": 512
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response(
|
||||
message="Request body is required",
|
||||
details={"error": "Request body is required"}
|
||||
)
|
||||
|
||||
character_id = data.get('character_id', '').strip()
|
||||
item_instance_id = data.get('item_instance_id', '').strip()
|
||||
quantity = data.get('quantity', 1)
|
||||
session_id = data.get('session_id', '').strip() or None
|
||||
|
||||
# Validate required fields
|
||||
validation_errors = {}
|
||||
|
||||
if not character_id:
|
||||
validation_errors['character_id'] = "character_id is required"
|
||||
|
||||
if not item_instance_id:
|
||||
validation_errors['item_instance_id'] = "item_instance_id is required"
|
||||
|
||||
if not isinstance(quantity, int) or quantity < 1:
|
||||
validation_errors['quantity'] = "quantity must be a positive integer"
|
||||
|
||||
if validation_errors:
|
||||
return validation_error_response(
|
||||
message="Validation failed",
|
||||
details=validation_errors
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Processing sale",
|
||||
user_id=user.id,
|
||||
shop_id=shop_id,
|
||||
character_id=character_id,
|
||||
item_instance_id=item_instance_id,
|
||||
quantity=quantity
|
||||
)
|
||||
|
||||
# Get character (validates ownership)
|
||||
char_service = get_character_service()
|
||||
character = char_service.get_character(character_id, user.id)
|
||||
|
||||
# Process sale
|
||||
shop_service = get_shop_service()
|
||||
result = shop_service.sell_item(
|
||||
character=character,
|
||||
shop_id=shop_id,
|
||||
item_instance_id=item_instance_id,
|
||||
quantity=quantity,
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
# Save updated character
|
||||
char_service.update_character(character)
|
||||
|
||||
logger.info(
|
||||
"Sale completed",
|
||||
user_id=user.id,
|
||||
shop_id=shop_id,
|
||||
character_id=character_id,
|
||||
item_instance_id=item_instance_id,
|
||||
total_earned=result["sale"]["total_earned"]
|
||||
)
|
||||
|
||||
return success_response(result=result)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning(
|
||||
"Character not found for sale",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id if 'character_id' in locals() else 'unknown',
|
||||
error=str(e)
|
||||
)
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except ShopNotFoundError as e:
|
||||
logger.warning(
|
||||
"Shop not found for sale",
|
||||
shop_id=shop_id,
|
||||
error=str(e)
|
||||
)
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except ItemNotOwnedError as e:
|
||||
logger.warning(
|
||||
"Item not owned for sale",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
character_id=character_id if 'character_id' in locals() else 'unknown',
|
||||
item_instance_id=item_instance_id if 'item_instance_id' in locals() else 'unknown',
|
||||
error=str(e)
|
||||
)
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to process sale",
|
||||
user_id=user.id if 'user' in locals() else 'unknown',
|
||||
shop_id=shop_id,
|
||||
error=str(e)
|
||||
)
|
||||
return error_response(
|
||||
code="SALE_ERROR",
|
||||
message="Failed to process sale",
|
||||
status=500
|
||||
)
|
||||
@@ -76,6 +76,7 @@ class RateLimitTier:
|
||||
ai_calls_per_day: int
|
||||
custom_actions_per_day: int # -1 for unlimited
|
||||
custom_action_char_limit: int
|
||||
max_sessions: int = 1 # Maximum active game sessions allowed
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -86,6 +87,14 @@ class RateLimitingConfig:
|
||||
tiers: Dict[str, RateLimitTier] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionCacheConfig:
|
||||
"""Session cache configuration for reducing Appwrite API calls."""
|
||||
enabled: bool = True
|
||||
ttl_seconds: int = 300 # 5 minutes
|
||||
redis_db: int = 2 # Separate from RQ (db 0) and rate limiting (db 1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthConfig:
|
||||
"""Authentication configuration."""
|
||||
@@ -104,6 +113,7 @@ class AuthConfig:
|
||||
name_min_length: int
|
||||
name_max_length: int
|
||||
email_max_length: int
|
||||
session_cache: SessionCacheConfig = field(default_factory=SessionCacheConfig)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -229,7 +239,11 @@ class Config:
|
||||
tiers=rate_limit_tiers
|
||||
)
|
||||
|
||||
auth_config = AuthConfig(**config_data['auth'])
|
||||
# Parse auth config with nested session_cache
|
||||
auth_data = config_data['auth'].copy()
|
||||
session_cache_data = auth_data.pop('session_cache', {})
|
||||
session_cache_config = SessionCacheConfig(**session_cache_data) if session_cache_data else SessionCacheConfig()
|
||||
auth_config = AuthConfig(**auth_data, session_cache=session_cache_config)
|
||||
session_config = SessionConfig(**config_data['session'])
|
||||
marketplace_config = MarketplaceConfig(**config_data['marketplace'])
|
||||
cors_config = CORSConfig(**config_data['cors'])
|
||||
|
||||
34
api/app/data/abilities/absolute_zero.yaml
Normal file
34
api/app/data/abilities/absolute_zero.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Absolute Zero - Arcanist Cryomancy ultimate
|
||||
# Ultimate freeze all enemies
|
||||
|
||||
ability_id: "absolute_zero"
|
||||
name: "Absolute Zero"
|
||||
description: "Lower the temperature to absolute zero, freezing all enemies solid and dealing massive ice damage"
|
||||
ability_type: "spell"
|
||||
base_power: 90
|
||||
damage_type: "ice"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.7
|
||||
mana_cost: 70
|
||||
cooldown: 6
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "absolute_freeze"
|
||||
name: "Absolute Zero"
|
||||
effect_type: "stun"
|
||||
duration: 2
|
||||
power: 0
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "absolute_zero"
|
||||
- effect_id: "shattered"
|
||||
name: "Shattered"
|
||||
effect_type: "dot"
|
||||
duration: 2
|
||||
power: 20
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "absolute_zero"
|
||||
16
api/app/data/abilities/aimed_shot.yaml
Normal file
16
api/app/data/abilities/aimed_shot.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Aimed Shot - Wildstrider Marksmanship ability
|
||||
# High accuracy ranged attack
|
||||
|
||||
ability_id: "aimed_shot"
|
||||
name: "Aimed Shot"
|
||||
description: "Take careful aim and fire a precise shot at your target"
|
||||
ability_type: "attack"
|
||||
base_power: 18
|
||||
damage_type: "physical"
|
||||
scaling_stat: "dexterity"
|
||||
scaling_factor: 0.6
|
||||
mana_cost: 8
|
||||
cooldown: 1
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
25
api/app/data/abilities/arcane_brilliance.yaml
Normal file
25
api/app/data/abilities/arcane_brilliance.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Arcane Brilliance - Lorekeeper Arcane Weaving ability
|
||||
# Intelligence buff
|
||||
|
||||
ability_id: "arcane_brilliance"
|
||||
name: "Arcane Brilliance"
|
||||
description: "Grant an ally increased intelligence and magical power"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.4
|
||||
mana_cost: 10
|
||||
cooldown: 0
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "arcane_brilliance_buff"
|
||||
name: "Arcane Brilliance"
|
||||
effect_type: "buff"
|
||||
duration: 5
|
||||
power: 10
|
||||
stat_affected: "intelligence"
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "arcane_brilliance"
|
||||
25
api/app/data/abilities/arcane_weakness.yaml
Normal file
25
api/app/data/abilities/arcane_weakness.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Arcane Weakness - Lorekeeper Arcane Weaving ability
|
||||
# Stat debuff on enemy
|
||||
|
||||
ability_id: "arcane_weakness"
|
||||
name: "Arcane Weakness"
|
||||
description: "Expose the weaknesses in your enemy's defenses, reducing their resistances"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: "arcane"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 25
|
||||
cooldown: 3
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "weakened_defenses"
|
||||
name: "Weakened"
|
||||
effect_type: "debuff"
|
||||
duration: 4
|
||||
power: 25
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "arcane_weakness"
|
||||
25
api/app/data/abilities/army_of_the_dead.yaml
Normal file
25
api/app/data/abilities/army_of_the_dead.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Army of the Dead - Necromancer Raise Dead ultimate
|
||||
# Summon undead army
|
||||
|
||||
ability_id: "army_of_the_dead"
|
||||
name: "Army of the Dead"
|
||||
description: "Raise an entire army of undead to overwhelm your enemies"
|
||||
ability_type: "spell"
|
||||
base_power: 80
|
||||
damage_type: "shadow"
|
||||
scaling_stat: "charisma"
|
||||
scaling_factor: 0.7
|
||||
mana_cost: 70
|
||||
cooldown: 8
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "undead_army"
|
||||
name: "Army of the Dead"
|
||||
effect_type: "buff"
|
||||
duration: 5
|
||||
power: 100
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "army_of_the_dead"
|
||||
25
api/app/data/abilities/bestial_wrath.yaml
Normal file
25
api/app/data/abilities/bestial_wrath.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Bestial Wrath - Wildstrider Beast Companion ability
|
||||
# Pet damage buff
|
||||
|
||||
ability_id: "bestial_wrath"
|
||||
name: "Bestial Wrath"
|
||||
description: "Enrage your companion, increasing their damage for 3 turns"
|
||||
ability_type: "skill"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.4
|
||||
mana_cost: 25
|
||||
cooldown: 4
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "enraged_companion"
|
||||
name: "Enraged Companion"
|
||||
effect_type: "buff"
|
||||
duration: 3
|
||||
power: 50
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "bestial_wrath"
|
||||
16
api/app/data/abilities/blessed_sacrifice.yaml
Normal file
16
api/app/data/abilities/blessed_sacrifice.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Blessed Sacrifice - Oathkeeper Redemption ability
|
||||
# Transfer ally wounds to self
|
||||
|
||||
ability_id: "blessed_sacrifice"
|
||||
name: "Blessed Sacrifice"
|
||||
description: "Take an ally's wounds upon yourself, healing them while damaging yourself"
|
||||
ability_type: "spell"
|
||||
base_power: 50
|
||||
damage_type: "holy"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 25
|
||||
cooldown: 4
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
25
api/app/data/abilities/blizzard.yaml
Normal file
25
api/app/data/abilities/blizzard.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Blizzard - Arcanist Cryomancy ability
|
||||
# AoE ice damage with slow
|
||||
|
||||
ability_id: "blizzard"
|
||||
name: "Blizzard"
|
||||
description: "Summon a devastating blizzard that damages and slows all enemies"
|
||||
ability_type: "spell"
|
||||
base_power: 40
|
||||
damage_type: "ice"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.55
|
||||
mana_cost: 32
|
||||
cooldown: 3
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "frostbitten"
|
||||
name: "Frostbitten"
|
||||
effect_type: "debuff"
|
||||
duration: 3
|
||||
power: 30
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "blizzard"
|
||||
16
api/app/data/abilities/cleanse.yaml
Normal file
16
api/app/data/abilities/cleanse.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Cleanse - Oathkeeper Redemption ability
|
||||
# Remove all debuffs
|
||||
|
||||
ability_id: "cleanse"
|
||||
name: "Cleanse"
|
||||
description: "Purify an ally, removing all negative effects"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.3
|
||||
mana_cost: 18
|
||||
cooldown: 3
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
16
api/app/data/abilities/cleave.yaml
Normal file
16
api/app/data/abilities/cleave.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Cleave - Vanguard Weapon Master ability
|
||||
# AoE attack hitting all enemies
|
||||
|
||||
ability_id: "cleave"
|
||||
name: "Cleave"
|
||||
description: "Swing your weapon in a wide arc, hitting all enemies"
|
||||
ability_type: "attack"
|
||||
base_power: 20
|
||||
damage_type: "physical"
|
||||
scaling_stat: "strength"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 15
|
||||
cooldown: 2
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied: []
|
||||
25
api/app/data/abilities/confuse.yaml
Normal file
25
api/app/data/abilities/confuse.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Confuse - Lorekeeper Illusionist ability
|
||||
# Random target attacks
|
||||
|
||||
ability_id: "confuse"
|
||||
name: "Confuse"
|
||||
description: "Confuse your enemy, causing them to attack random targets"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: "arcane"
|
||||
scaling_stat: "charisma"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 12
|
||||
cooldown: 2
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "confused"
|
||||
name: "Confused"
|
||||
effect_type: "debuff"
|
||||
duration: 2
|
||||
power: 50
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "confuse"
|
||||
25
api/app/data/abilities/consecrated_ground.yaml
Normal file
25
api/app/data/abilities/consecrated_ground.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Consecrated Ground - Oathkeeper Aegis of Light ability
|
||||
# Ground buff with damage reduction zone
|
||||
|
||||
ability_id: "consecrated_ground"
|
||||
name: "Consecrated Ground"
|
||||
description: "Consecrate the ground, creating a zone that reduces damage taken by all allies standing within"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.4
|
||||
mana_cost: 30
|
||||
cooldown: 4
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "consecrated_protection"
|
||||
name: "Consecrated"
|
||||
effect_type: "buff"
|
||||
duration: 3
|
||||
power: 25
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "consecrated_ground"
|
||||
25
api/app/data/abilities/consecration.yaml
Normal file
25
api/app/data/abilities/consecration.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Consecration - Luminary Radiant Judgment ability
|
||||
# Ground AoE holy damage
|
||||
|
||||
ability_id: "consecration"
|
||||
name: "Consecration"
|
||||
description: "Consecrate the ground beneath your feet, dealing holy damage to all nearby enemies"
|
||||
ability_type: "spell"
|
||||
base_power: 40
|
||||
damage_type: "holy"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.55
|
||||
mana_cost: 28
|
||||
cooldown: 3
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "consecrated_ground"
|
||||
name: "Consecrated"
|
||||
effect_type: "dot"
|
||||
duration: 3
|
||||
power: 10
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "consecration"
|
||||
16
api/app/data/abilities/coordinated_attack.yaml
Normal file
16
api/app/data/abilities/coordinated_attack.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Coordinated Attack - Wildstrider Beast Companion ability
|
||||
# Attack with pet
|
||||
|
||||
ability_id: "coordinated_attack"
|
||||
name: "Coordinated Attack"
|
||||
description: "Attack in perfect coordination with your companion for bonus damage"
|
||||
ability_type: "skill"
|
||||
base_power: 30
|
||||
damage_type: "physical"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 18
|
||||
cooldown: 2
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
16
api/app/data/abilities/corpse_explosion.yaml
Normal file
16
api/app/data/abilities/corpse_explosion.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Corpse Explosion - Necromancer Raise Dead ability
|
||||
# Detonate corpse/minion AoE
|
||||
|
||||
ability_id: "corpse_explosion"
|
||||
name: "Corpse Explosion"
|
||||
description: "Detonate a corpse or minion, dealing AoE shadow damage to all nearby enemies"
|
||||
ability_type: "spell"
|
||||
base_power: 45
|
||||
damage_type: "shadow"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.55
|
||||
mana_cost: 28
|
||||
cooldown: 3
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied: []
|
||||
16
api/app/data/abilities/coup_de_grace.yaml
Normal file
16
api/app/data/abilities/coup_de_grace.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Coup de Grace - Assassin Blade Specialist ability
|
||||
# Execute low HP targets
|
||||
|
||||
ability_id: "coup_de_grace"
|
||||
name: "Coup de Grace"
|
||||
description: "Deliver the killing blow. Instantly kills targets below 25% HP, otherwise deals massive damage"
|
||||
ability_type: "attack"
|
||||
base_power: 70
|
||||
damage_type: "physical"
|
||||
scaling_stat: "dexterity"
|
||||
scaling_factor: 0.6
|
||||
mana_cost: 40
|
||||
cooldown: 4
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
25
api/app/data/abilities/curse_of_agony.yaml
Normal file
25
api/app/data/abilities/curse_of_agony.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Curse of Agony - Necromancer Dark Affliction ability
|
||||
# Heavy shadow DoT
|
||||
|
||||
ability_id: "curse_of_agony"
|
||||
name: "Curse of Agony"
|
||||
description: "Curse your target with unbearable agony, dealing increasing shadow damage over 5 turns"
|
||||
ability_type: "spell"
|
||||
base_power: 10
|
||||
damage_type: "shadow"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.55
|
||||
mana_cost: 28
|
||||
cooldown: 4
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "agony"
|
||||
name: "Curse of Agony"
|
||||
effect_type: "dot"
|
||||
duration: 5
|
||||
power: 12
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "curse_of_agony"
|
||||
25
api/app/data/abilities/death_mark.yaml
Normal file
25
api/app/data/abilities/death_mark.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Death Mark - Assassin Shadow Dancer ability
|
||||
# Mark target for bonus damage
|
||||
|
||||
ability_id: "death_mark"
|
||||
name: "Death Mark"
|
||||
description: "Mark your target for death. Your next attack deals 200% damage"
|
||||
ability_type: "skill"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "dexterity"
|
||||
scaling_factor: 0.0
|
||||
mana_cost: 30
|
||||
cooldown: 4
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "marked_for_death"
|
||||
name: "Marked for Death"
|
||||
effect_type: "debuff"
|
||||
duration: 2
|
||||
power: 100
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "death_mark"
|
||||
25
api/app/data/abilities/death_pact.yaml
Normal file
25
api/app/data/abilities/death_pact.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Death Pact - Necromancer Raise Dead ability
|
||||
# Sacrifice minion for HP/mana
|
||||
|
||||
ability_id: "death_pact"
|
||||
name: "Death Pact"
|
||||
description: "Sacrifice one of your minions to restore your health and mana"
|
||||
ability_type: "spell"
|
||||
base_power: 50
|
||||
damage_type: null
|
||||
scaling_stat: "charisma"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 0
|
||||
cooldown: 5
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "death_pact_heal"
|
||||
name: "Death Pact"
|
||||
effect_type: "hot"
|
||||
duration: 1
|
||||
power: 40
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "death_pact"
|
||||
25
api/app/data/abilities/divine_aegis.yaml
Normal file
25
api/app/data/abilities/divine_aegis.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Divine Aegis - Oathkeeper Aegis of Light ability
|
||||
# Massive party shield
|
||||
|
||||
ability_id: "divine_aegis"
|
||||
name: "Divine Aegis"
|
||||
description: "Invoke divine protection to create a powerful shield around all allies"
|
||||
ability_type: "spell"
|
||||
base_power: 60
|
||||
damage_type: null
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.6
|
||||
mana_cost: 45
|
||||
cooldown: 5
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "divine_aegis_shield"
|
||||
name: "Divine Aegis"
|
||||
effect_type: "shield"
|
||||
duration: 3
|
||||
power: 50
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "divine_aegis"
|
||||
34
api/app/data/abilities/divine_blessing.yaml
Normal file
34
api/app/data/abilities/divine_blessing.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Divine Blessing - Oathkeeper Redemption ability
|
||||
# Stat buff + HoT
|
||||
|
||||
ability_id: "divine_blessing"
|
||||
name: "Divine Blessing"
|
||||
description: "Bless an ally with divine power, increasing their stats and healing over time"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 35
|
||||
cooldown: 4
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "blessed"
|
||||
name: "Divine Blessing"
|
||||
effect_type: "buff"
|
||||
duration: 4
|
||||
power: 15
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "divine_blessing"
|
||||
- effect_id: "blessed_healing"
|
||||
name: "Blessed Healing"
|
||||
effect_type: "hot"
|
||||
duration: 4
|
||||
power: 10
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "divine_blessing"
|
||||
16
api/app/data/abilities/divine_intervention.yaml
Normal file
16
api/app/data/abilities/divine_intervention.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Divine Intervention - Luminary Divine Protection ability
|
||||
# Full heal + cleanse
|
||||
|
||||
ability_id: "divine_intervention"
|
||||
name: "Divine Intervention"
|
||||
description: "Call upon divine power to fully heal and cleanse an ally of all negative effects"
|
||||
ability_type: "spell"
|
||||
base_power: 80
|
||||
damage_type: "holy"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.6
|
||||
mana_cost: 45
|
||||
cooldown: 5
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
25
api/app/data/abilities/divine_storm.yaml
Normal file
25
api/app/data/abilities/divine_storm.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Divine Storm - Luminary Radiant Judgment ultimate
|
||||
# Ultimate AoE holy + stun all
|
||||
|
||||
ability_id: "divine_storm"
|
||||
name: "Divine Storm"
|
||||
description: "Unleash the full fury of the divine, dealing massive holy damage to all enemies and stunning them"
|
||||
ability_type: "spell"
|
||||
base_power: 95
|
||||
damage_type: "holy"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.7
|
||||
mana_cost: 60
|
||||
cooldown: 5
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "divine_judgment"
|
||||
name: "Divine Judgment"
|
||||
effect_type: "stun"
|
||||
duration: 1
|
||||
power: 0
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "divine_storm"
|
||||
25
api/app/data/abilities/drain_life.yaml
Normal file
25
api/app/data/abilities/drain_life.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Drain Life - Necromancer Dark Affliction ability
|
||||
# Shadow damage + self-heal
|
||||
|
||||
ability_id: "drain_life"
|
||||
name: "Drain Life"
|
||||
description: "Drain the life force from your enemy, dealing shadow damage and healing yourself"
|
||||
ability_type: "spell"
|
||||
base_power: 18
|
||||
damage_type: "shadow"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 12
|
||||
cooldown: 1
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "life_drain"
|
||||
name: "Life Drained"
|
||||
effect_type: "hot"
|
||||
duration: 1
|
||||
power: 9
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "drain_life"
|
||||
34
api/app/data/abilities/epidemic.yaml
Normal file
34
api/app/data/abilities/epidemic.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Epidemic - Necromancer Dark Affliction ultimate
|
||||
# Ultimate multi-DoT all enemies
|
||||
|
||||
ability_id: "epidemic"
|
||||
name: "Epidemic"
|
||||
description: "Unleash a devastating epidemic that afflicts all enemies with multiple diseases"
|
||||
ability_type: "spell"
|
||||
base_power: 60
|
||||
damage_type: "shadow"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.7
|
||||
mana_cost: 60
|
||||
cooldown: 6
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "epidemic_plague"
|
||||
name: "Epidemic"
|
||||
effect_type: "dot"
|
||||
duration: 5
|
||||
power: 20
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "epidemic"
|
||||
- effect_id: "weakened"
|
||||
name: "Weakened"
|
||||
effect_type: "debuff"
|
||||
duration: 5
|
||||
power: 25
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "epidemic"
|
||||
16
api/app/data/abilities/execute.yaml
Normal file
16
api/app/data/abilities/execute.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Execute - Vanguard Weapon Master ability
|
||||
# Bonus damage to low HP targets
|
||||
|
||||
ability_id: "execute"
|
||||
name: "Execute"
|
||||
description: "Finish off weakened enemies. Deals bonus damage to targets below 30% HP"
|
||||
ability_type: "attack"
|
||||
base_power: 60
|
||||
damage_type: "physical"
|
||||
scaling_stat: "strength"
|
||||
scaling_factor: 0.6
|
||||
mana_cost: 40
|
||||
cooldown: 3
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
25
api/app/data/abilities/explosive_shot.yaml
Normal file
25
api/app/data/abilities/explosive_shot.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Explosive Shot - Wildstrider Marksmanship ability
|
||||
# Impact AoE damage
|
||||
|
||||
ability_id: "explosive_shot"
|
||||
name: "Explosive Shot"
|
||||
description: "Fire an explosive arrow that detonates on impact, dealing AoE damage"
|
||||
ability_type: "attack"
|
||||
base_power: 55
|
||||
damage_type: "fire"
|
||||
scaling_stat: "dexterity"
|
||||
scaling_factor: 0.55
|
||||
mana_cost: 38
|
||||
cooldown: 3
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "burning_shrapnel"
|
||||
name: "Burning Shrapnel"
|
||||
effect_type: "dot"
|
||||
duration: 2
|
||||
power: 8
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "explosive_shot"
|
||||
25
api/app/data/abilities/firestorm.yaml
Normal file
25
api/app/data/abilities/firestorm.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Firestorm - Arcanist Pyromancy ability
|
||||
# Massive AoE fire damage
|
||||
|
||||
ability_id: "firestorm"
|
||||
name: "Firestorm"
|
||||
description: "Call down a storm of fire from the heavens, devastating all enemies"
|
||||
ability_type: "spell"
|
||||
base_power: 55
|
||||
damage_type: "fire"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.6
|
||||
mana_cost: 45
|
||||
cooldown: 4
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "scorched"
|
||||
name: "Scorched"
|
||||
effect_type: "dot"
|
||||
duration: 2
|
||||
power: 12
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 3
|
||||
source: "firestorm"
|
||||
16
api/app/data/abilities/flame_burst.yaml
Normal file
16
api/app/data/abilities/flame_burst.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Flame Burst - Arcanist Pyromancy ability
|
||||
# AoE fire burst centered on caster
|
||||
|
||||
ability_id: "flame_burst"
|
||||
name: "Flame Burst"
|
||||
description: "Release a burst of flames around you, scorching all nearby enemies"
|
||||
ability_type: "spell"
|
||||
base_power: 25
|
||||
damage_type: "fire"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 18
|
||||
cooldown: 2
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied: []
|
||||
25
api/app/data/abilities/frozen_orb.yaml
Normal file
25
api/app/data/abilities/frozen_orb.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Frozen Orb - Arcanist Cryomancy ability
|
||||
# AoE freeze with damage
|
||||
|
||||
ability_id: "frozen_orb"
|
||||
name: "Frozen Orb"
|
||||
description: "Launch a swirling orb of frost that freezes enemies in its path"
|
||||
ability_type: "spell"
|
||||
base_power: 28
|
||||
damage_type: "ice"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 20
|
||||
cooldown: 3
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "frozen"
|
||||
name: "Frozen"
|
||||
effect_type: "stun"
|
||||
duration: 1
|
||||
power: 0
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "frozen_orb"
|
||||
25
api/app/data/abilities/glacial_spike.yaml
Normal file
25
api/app/data/abilities/glacial_spike.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Glacial Spike - Arcanist Cryomancy ability
|
||||
# Heavy single target with freeze
|
||||
|
||||
ability_id: "glacial_spike"
|
||||
name: "Glacial Spike"
|
||||
description: "Impale your target with a massive spike of ice, dealing heavy damage and freezing them"
|
||||
ability_type: "spell"
|
||||
base_power: 60
|
||||
damage_type: "ice"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.6
|
||||
mana_cost: 40
|
||||
cooldown: 3
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "deep_freeze"
|
||||
name: "Deep Freeze"
|
||||
effect_type: "stun"
|
||||
duration: 2
|
||||
power: 0
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "glacial_spike"
|
||||
25
api/app/data/abilities/guardian_angel.yaml
Normal file
25
api/app/data/abilities/guardian_angel.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Guardian Angel - Luminary Divine Protection ability
|
||||
# Death prevention buff
|
||||
|
||||
ability_id: "guardian_angel"
|
||||
name: "Guardian Angel"
|
||||
description: "Bless an ally with divine protection that prevents death once"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.4
|
||||
mana_cost: 35
|
||||
cooldown: 6
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "guardian_angel_buff"
|
||||
name: "Guardian Angel"
|
||||
effect_type: "buff"
|
||||
duration: 5
|
||||
power: 1
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "guardian_angel"
|
||||
25
api/app/data/abilities/hammer_of_justice.yaml
Normal file
25
api/app/data/abilities/hammer_of_justice.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Hammer of Justice - Luminary Radiant Judgment ability
|
||||
# Holy damage + stun
|
||||
|
||||
ability_id: "hammer_of_justice"
|
||||
name: "Hammer of Justice"
|
||||
description: "Smash your enemy with a divine hammer, dealing holy damage and stunning them"
|
||||
ability_type: "spell"
|
||||
base_power: 55
|
||||
damage_type: "holy"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.6
|
||||
mana_cost: 38
|
||||
cooldown: 4
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "justice_stun"
|
||||
name: "Judged"
|
||||
effect_type: "stun"
|
||||
duration: 2
|
||||
power: 0
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "hammer_of_justice"
|
||||
25
api/app/data/abilities/haste.yaml
Normal file
25
api/app/data/abilities/haste.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Haste - Lorekeeper Arcane Weaving ability
|
||||
# Grant extra action
|
||||
|
||||
ability_id: "haste"
|
||||
name: "Haste"
|
||||
description: "Speed up time around an ally, granting them an extra action"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.4
|
||||
mana_cost: 20
|
||||
cooldown: 4
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "hasted"
|
||||
name: "Hasted"
|
||||
effect_type: "buff"
|
||||
duration: 2
|
||||
power: 50
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "haste"
|
||||
@@ -7,7 +7,7 @@ description: "Channel divine energy to restore an ally's health"
|
||||
ability_type: "spell"
|
||||
base_power: 25
|
||||
damage_type: "holy"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 10
|
||||
cooldown: 0
|
||||
|
||||
25
api/app/data/abilities/holy_fire.yaml
Normal file
25
api/app/data/abilities/holy_fire.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Holy Fire - Luminary Radiant Judgment ability
|
||||
# Holy DoT with reduced healing
|
||||
|
||||
ability_id: "holy_fire"
|
||||
name: "Holy Fire"
|
||||
description: "Engulf your enemy in holy flames that burn over time and reduce their healing"
|
||||
ability_type: "spell"
|
||||
base_power: 25
|
||||
damage_type: "holy"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 18
|
||||
cooldown: 2
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "holy_burning"
|
||||
name: "Holy Fire"
|
||||
effect_type: "dot"
|
||||
duration: 3
|
||||
power: 8
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "holy_fire"
|
||||
25
api/app/data/abilities/holy_shield.yaml
Normal file
25
api/app/data/abilities/holy_shield.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Holy Shield - Luminary Divine Protection ability
|
||||
# Grant damage absorb shield
|
||||
|
||||
ability_id: "holy_shield"
|
||||
name: "Holy Shield"
|
||||
description: "Grant an ally a protective barrier of holy light that absorbs damage"
|
||||
ability_type: "spell"
|
||||
base_power: 30
|
||||
damage_type: null
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 15
|
||||
cooldown: 2
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "holy_shield_barrier"
|
||||
name: "Holy Shield"
|
||||
effect_type: "shield"
|
||||
duration: 3
|
||||
power: 30
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "holy_shield"
|
||||
25
api/app/data/abilities/ice_shard.yaml
Normal file
25
api/app/data/abilities/ice_shard.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Ice Shard - Arcanist Cryomancy ability
|
||||
# Single target ice damage with slow
|
||||
|
||||
ability_id: "ice_shard"
|
||||
name: "Ice Shard"
|
||||
description: "Hurl a shard of ice at your enemy, dealing frost damage and slowing them"
|
||||
ability_type: "spell"
|
||||
base_power: 20
|
||||
damage_type: "ice"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 10
|
||||
cooldown: 0
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "chilled"
|
||||
name: "Chilled"
|
||||
effect_type: "debuff"
|
||||
duration: 2
|
||||
power: 20
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 3
|
||||
source: "ice_shard"
|
||||
25
api/app/data/abilities/inferno.yaml
Normal file
25
api/app/data/abilities/inferno.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Inferno - Arcanist Pyromancy ability
|
||||
# AoE fire DoT
|
||||
|
||||
ability_id: "inferno"
|
||||
name: "Inferno"
|
||||
description: "Summon a raging inferno that burns all enemies for 3 turns"
|
||||
ability_type: "spell"
|
||||
base_power: 35
|
||||
damage_type: "fire"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.55
|
||||
mana_cost: 30
|
||||
cooldown: 3
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "inferno_burn"
|
||||
name: "Inferno Flames"
|
||||
effect_type: "dot"
|
||||
duration: 3
|
||||
power: 10
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 3
|
||||
source: "inferno"
|
||||
34
api/app/data/abilities/last_stand.yaml
Normal file
34
api/app/data/abilities/last_stand.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Last Stand - Oathkeeper Aegis of Light ultimate
|
||||
# Invulnerable + taunt all
|
||||
|
||||
ability_id: "last_stand"
|
||||
name: "Last Stand"
|
||||
description: "Make your final stand, becoming invulnerable and forcing all enemies to attack you"
|
||||
ability_type: "skill"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "constitution"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 55
|
||||
cooldown: 8
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "invulnerable"
|
||||
name: "Invulnerable"
|
||||
effect_type: "buff"
|
||||
duration: 3
|
||||
power: 100
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "last_stand"
|
||||
- effect_id: "ultimate_taunt"
|
||||
name: "Challenged"
|
||||
effect_type: "debuff"
|
||||
duration: 3
|
||||
power: 100
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "last_stand"
|
||||
25
api/app/data/abilities/lay_on_hands.yaml
Normal file
25
api/app/data/abilities/lay_on_hands.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Lay on Hands - Oathkeeper Redemption ability
|
||||
# Touch heal
|
||||
|
||||
ability_id: "lay_on_hands"
|
||||
name: "Lay on Hands"
|
||||
description: "Place your hands upon an ally to heal their wounds"
|
||||
ability_type: "spell"
|
||||
base_power: 25
|
||||
damage_type: "holy"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 12
|
||||
cooldown: 0
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "gentle_healing"
|
||||
name: "Soothed"
|
||||
effect_type: "hot"
|
||||
duration: 2
|
||||
power: 5
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "lay_on_hands"
|
||||
25
api/app/data/abilities/mass_confusion.yaml
Normal file
25
api/app/data/abilities/mass_confusion.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Mass Confusion - Lorekeeper Illusionist ability
|
||||
# AoE confusion
|
||||
|
||||
ability_id: "mass_confusion"
|
||||
name: "Mass Confusion"
|
||||
description: "Unleash a wave of illusions that confuses all enemies"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: "arcane"
|
||||
scaling_stat: "charisma"
|
||||
scaling_factor: 0.55
|
||||
mana_cost: 35
|
||||
cooldown: 4
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "mass_confused"
|
||||
name: "Bewildered"
|
||||
effect_type: "debuff"
|
||||
duration: 3
|
||||
power: 40
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "mass_confusion"
|
||||
25
api/app/data/abilities/mass_domination.yaml
Normal file
25
api/app/data/abilities/mass_domination.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Mass Domination - Lorekeeper Illusionist ultimate
|
||||
# Mind control all enemies
|
||||
|
||||
ability_id: "mass_domination"
|
||||
name: "Mass Domination"
|
||||
description: "Dominate the minds of all enemies, forcing them to attack each other"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: "arcane"
|
||||
scaling_stat: "charisma"
|
||||
scaling_factor: 0.7
|
||||
mana_cost: 75
|
||||
cooldown: 8
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "dominated"
|
||||
name: "Dominated"
|
||||
effect_type: "debuff"
|
||||
duration: 3
|
||||
power: 100
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "mass_domination"
|
||||
25
api/app/data/abilities/mass_enhancement.yaml
Normal file
25
api/app/data/abilities/mass_enhancement.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Mass Enhancement - Lorekeeper Arcane Weaving ability
|
||||
# AoE stat buff
|
||||
|
||||
ability_id: "mass_enhancement"
|
||||
name: "Mass Enhancement"
|
||||
description: "Enhance all allies with arcane power, increasing all their stats"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 32
|
||||
cooldown: 4
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "enhanced"
|
||||
name: "Enhanced"
|
||||
effect_type: "buff"
|
||||
duration: 4
|
||||
power: 15
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "mass_enhancement"
|
||||
25
api/app/data/abilities/mass_heal.yaml
Normal file
25
api/app/data/abilities/mass_heal.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Mass Heal - Luminary Divine Protection ability
|
||||
# AoE healing
|
||||
|
||||
ability_id: "mass_heal"
|
||||
name: "Mass Heal"
|
||||
description: "Channel divine energy to heal all allies"
|
||||
ability_type: "spell"
|
||||
base_power: 35
|
||||
damage_type: "holy"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.55
|
||||
mana_cost: 30
|
||||
cooldown: 3
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "mass_regen"
|
||||
name: "Divine Healing"
|
||||
effect_type: "hot"
|
||||
duration: 2
|
||||
power: 8
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "mass_heal"
|
||||
25
api/app/data/abilities/mesmerize.yaml
Normal file
25
api/app/data/abilities/mesmerize.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Mesmerize - Lorekeeper Illusionist ability
|
||||
# Stun for 2 turns
|
||||
|
||||
ability_id: "mesmerize"
|
||||
name: "Mesmerize"
|
||||
description: "Mesmerize your target with illusions, stunning them for 2 turns"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: "arcane"
|
||||
scaling_stat: "charisma"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 22
|
||||
cooldown: 4
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "mesmerized"
|
||||
name: "Mesmerized"
|
||||
effect_type: "stun"
|
||||
duration: 2
|
||||
power: 0
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "mesmerize"
|
||||
25
api/app/data/abilities/miracle.yaml
Normal file
25
api/app/data/abilities/miracle.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Miracle - Oathkeeper Redemption ultimate
|
||||
# Full party heal + cleanse all
|
||||
|
||||
ability_id: "miracle"
|
||||
name: "Miracle"
|
||||
description: "Perform a divine miracle that fully heals all allies and cleanses all negative effects"
|
||||
ability_type: "spell"
|
||||
base_power: 100
|
||||
damage_type: "holy"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.7
|
||||
mana_cost: 70
|
||||
cooldown: 8
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "miraculous_healing"
|
||||
name: "Miraculous"
|
||||
effect_type: "hot"
|
||||
duration: 3
|
||||
power: 20
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "miracle"
|
||||
25
api/app/data/abilities/mirror_image.yaml
Normal file
25
api/app/data/abilities/mirror_image.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Mirror Image - Lorekeeper Illusionist ability
|
||||
# Summon decoys
|
||||
|
||||
ability_id: "mirror_image"
|
||||
name: "Mirror Image"
|
||||
description: "Create illusory copies of yourself that absorb enemy attacks"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "charisma"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 28
|
||||
cooldown: 5
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "mirror_images"
|
||||
name: "Mirror Images"
|
||||
effect_type: "shield"
|
||||
duration: 4
|
||||
power: 40
|
||||
stat_affected: null
|
||||
stacks: 3
|
||||
max_stacks: 3
|
||||
source: "mirror_image"
|
||||
16
api/app/data/abilities/multishot.yaml
Normal file
16
api/app/data/abilities/multishot.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Multishot - Wildstrider Marksmanship ability
|
||||
# Hit multiple targets
|
||||
|
||||
ability_id: "multishot"
|
||||
name: "Multishot"
|
||||
description: "Fire multiple arrows in quick succession, hitting up to 3 targets"
|
||||
ability_type: "attack"
|
||||
base_power: 22
|
||||
damage_type: "physical"
|
||||
scaling_stat: "dexterity"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 18
|
||||
cooldown: 2
|
||||
is_aoe: true
|
||||
target_count: 3
|
||||
effects_applied: []
|
||||
25
api/app/data/abilities/phantasmal_killer.yaml
Normal file
25
api/app/data/abilities/phantasmal_killer.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Phantasmal Killer - Lorekeeper Illusionist ability
|
||||
# Psychic damage + fear
|
||||
|
||||
ability_id: "phantasmal_killer"
|
||||
name: "Phantasmal Killer"
|
||||
description: "Conjure a nightmarish illusion that terrifies and damages your target"
|
||||
ability_type: "spell"
|
||||
base_power: 55
|
||||
damage_type: "arcane"
|
||||
scaling_stat: "charisma"
|
||||
scaling_factor: 0.6
|
||||
mana_cost: 42
|
||||
cooldown: 4
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "terrified"
|
||||
name: "Terrified"
|
||||
effect_type: "debuff"
|
||||
duration: 3
|
||||
power: 30
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "phantasmal_killer"
|
||||
25
api/app/data/abilities/piercing_shot.yaml
Normal file
25
api/app/data/abilities/piercing_shot.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Piercing Shot - Wildstrider Marksmanship ability
|
||||
# Line AoE that pierces through enemies
|
||||
|
||||
ability_id: "piercing_shot"
|
||||
name: "Piercing Shot"
|
||||
description: "Fire a powerful arrow that pierces through all enemies in a line"
|
||||
ability_type: "attack"
|
||||
base_power: 40
|
||||
damage_type: "physical"
|
||||
scaling_stat: "dexterity"
|
||||
scaling_factor: 0.55
|
||||
mana_cost: 28
|
||||
cooldown: 3
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "armor_pierced"
|
||||
name: "Armor Pierced"
|
||||
effect_type: "debuff"
|
||||
duration: 2
|
||||
power: 15
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "piercing_shot"
|
||||
25
api/app/data/abilities/plague.yaml
Normal file
25
api/app/data/abilities/plague.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Plague - Necromancer Dark Affliction ability
|
||||
# Spreading poison DoT
|
||||
|
||||
ability_id: "plague"
|
||||
name: "Plague"
|
||||
description: "Infect your target with a virulent plague that spreads to nearby enemies"
|
||||
ability_type: "spell"
|
||||
base_power: 15
|
||||
damage_type: "poison"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 20
|
||||
cooldown: 3
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "plagued"
|
||||
name: "Plagued"
|
||||
effect_type: "dot"
|
||||
duration: 4
|
||||
power: 8
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 3
|
||||
source: "plague"
|
||||
16
api/app/data/abilities/power_strike.yaml
Normal file
16
api/app/data/abilities/power_strike.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Power Strike - Vanguard Weapon Master ability
|
||||
# Heavy attack dealing 150% weapon damage
|
||||
|
||||
ability_id: "power_strike"
|
||||
name: "Power Strike"
|
||||
description: "A heavy attack that deals 150% weapon damage"
|
||||
ability_type: "attack"
|
||||
base_power: 15
|
||||
damage_type: "physical"
|
||||
scaling_stat: "strength"
|
||||
scaling_factor: 0.6
|
||||
mana_cost: 8
|
||||
cooldown: 1
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
16
api/app/data/abilities/precise_strike.yaml
Normal file
16
api/app/data/abilities/precise_strike.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Precise Strike - Assassin Blade Specialist ability
|
||||
# High crit chance attack
|
||||
|
||||
ability_id: "precise_strike"
|
||||
name: "Precise Strike"
|
||||
description: "A calculated strike aimed at vital points with increased critical chance"
|
||||
ability_type: "attack"
|
||||
base_power: 15
|
||||
damage_type: "physical"
|
||||
scaling_stat: "dexterity"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 8
|
||||
cooldown: 1
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
16
api/app/data/abilities/primal_fury.yaml
Normal file
16
api/app/data/abilities/primal_fury.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Primal Fury - Wildstrider Beast Companion ability
|
||||
# Pet AoE attack
|
||||
|
||||
ability_id: "primal_fury"
|
||||
name: "Primal Fury"
|
||||
description: "Command your companion to unleash a devastating attack on all enemies"
|
||||
ability_type: "skill"
|
||||
base_power: 50
|
||||
damage_type: "physical"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.55
|
||||
mana_cost: 35
|
||||
cooldown: 4
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied: []
|
||||
25
api/app/data/abilities/rain_of_arrows.yaml
Normal file
25
api/app/data/abilities/rain_of_arrows.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Rain of Arrows - Wildstrider Marksmanship ultimate
|
||||
# Ultimate AoE attack
|
||||
|
||||
ability_id: "rain_of_arrows"
|
||||
name: "Rain of Arrows"
|
||||
description: "Call down a devastating rain of arrows upon all enemies"
|
||||
ability_type: "attack"
|
||||
base_power: 85
|
||||
damage_type: "physical"
|
||||
scaling_stat: "dexterity"
|
||||
scaling_factor: 0.7
|
||||
mana_cost: 55
|
||||
cooldown: 5
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "pinned"
|
||||
name: "Pinned"
|
||||
effect_type: "debuff"
|
||||
duration: 1
|
||||
power: 50
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "rain_of_arrows"
|
||||
25
api/app/data/abilities/raise_ghoul.yaml
Normal file
25
api/app/data/abilities/raise_ghoul.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Raise Ghoul - Necromancer Raise Dead ability
|
||||
# Summon stronger ghoul
|
||||
|
||||
ability_id: "raise_ghoul"
|
||||
name: "Raise Ghoul"
|
||||
description: "Raise a powerful ghoul from the dead to serve you"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "charisma"
|
||||
scaling_factor: 0.55
|
||||
mana_cost: 22
|
||||
cooldown: 0
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "ghoul_minion"
|
||||
name: "Ghoul"
|
||||
effect_type: "buff"
|
||||
duration: 99
|
||||
power: 35
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "raise_ghoul"
|
||||
34
api/app/data/abilities/reality_shift.yaml
Normal file
34
api/app/data/abilities/reality_shift.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Reality Shift - Lorekeeper Arcane Weaving ultimate
|
||||
# Massive buff allies + debuff enemies
|
||||
|
||||
ability_id: "reality_shift"
|
||||
name: "Reality Shift"
|
||||
description: "Alter reality itself, greatly empowering allies while weakening all enemies"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: "arcane"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.7
|
||||
mana_cost: 70
|
||||
cooldown: 8
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "reality_empowered"
|
||||
name: "Reality Empowered"
|
||||
effect_type: "buff"
|
||||
duration: 5
|
||||
power: 30
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "reality_shift"
|
||||
- effect_id: "reality_weakened"
|
||||
name: "Reality Distorted"
|
||||
effect_type: "debuff"
|
||||
duration: 5
|
||||
power: 30
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "reality_shift"
|
||||
25
api/app/data/abilities/rending_blow.yaml
Normal file
25
api/app/data/abilities/rending_blow.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Rending Blow - Vanguard Weapon Master ability
|
||||
# Attack with bleed DoT
|
||||
|
||||
ability_id: "rending_blow"
|
||||
name: "Rending Blow"
|
||||
description: "Strike with such force that your enemy bleeds for 3 turns"
|
||||
ability_type: "attack"
|
||||
base_power: 35
|
||||
damage_type: "physical"
|
||||
scaling_stat: "strength"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 25
|
||||
cooldown: 2
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "bleed"
|
||||
name: "Bleeding"
|
||||
effect_type: "dot"
|
||||
duration: 3
|
||||
power: 8
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 3
|
||||
source: "rending_blow"
|
||||
16
api/app/data/abilities/resurrection.yaml
Normal file
16
api/app/data/abilities/resurrection.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Resurrection - Luminary Divine Protection ultimate
|
||||
# Revive fallen ally
|
||||
|
||||
ability_id: "resurrection"
|
||||
name: "Resurrection"
|
||||
description: "Call upon the divine to bring a fallen ally back to life with 50% HP and mana"
|
||||
ability_type: "spell"
|
||||
base_power: 50
|
||||
damage_type: "holy"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 60
|
||||
cooldown: 8
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
16
api/app/data/abilities/riposte.yaml
Normal file
16
api/app/data/abilities/riposte.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Riposte - Vanguard Shield Bearer ability
|
||||
# Counter attack after blocking
|
||||
|
||||
ability_id: "riposte"
|
||||
name: "Riposte"
|
||||
description: "After blocking an attack, counter with a swift strike"
|
||||
ability_type: "skill"
|
||||
base_power: 30
|
||||
damage_type: "physical"
|
||||
scaling_stat: "strength"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 20
|
||||
cooldown: 2
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
25
api/app/data/abilities/shadow_assault.yaml
Normal file
25
api/app/data/abilities/shadow_assault.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Shadow Assault - Assassin Shadow Dancer ultimate
|
||||
# AoE guaranteed crits
|
||||
|
||||
ability_id: "shadow_assault"
|
||||
name: "Shadow Assault"
|
||||
description: "Become one with the shadows and strike all enemies with guaranteed critical hits"
|
||||
ability_type: "skill"
|
||||
base_power: 80
|
||||
damage_type: "physical"
|
||||
scaling_stat: "dexterity"
|
||||
scaling_factor: 0.7
|
||||
mana_cost: 55
|
||||
cooldown: 6
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "shadow_crit"
|
||||
name: "Shadow Strike"
|
||||
effect_type: "buff"
|
||||
duration: 1
|
||||
power: 100
|
||||
stat_affected: "crit_chance"
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "shadow_assault"
|
||||
16
api/app/data/abilities/shadowstep.yaml
Normal file
16
api/app/data/abilities/shadowstep.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Shadowstep - Assassin Shadow Dancer ability
|
||||
# Teleport and backstab
|
||||
|
||||
ability_id: "shadowstep"
|
||||
name: "Shadowstep"
|
||||
description: "Vanish into the shadows and reappear behind your target, striking from behind"
|
||||
ability_type: "skill"
|
||||
base_power: 18
|
||||
damage_type: "physical"
|
||||
scaling_stat: "dexterity"
|
||||
scaling_factor: 0.6
|
||||
mana_cost: 10
|
||||
cooldown: 2
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
25
api/app/data/abilities/shield_of_faith.yaml
Normal file
25
api/app/data/abilities/shield_of_faith.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Shield of Faith - Oathkeeper Aegis of Light ability
|
||||
# Shield for self and allies
|
||||
|
||||
ability_id: "shield_of_faith"
|
||||
name: "Shield of Faith"
|
||||
description: "Create a shield of divine faith that protects you and nearby allies"
|
||||
ability_type: "spell"
|
||||
base_power: 35
|
||||
damage_type: null
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 20
|
||||
cooldown: 3
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "faith_shield"
|
||||
name: "Shield of Faith"
|
||||
effect_type: "shield"
|
||||
duration: 3
|
||||
power: 25
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "shield_of_faith"
|
||||
25
api/app/data/abilities/shield_wall.yaml
Normal file
25
api/app/data/abilities/shield_wall.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Shield Wall - Vanguard Shield Bearer ability
|
||||
# Defensive buff reducing damage
|
||||
|
||||
ability_id: "shield_wall"
|
||||
name: "Shield Wall"
|
||||
description: "Raise your shield to block incoming attacks, reducing damage by 50% for 3 turns"
|
||||
ability_type: "defend"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "constitution"
|
||||
scaling_factor: 0.3
|
||||
mana_cost: 12
|
||||
cooldown: 4
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "shield_wall_buff"
|
||||
name: "Shield Wall"
|
||||
effect_type: "buff"
|
||||
duration: 3
|
||||
power: 50
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "shield_wall"
|
||||
16
api/app/data/abilities/smite.yaml
Normal file
16
api/app/data/abilities/smite.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Smite - Luminary Radiant Judgment ability
|
||||
# Holy damage attack
|
||||
|
||||
ability_id: "smite"
|
||||
name: "Smite"
|
||||
description: "Call down holy light to smite your enemies"
|
||||
ability_type: "spell"
|
||||
base_power: 20
|
||||
damage_type: "holy"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 10
|
||||
cooldown: 0
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
25
api/app/data/abilities/smoke_bomb.yaml
Normal file
25
api/app/data/abilities/smoke_bomb.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Smoke Bomb - Assassin Shadow Dancer ability
|
||||
# Evasion buff
|
||||
|
||||
ability_id: "smoke_bomb"
|
||||
name: "Smoke Bomb"
|
||||
description: "Throw a smoke bomb, making yourself untargetable for 1 turn"
|
||||
ability_type: "skill"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "dexterity"
|
||||
scaling_factor: 0.3
|
||||
mana_cost: 15
|
||||
cooldown: 4
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "smoke_screen"
|
||||
name: "Smoke Screen"
|
||||
effect_type: "buff"
|
||||
duration: 1
|
||||
power: 100
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "smoke_bomb"
|
||||
34
api/app/data/abilities/soul_rot.yaml
Normal file
34
api/app/data/abilities/soul_rot.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Soul Rot - Necromancer Dark Affliction ability
|
||||
# DoT + reduced healing on target
|
||||
|
||||
ability_id: "soul_rot"
|
||||
name: "Soul Rot"
|
||||
description: "Rot your target's soul, dealing shadow damage over time and reducing their healing received"
|
||||
ability_type: "spell"
|
||||
base_power: 45
|
||||
damage_type: "shadow"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.6
|
||||
mana_cost: 38
|
||||
cooldown: 4
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "rotting_soul"
|
||||
name: "Soul Rot"
|
||||
effect_type: "dot"
|
||||
duration: 4
|
||||
power: 15
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "soul_rot"
|
||||
- effect_id: "healing_reduction"
|
||||
name: "Corrupted"
|
||||
effect_type: "debuff"
|
||||
duration: 4
|
||||
power: 50
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "soul_rot"
|
||||
25
api/app/data/abilities/stampede.yaml
Normal file
25
api/app/data/abilities/stampede.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Stampede - Wildstrider Beast Companion ultimate
|
||||
# Summon beast horde AoE
|
||||
|
||||
ability_id: "stampede"
|
||||
name: "Stampede"
|
||||
description: "Call upon the spirits of the wild to summon a stampede of beasts that tramples all enemies"
|
||||
ability_type: "skill"
|
||||
base_power: 90
|
||||
damage_type: "physical"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.7
|
||||
mana_cost: 60
|
||||
cooldown: 6
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "trampled"
|
||||
name: "Trampled"
|
||||
effect_type: "debuff"
|
||||
duration: 2
|
||||
power: 30
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "stampede"
|
||||
25
api/app/data/abilities/summon_abomination.yaml
Normal file
25
api/app/data/abilities/summon_abomination.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Summon Abomination - Necromancer Raise Dead ability
|
||||
# Summon powerful abomination
|
||||
|
||||
ability_id: "summon_abomination"
|
||||
name: "Summon Abomination"
|
||||
description: "Stitch together corpses to create a powerful abomination"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "charisma"
|
||||
scaling_factor: 0.6
|
||||
mana_cost: 45
|
||||
cooldown: 0
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "abomination_minion"
|
||||
name: "Abomination"
|
||||
effect_type: "buff"
|
||||
duration: 99
|
||||
power: 60
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "summon_abomination"
|
||||
25
api/app/data/abilities/summon_companion.yaml
Normal file
25
api/app/data/abilities/summon_companion.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Summon Companion - Wildstrider Beast Companion ability
|
||||
# Summon animal pet
|
||||
|
||||
ability_id: "summon_companion"
|
||||
name: "Summon Companion"
|
||||
description: "Call your loyal animal companion to fight by your side"
|
||||
ability_type: "skill"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 15
|
||||
cooldown: 0
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "companion_active"
|
||||
name: "Animal Companion"
|
||||
effect_type: "buff"
|
||||
duration: 99
|
||||
power: 20
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "summon_companion"
|
||||
25
api/app/data/abilities/summon_skeleton.yaml
Normal file
25
api/app/data/abilities/summon_skeleton.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Summon Skeleton - Necromancer Raise Dead ability
|
||||
# Summon skeleton warrior
|
||||
|
||||
ability_id: "summon_skeleton"
|
||||
name: "Summon Skeleton"
|
||||
description: "Raise a skeleton warrior from the dead to fight for you"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "charisma"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 15
|
||||
cooldown: 0
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "skeleton_minion"
|
||||
name: "Skeleton Warrior"
|
||||
effect_type: "buff"
|
||||
duration: 99
|
||||
power: 20
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "summon_skeleton"
|
||||
25
api/app/data/abilities/sun_burst.yaml
Normal file
25
api/app/data/abilities/sun_burst.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Sun Burst - Arcanist Pyromancy ultimate
|
||||
# Ultimate fire nuke
|
||||
|
||||
ability_id: "sun_burst"
|
||||
name: "Sun Burst"
|
||||
description: "Channel the power of the sun to unleash a devastating explosion of fire on all enemies"
|
||||
ability_type: "spell"
|
||||
base_power: 100
|
||||
damage_type: "fire"
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.7
|
||||
mana_cost: 65
|
||||
cooldown: 5
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "incinerated"
|
||||
name: "Incinerated"
|
||||
effect_type: "dot"
|
||||
duration: 3
|
||||
power: 15
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "sun_burst"
|
||||
25
api/app/data/abilities/taunt.yaml
Normal file
25
api/app/data/abilities/taunt.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Taunt - Oathkeeper Aegis of Light ability
|
||||
# Force enemies to attack you
|
||||
|
||||
ability_id: "taunt"
|
||||
name: "Taunt"
|
||||
description: "Force all enemies to focus their attacks on you"
|
||||
ability_type: "skill"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "constitution"
|
||||
scaling_factor: 0.3
|
||||
mana_cost: 8
|
||||
cooldown: 3
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "taunted"
|
||||
name: "Taunted"
|
||||
effect_type: "debuff"
|
||||
duration: 2
|
||||
power: 100
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "taunt"
|
||||
25
api/app/data/abilities/thousand_cuts.yaml
Normal file
25
api/app/data/abilities/thousand_cuts.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Thousand Cuts - Assassin Blade Specialist ultimate
|
||||
# Multi-hit flurry
|
||||
|
||||
ability_id: "thousand_cuts"
|
||||
name: "Thousand Cuts"
|
||||
description: "Unleash a flurry of strikes, each with 50% crit chance"
|
||||
ability_type: "attack"
|
||||
base_power: 100
|
||||
damage_type: "physical"
|
||||
scaling_stat: "dexterity"
|
||||
scaling_factor: 0.7
|
||||
mana_cost: 60
|
||||
cooldown: 5
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "bleeding_wounds"
|
||||
name: "Bleeding Wounds"
|
||||
effect_type: "dot"
|
||||
duration: 3
|
||||
power: 15
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 5
|
||||
source: "thousand_cuts"
|
||||
25
api/app/data/abilities/time_warp.yaml
Normal file
25
api/app/data/abilities/time_warp.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Time Warp - Lorekeeper Arcane Weaving ability
|
||||
# AoE extra actions
|
||||
|
||||
ability_id: "time_warp"
|
||||
name: "Time Warp"
|
||||
description: "Bend time itself, granting all allies increased speed"
|
||||
ability_type: "spell"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "intelligence"
|
||||
scaling_factor: 0.5
|
||||
mana_cost: 45
|
||||
cooldown: 5
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "time_warped"
|
||||
name: "Time Warped"
|
||||
effect_type: "buff"
|
||||
duration: 3
|
||||
power: 75
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "time_warp"
|
||||
25
api/app/data/abilities/titans_wrath.yaml
Normal file
25
api/app/data/abilities/titans_wrath.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Titan's Wrath - Vanguard Weapon Master ultimate
|
||||
# Devastating AoE attack with stun
|
||||
|
||||
ability_id: "titans_wrath"
|
||||
name: "Titan's Wrath"
|
||||
description: "Unleash a devastating attack that deals 300% weapon damage and stuns all enemies"
|
||||
ability_type: "attack"
|
||||
base_power: 100
|
||||
damage_type: "physical"
|
||||
scaling_stat: "strength"
|
||||
scaling_factor: 0.7
|
||||
mana_cost: 60
|
||||
cooldown: 5
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied:
|
||||
- effect_id: "titans_stun"
|
||||
name: "Staggered"
|
||||
effect_type: "stun"
|
||||
duration: 1
|
||||
power: 0
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "titans_wrath"
|
||||
25
api/app/data/abilities/unbreakable.yaml
Normal file
25
api/app/data/abilities/unbreakable.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Unbreakable - Vanguard Shield Bearer ultimate
|
||||
# Massive damage reduction
|
||||
|
||||
ability_id: "unbreakable"
|
||||
name: "Unbreakable"
|
||||
description: "Channel your inner strength to become nearly invulnerable, reducing all damage by 75% for 5 turns"
|
||||
ability_type: "defend"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "constitution"
|
||||
scaling_factor: 0.3
|
||||
mana_cost: 50
|
||||
cooldown: 6
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "unbreakable_buff"
|
||||
name: "Unbreakable"
|
||||
effect_type: "buff"
|
||||
duration: 5
|
||||
power: 75
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "unbreakable"
|
||||
25
api/app/data/abilities/vanish.yaml
Normal file
25
api/app/data/abilities/vanish.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Vanish - Assassin Shadow Dancer ability
|
||||
# Stealth for 2 turns
|
||||
|
||||
ability_id: "vanish"
|
||||
name: "Vanish"
|
||||
description: "Disappear into the shadows, becoming invisible for 2 turns and dropping threat"
|
||||
ability_type: "skill"
|
||||
base_power: 0
|
||||
damage_type: null
|
||||
scaling_stat: "dexterity"
|
||||
scaling_factor: 0.3
|
||||
mana_cost: 25
|
||||
cooldown: 5
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied:
|
||||
- effect_id: "stealth"
|
||||
name: "Stealthed"
|
||||
effect_type: "buff"
|
||||
duration: 2
|
||||
power: 100
|
||||
stat_affected: null
|
||||
stacks: 1
|
||||
max_stacks: 1
|
||||
source: "vanish"
|
||||
16
api/app/data/abilities/vital_strike.yaml
Normal file
16
api/app/data/abilities/vital_strike.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Vital Strike - Assassin Blade Specialist ability
|
||||
# Massive crit damage
|
||||
|
||||
ability_id: "vital_strike"
|
||||
name: "Vital Strike"
|
||||
description: "Strike a vital organ for massive critical damage"
|
||||
ability_type: "attack"
|
||||
base_power: 30
|
||||
damage_type: "physical"
|
||||
scaling_stat: "dexterity"
|
||||
scaling_factor: 0.55
|
||||
mana_cost: 18
|
||||
cooldown: 2
|
||||
is_aoe: false
|
||||
target_count: 1
|
||||
effects_applied: []
|
||||
16
api/app/data/abilities/word_of_healing.yaml
Normal file
16
api/app/data/abilities/word_of_healing.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Word of Healing - Oathkeeper Redemption ability
|
||||
# AoE heal
|
||||
|
||||
ability_id: "word_of_healing"
|
||||
name: "Word of Healing"
|
||||
description: "Speak a word of power that heals all nearby allies"
|
||||
ability_type: "spell"
|
||||
base_power: 40
|
||||
damage_type: "holy"
|
||||
scaling_stat: "wisdom"
|
||||
scaling_factor: 0.55
|
||||
mana_cost: 30
|
||||
cooldown: 3
|
||||
is_aoe: true
|
||||
target_count: 0
|
||||
effects_applied: []
|
||||
177
api/app/data/affixes/prefixes.yaml
Normal file
177
api/app/data/affixes/prefixes.yaml
Normal file
@@ -0,0 +1,177 @@
|
||||
# Item Prefix Affixes
|
||||
# Prefixes appear before the item name: "Flaming Dagger"
|
||||
#
|
||||
# Affix Structure:
|
||||
# affix_id: Unique identifier
|
||||
# name: Display name (what appears in the item name)
|
||||
# affix_type: "prefix"
|
||||
# tier: "minor" (RARE), "major" (EPIC), "legendary" (LEGENDARY only)
|
||||
# description: Flavor text describing the effect
|
||||
# stat_bonuses: Dict of stat_name -> bonus value
|
||||
# defense_bonus: Direct defense bonus
|
||||
# resistance_bonus: Direct resistance bonus
|
||||
# damage_bonus: Flat damage bonus (weapons)
|
||||
# damage_type: Elemental damage type
|
||||
# elemental_ratio: Portion converted to elemental (0.0-1.0)
|
||||
# crit_chance_bonus: Added to crit chance
|
||||
# crit_multiplier_bonus: Added to crit multiplier
|
||||
# allowed_item_types: [] = all types, or ["weapon", "armor"]
|
||||
# required_rarity: null = any, or "legendary"
|
||||
|
||||
prefixes:
|
||||
# ==================== ELEMENTAL PREFIXES (FIRE) ====================
|
||||
flaming:
|
||||
affix_id: "flaming"
|
||||
name: "Flaming"
|
||||
affix_type: "prefix"
|
||||
tier: "minor"
|
||||
description: "Imbued with fire magic, dealing bonus fire damage"
|
||||
damage_type: "fire"
|
||||
elemental_ratio: 0.25
|
||||
damage_bonus: 3
|
||||
allowed_item_types: ["weapon"]
|
||||
|
||||
blazing:
|
||||
affix_id: "blazing"
|
||||
name: "Blazing"
|
||||
affix_type: "prefix"
|
||||
tier: "major"
|
||||
description: "Wreathed in intense flames"
|
||||
damage_type: "fire"
|
||||
elemental_ratio: 0.35
|
||||
damage_bonus: 6
|
||||
allowed_item_types: ["weapon"]
|
||||
|
||||
# ==================== ELEMENTAL PREFIXES (ICE) ====================
|
||||
frozen:
|
||||
affix_id: "frozen"
|
||||
name: "Frozen"
|
||||
affix_type: "prefix"
|
||||
tier: "minor"
|
||||
description: "Enchanted with frost magic"
|
||||
damage_type: "ice"
|
||||
elemental_ratio: 0.25
|
||||
damage_bonus: 3
|
||||
allowed_item_types: ["weapon"]
|
||||
|
||||
glacial:
|
||||
affix_id: "glacial"
|
||||
name: "Glacial"
|
||||
affix_type: "prefix"
|
||||
tier: "major"
|
||||
description: "Encased in eternal ice"
|
||||
damage_type: "ice"
|
||||
elemental_ratio: 0.35
|
||||
damage_bonus: 6
|
||||
allowed_item_types: ["weapon"]
|
||||
|
||||
# ==================== ELEMENTAL PREFIXES (LIGHTNING) ====================
|
||||
shocking:
|
||||
affix_id: "shocking"
|
||||
name: "Shocking"
|
||||
affix_type: "prefix"
|
||||
tier: "minor"
|
||||
description: "Crackles with electrical energy"
|
||||
damage_type: "lightning"
|
||||
elemental_ratio: 0.25
|
||||
damage_bonus: 3
|
||||
allowed_item_types: ["weapon"]
|
||||
|
||||
thundering:
|
||||
affix_id: "thundering"
|
||||
name: "Thundering"
|
||||
affix_type: "prefix"
|
||||
tier: "major"
|
||||
description: "Charged with the power of storms"
|
||||
damage_type: "lightning"
|
||||
elemental_ratio: 0.35
|
||||
damage_bonus: 6
|
||||
allowed_item_types: ["weapon"]
|
||||
|
||||
# ==================== MATERIAL PREFIXES ====================
|
||||
iron:
|
||||
affix_id: "iron"
|
||||
name: "Iron"
|
||||
affix_type: "prefix"
|
||||
tier: "minor"
|
||||
description: "Reinforced with sturdy iron"
|
||||
stat_bonuses:
|
||||
constitution: 1
|
||||
defense_bonus: 2
|
||||
|
||||
steel:
|
||||
affix_id: "steel"
|
||||
name: "Steel"
|
||||
affix_type: "prefix"
|
||||
tier: "major"
|
||||
description: "Forged from fine steel"
|
||||
stat_bonuses:
|
||||
constitution: 2
|
||||
strength: 1
|
||||
defense_bonus: 4
|
||||
|
||||
# ==================== QUALITY PREFIXES ====================
|
||||
sharp:
|
||||
affix_id: "sharp"
|
||||
name: "Sharp"
|
||||
affix_type: "prefix"
|
||||
tier: "minor"
|
||||
description: "Honed to a fine edge"
|
||||
damage_bonus: 3
|
||||
crit_chance_bonus: 0.02
|
||||
allowed_item_types: ["weapon"]
|
||||
|
||||
keen:
|
||||
affix_id: "keen"
|
||||
name: "Keen"
|
||||
affix_type: "prefix"
|
||||
tier: "major"
|
||||
description: "Razor-sharp edge that finds weak points"
|
||||
damage_bonus: 5
|
||||
crit_chance_bonus: 0.04
|
||||
allowed_item_types: ["weapon"]
|
||||
|
||||
# ==================== DEFENSIVE PREFIXES ====================
|
||||
sturdy:
|
||||
affix_id: "sturdy"
|
||||
name: "Sturdy"
|
||||
affix_type: "prefix"
|
||||
tier: "minor"
|
||||
description: "Built to withstand punishment"
|
||||
defense_bonus: 3
|
||||
allowed_item_types: ["armor"]
|
||||
|
||||
reinforced:
|
||||
affix_id: "reinforced"
|
||||
name: "Reinforced"
|
||||
affix_type: "prefix"
|
||||
tier: "major"
|
||||
description: "Heavily reinforced for maximum protection"
|
||||
defense_bonus: 5
|
||||
resistance_bonus: 2
|
||||
allowed_item_types: ["armor"]
|
||||
|
||||
# ==================== LEGENDARY PREFIXES ====================
|
||||
infernal:
|
||||
affix_id: "infernal"
|
||||
name: "Infernal"
|
||||
affix_type: "prefix"
|
||||
tier: "legendary"
|
||||
description: "Burns with hellfire"
|
||||
damage_type: "fire"
|
||||
elemental_ratio: 0.45
|
||||
damage_bonus: 12
|
||||
allowed_item_types: ["weapon"]
|
||||
required_rarity: "legendary"
|
||||
|
||||
vorpal:
|
||||
affix_id: "vorpal"
|
||||
name: "Vorpal"
|
||||
affix_type: "prefix"
|
||||
tier: "legendary"
|
||||
description: "Cuts through anything with supernatural precision"
|
||||
damage_bonus: 10
|
||||
crit_chance_bonus: 0.08
|
||||
crit_multiplier_bonus: 0.5
|
||||
allowed_item_types: ["weapon"]
|
||||
required_rarity: "legendary"
|
||||
155
api/app/data/affixes/suffixes.yaml
Normal file
155
api/app/data/affixes/suffixes.yaml
Normal file
@@ -0,0 +1,155 @@
|
||||
# Item Suffix Affixes
|
||||
# Suffixes appear after the item name: "Dagger of Strength"
|
||||
#
|
||||
# Suffix naming convention:
|
||||
# - Minor tier: "of [Stat]" (e.g., "of Strength")
|
||||
# - Major tier: "of the [Animal/Element]" (e.g., "of the Bear")
|
||||
# - Legendary tier: "of the [Mythical]" (e.g., "of the Titan")
|
||||
|
||||
suffixes:
|
||||
# ==================== STAT SUFFIXES (MINOR) ====================
|
||||
of_strength:
|
||||
affix_id: "of_strength"
|
||||
name: "of Strength"
|
||||
affix_type: "suffix"
|
||||
tier: "minor"
|
||||
description: "Grants physical power"
|
||||
stat_bonuses:
|
||||
strength: 2
|
||||
|
||||
of_dexterity:
|
||||
affix_id: "of_dexterity"
|
||||
name: "of Dexterity"
|
||||
affix_type: "suffix"
|
||||
tier: "minor"
|
||||
description: "Grants agility and precision"
|
||||
stat_bonuses:
|
||||
dexterity: 2
|
||||
|
||||
of_constitution:
|
||||
affix_id: "of_constitution"
|
||||
name: "of Fortitude"
|
||||
affix_type: "suffix"
|
||||
tier: "minor"
|
||||
description: "Grants endurance"
|
||||
stat_bonuses:
|
||||
constitution: 2
|
||||
|
||||
of_intelligence:
|
||||
affix_id: "of_intelligence"
|
||||
name: "of Intelligence"
|
||||
affix_type: "suffix"
|
||||
tier: "minor"
|
||||
description: "Grants magical aptitude"
|
||||
stat_bonuses:
|
||||
intelligence: 2
|
||||
|
||||
of_wisdom:
|
||||
affix_id: "of_wisdom"
|
||||
name: "of Wisdom"
|
||||
affix_type: "suffix"
|
||||
tier: "minor"
|
||||
description: "Grants insight and perception"
|
||||
stat_bonuses:
|
||||
wisdom: 2
|
||||
|
||||
of_charisma:
|
||||
affix_id: "of_charisma"
|
||||
name: "of Charm"
|
||||
affix_type: "suffix"
|
||||
tier: "minor"
|
||||
description: "Grants social influence"
|
||||
stat_bonuses:
|
||||
charisma: 2
|
||||
|
||||
of_luck:
|
||||
affix_id: "of_luck"
|
||||
name: "of Fortune"
|
||||
affix_type: "suffix"
|
||||
tier: "minor"
|
||||
description: "Grants favor from fate"
|
||||
stat_bonuses:
|
||||
luck: 2
|
||||
|
||||
# ==================== ENHANCED STAT SUFFIXES (MAJOR) ====================
|
||||
of_the_bear:
|
||||
affix_id: "of_the_bear"
|
||||
name: "of the Bear"
|
||||
affix_type: "suffix"
|
||||
tier: "major"
|
||||
description: "Grants the might and endurance of a bear"
|
||||
stat_bonuses:
|
||||
strength: 4
|
||||
constitution: 2
|
||||
|
||||
of_the_fox:
|
||||
affix_id: "of_the_fox"
|
||||
name: "of the Fox"
|
||||
affix_type: "suffix"
|
||||
tier: "major"
|
||||
description: "Grants the cunning and agility of a fox"
|
||||
stat_bonuses:
|
||||
dexterity: 4
|
||||
luck: 2
|
||||
|
||||
of_the_owl:
|
||||
affix_id: "of_the_owl"
|
||||
name: "of the Owl"
|
||||
affix_type: "suffix"
|
||||
tier: "major"
|
||||
description: "Grants the wisdom and insight of an owl"
|
||||
stat_bonuses:
|
||||
intelligence: 3
|
||||
wisdom: 3
|
||||
|
||||
# ==================== DEFENSIVE SUFFIXES ====================
|
||||
of_protection:
|
||||
affix_id: "of_protection"
|
||||
name: "of Protection"
|
||||
affix_type: "suffix"
|
||||
tier: "minor"
|
||||
description: "Offers physical protection"
|
||||
defense_bonus: 3
|
||||
|
||||
of_warding:
|
||||
affix_id: "of_warding"
|
||||
name: "of Warding"
|
||||
affix_type: "suffix"
|
||||
tier: "major"
|
||||
description: "Wards against physical and magical harm"
|
||||
defense_bonus: 5
|
||||
resistance_bonus: 3
|
||||
|
||||
# ==================== LEGENDARY SUFFIXES ====================
|
||||
of_the_titan:
|
||||
affix_id: "of_the_titan"
|
||||
name: "of the Titan"
|
||||
affix_type: "suffix"
|
||||
tier: "legendary"
|
||||
description: "Grants titanic strength and endurance"
|
||||
stat_bonuses:
|
||||
strength: 8
|
||||
constitution: 4
|
||||
required_rarity: "legendary"
|
||||
|
||||
of_the_wind:
|
||||
affix_id: "of_the_wind"
|
||||
name: "of the Wind"
|
||||
affix_type: "suffix"
|
||||
tier: "legendary"
|
||||
description: "Swift as the wind itself"
|
||||
stat_bonuses:
|
||||
dexterity: 8
|
||||
luck: 4
|
||||
crit_chance_bonus: 0.05
|
||||
required_rarity: "legendary"
|
||||
|
||||
of_invincibility:
|
||||
affix_id: "of_invincibility"
|
||||
name: "of Invincibility"
|
||||
affix_type: "suffix"
|
||||
tier: "legendary"
|
||||
description: "Grants supreme protection"
|
||||
defense_bonus: 10
|
||||
resistance_bonus: 8
|
||||
required_rarity: "legendary"
|
||||
152
api/app/data/base_items/armor.yaml
Normal file
152
api/app/data/base_items/armor.yaml
Normal file
@@ -0,0 +1,152 @@
|
||||
# Base Armor Templates for Procedural Generation
|
||||
#
|
||||
# These templates define the foundation that affixes attach to.
|
||||
# Example: "Leather Vest" + "Sturdy" prefix = "Sturdy Leather Vest"
|
||||
#
|
||||
# Armor categories:
|
||||
# - Cloth: Low defense, high resistance (mages)
|
||||
# - Leather: Balanced defense/resistance (rogues)
|
||||
# - Chain: Medium defense, low resistance (versatile)
|
||||
# - Plate: High defense, low resistance (warriors)
|
||||
|
||||
armor:
|
||||
# ==================== CLOTH (MAGE ARMOR) ====================
|
||||
cloth_robe:
|
||||
template_id: "cloth_robe"
|
||||
name: "Cloth Robe"
|
||||
item_type: "armor"
|
||||
description: "Simple cloth robes favored by spellcasters"
|
||||
base_defense: 2
|
||||
base_resistance: 5
|
||||
base_value: 15
|
||||
required_level: 1
|
||||
drop_weight: 1.3
|
||||
|
||||
silk_robe:
|
||||
template_id: "silk_robe"
|
||||
name: "Silk Robe"
|
||||
item_type: "armor"
|
||||
description: "Fine silk robes that channel magical energy"
|
||||
base_defense: 3
|
||||
base_resistance: 8
|
||||
base_value: 40
|
||||
required_level: 3
|
||||
drop_weight: 0.9
|
||||
|
||||
arcane_vestments:
|
||||
template_id: "arcane_vestments"
|
||||
name: "Arcane Vestments"
|
||||
item_type: "armor"
|
||||
description: "Robes woven with magical threads"
|
||||
base_defense: 5
|
||||
base_resistance: 12
|
||||
base_value: 80
|
||||
required_level: 5
|
||||
drop_weight: 0.6
|
||||
min_rarity: "uncommon"
|
||||
|
||||
# ==================== LEATHER (ROGUE ARMOR) ====================
|
||||
leather_vest:
|
||||
template_id: "leather_vest"
|
||||
name: "Leather Vest"
|
||||
item_type: "armor"
|
||||
description: "Basic leather protection for agile fighters"
|
||||
base_defense: 5
|
||||
base_resistance: 2
|
||||
base_value: 20
|
||||
required_level: 1
|
||||
drop_weight: 1.3
|
||||
|
||||
studded_leather:
|
||||
template_id: "studded_leather"
|
||||
name: "Studded Leather"
|
||||
item_type: "armor"
|
||||
description: "Leather armor reinforced with metal studs"
|
||||
base_defense: 8
|
||||
base_resistance: 3
|
||||
base_value: 45
|
||||
required_level: 3
|
||||
drop_weight: 1.0
|
||||
|
||||
hardened_leather:
|
||||
template_id: "hardened_leather"
|
||||
name: "Hardened Leather"
|
||||
item_type: "armor"
|
||||
description: "Boiled and hardened leather for superior protection"
|
||||
base_defense: 12
|
||||
base_resistance: 5
|
||||
base_value: 75
|
||||
required_level: 5
|
||||
drop_weight: 0.7
|
||||
min_rarity: "uncommon"
|
||||
|
||||
# ==================== CHAIN (VERSATILE) ====================
|
||||
chain_shirt:
|
||||
template_id: "chain_shirt"
|
||||
name: "Chain Shirt"
|
||||
item_type: "armor"
|
||||
description: "A shirt of interlocking metal rings"
|
||||
base_defense: 7
|
||||
base_resistance: 2
|
||||
base_value: 35
|
||||
required_level: 2
|
||||
drop_weight: 1.0
|
||||
|
||||
chainmail:
|
||||
template_id: "chainmail"
|
||||
name: "Chainmail"
|
||||
item_type: "armor"
|
||||
description: "Full chainmail armor covering torso and arms"
|
||||
base_defense: 10
|
||||
base_resistance: 3
|
||||
base_value: 50
|
||||
required_level: 3
|
||||
drop_weight: 1.0
|
||||
|
||||
heavy_chainmail:
|
||||
template_id: "heavy_chainmail"
|
||||
name: "Heavy Chainmail"
|
||||
item_type: "armor"
|
||||
description: "Thick chainmail with reinforced rings"
|
||||
base_defense: 14
|
||||
base_resistance: 4
|
||||
base_value: 85
|
||||
required_level: 5
|
||||
drop_weight: 0.7
|
||||
min_rarity: "uncommon"
|
||||
|
||||
# ==================== PLATE (WARRIOR ARMOR) ====================
|
||||
scale_mail:
|
||||
template_id: "scale_mail"
|
||||
name: "Scale Mail"
|
||||
item_type: "armor"
|
||||
description: "Overlapping metal scales on leather backing"
|
||||
base_defense: 12
|
||||
base_resistance: 2
|
||||
base_value: 60
|
||||
required_level: 4
|
||||
drop_weight: 0.8
|
||||
|
||||
half_plate:
|
||||
template_id: "half_plate"
|
||||
name: "Half Plate"
|
||||
item_type: "armor"
|
||||
description: "Plate armor protecting vital areas"
|
||||
base_defense: 16
|
||||
base_resistance: 2
|
||||
base_value: 120
|
||||
required_level: 6
|
||||
drop_weight: 0.5
|
||||
min_rarity: "rare"
|
||||
|
||||
plate_armor:
|
||||
template_id: "plate_armor"
|
||||
name: "Plate Armor"
|
||||
item_type: "armor"
|
||||
description: "Full metal plate protection"
|
||||
base_defense: 22
|
||||
base_resistance: 3
|
||||
base_value: 200
|
||||
required_level: 7
|
||||
drop_weight: 0.4
|
||||
min_rarity: "rare"
|
||||
246
api/app/data/base_items/shields.yaml
Normal file
246
api/app/data/base_items/shields.yaml
Normal file
@@ -0,0 +1,246 @@
|
||||
# Base Shield Templates for Procedural Generation
|
||||
#
|
||||
# These templates define the foundation that affixes attach to.
|
||||
# Example: "Wooden Shield" + "Reinforced" prefix = "Reinforced Wooden Shield"
|
||||
#
|
||||
# Shield categories:
|
||||
# - Light Shields: Low defense, high mobility (bucklers)
|
||||
# - Medium Shields: Balanced defense/weight (round shields, kite shields)
|
||||
# - Heavy Shields: High defense, reduced mobility (tower shields)
|
||||
# - Magical Shields: Low physical defense, high resistance (arcane aegis)
|
||||
#
|
||||
# Template Structure:
|
||||
# template_id: Unique identifier
|
||||
# name: Base item name
|
||||
# item_type: "armor" (shields use armor type)
|
||||
# slot: "off_hand" (shields occupy off-hand slot)
|
||||
# description: Flavor text
|
||||
# base_defense: Physical damage reduction
|
||||
# base_resistance: Magical damage reduction
|
||||
# base_value: Gold value
|
||||
# required_level: Min level to use/drop
|
||||
# drop_weight: Higher = more common (1.0 = standard)
|
||||
# min_rarity: Minimum rarity for this template
|
||||
# block_chance: Chance to fully block an attack (optional)
|
||||
|
||||
shields:
|
||||
# ==================== LIGHT SHIELDS (HIGH MOBILITY) ====================
|
||||
buckler:
|
||||
template_id: "buckler"
|
||||
name: "Buckler"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A small, round shield strapped to the forearm. Offers minimal protection but doesn't hinder movement."
|
||||
base_defense: 3
|
||||
base_resistance: 0
|
||||
base_value: 20
|
||||
required_level: 1
|
||||
drop_weight: 1.5
|
||||
block_chance: 0.05
|
||||
|
||||
parrying_buckler:
|
||||
template_id: "parrying_buckler"
|
||||
name: "Parrying Buckler"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A lightweight buckler with a reinforced edge, designed for deflecting blows."
|
||||
base_defense: 4
|
||||
base_resistance: 0
|
||||
base_value: 45
|
||||
required_level: 3
|
||||
drop_weight: 1.0
|
||||
block_chance: 0.08
|
||||
|
||||
# ==================== MEDIUM SHIELDS (BALANCED) ====================
|
||||
wooden_shield:
|
||||
template_id: "wooden_shield"
|
||||
name: "Wooden Shield"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A basic shield carved from sturdy oak. Reliable protection for new adventurers."
|
||||
base_defense: 5
|
||||
base_resistance: 1
|
||||
base_value: 35
|
||||
required_level: 1
|
||||
drop_weight: 1.3
|
||||
|
||||
round_shield:
|
||||
template_id: "round_shield"
|
||||
name: "Round Shield"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A circular shield with an iron boss. Popular among infantry and mercenaries."
|
||||
base_defense: 7
|
||||
base_resistance: 1
|
||||
base_value: 50
|
||||
required_level: 2
|
||||
drop_weight: 1.2
|
||||
|
||||
iron_shield:
|
||||
template_id: "iron_shield"
|
||||
name: "Iron Shield"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A solid iron shield. Heavy but dependable."
|
||||
base_defense: 9
|
||||
base_resistance: 1
|
||||
base_value: 70
|
||||
required_level: 3
|
||||
drop_weight: 1.0
|
||||
|
||||
kite_shield:
|
||||
template_id: "kite_shield"
|
||||
name: "Kite Shield"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A tall, tapered shield that protects the entire body. Favored by knights."
|
||||
base_defense: 10
|
||||
base_resistance: 2
|
||||
base_value: 80
|
||||
required_level: 3
|
||||
drop_weight: 1.0
|
||||
|
||||
heater_shield:
|
||||
template_id: "heater_shield"
|
||||
name: "Heater Shield"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A classic triangular shield with excellent balance of protection and mobility."
|
||||
base_defense: 12
|
||||
base_resistance: 2
|
||||
base_value: 100
|
||||
required_level: 4
|
||||
drop_weight: 0.9
|
||||
|
||||
steel_shield:
|
||||
template_id: "steel_shield"
|
||||
name: "Steel Shield"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A finely crafted steel shield. Durable and well-balanced."
|
||||
base_defense: 14
|
||||
base_resistance: 2
|
||||
base_value: 125
|
||||
required_level: 5
|
||||
drop_weight: 0.8
|
||||
min_rarity: "uncommon"
|
||||
|
||||
# ==================== HEAVY SHIELDS (MAXIMUM DEFENSE) ====================
|
||||
tower_shield:
|
||||
template_id: "tower_shield"
|
||||
name: "Tower Shield"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A massive shield that covers the entire body. Extremely heavy but offers unparalleled protection."
|
||||
base_defense: 18
|
||||
base_resistance: 3
|
||||
base_value: 175
|
||||
required_level: 6
|
||||
drop_weight: 0.6
|
||||
min_rarity: "uncommon"
|
||||
block_chance: 0.12
|
||||
|
||||
fortified_tower_shield:
|
||||
template_id: "fortified_tower_shield"
|
||||
name: "Fortified Tower Shield"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A reinforced tower shield with iron plating. A mobile wall on the battlefield."
|
||||
base_defense: 22
|
||||
base_resistance: 4
|
||||
base_value: 250
|
||||
required_level: 7
|
||||
drop_weight: 0.4
|
||||
min_rarity: "rare"
|
||||
block_chance: 0.15
|
||||
|
||||
# ==================== MAGICAL SHIELDS (HIGH RESISTANCE) ====================
|
||||
enchanted_buckler:
|
||||
template_id: "enchanted_buckler"
|
||||
name: "Enchanted Buckler"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A small shield inscribed with protective runes. Weak against physical attacks but excellent against magic."
|
||||
base_defense: 2
|
||||
base_resistance: 6
|
||||
base_value: 80
|
||||
required_level: 3
|
||||
drop_weight: 0.8
|
||||
|
||||
warded_shield:
|
||||
template_id: "warded_shield"
|
||||
name: "Warded Shield"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A shield covered in magical wards that deflect spells."
|
||||
base_defense: 4
|
||||
base_resistance: 8
|
||||
base_value: 110
|
||||
required_level: 4
|
||||
drop_weight: 0.7
|
||||
|
||||
magical_aegis:
|
||||
template_id: "magical_aegis"
|
||||
name: "Magical Aegis"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "An arcane shield that shimmers with protective energy. Prized by battle mages."
|
||||
base_defense: 8
|
||||
base_resistance: 10
|
||||
base_value: 150
|
||||
required_level: 5
|
||||
drop_weight: 0.6
|
||||
min_rarity: "uncommon"
|
||||
|
||||
spellguard:
|
||||
template_id: "spellguard"
|
||||
name: "Spellguard"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A crystalline shield forged from condensed magical energy. Near-impervious to spells."
|
||||
base_defense: 6
|
||||
base_resistance: 14
|
||||
base_value: 200
|
||||
required_level: 6
|
||||
drop_weight: 0.5
|
||||
min_rarity: "rare"
|
||||
|
||||
# ==================== SPECIALIZED SHIELDS ====================
|
||||
spiked_shield:
|
||||
template_id: "spiked_shield"
|
||||
name: "Spiked Shield"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A shield with iron spikes. Can be used offensively in close combat."
|
||||
base_defense: 8
|
||||
base_resistance: 1
|
||||
base_value: 90
|
||||
required_level: 4
|
||||
drop_weight: 0.8
|
||||
base_damage: 5
|
||||
|
||||
lantern_shield:
|
||||
template_id: "lantern_shield"
|
||||
name: "Lantern Shield"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A peculiar shield with an attached lantern. Useful for dungeon exploration."
|
||||
base_defense: 6
|
||||
base_resistance: 1
|
||||
base_value: 75
|
||||
required_level: 3
|
||||
drop_weight: 0.7
|
||||
provides_light: true
|
||||
|
||||
pavise:
|
||||
template_id: "pavise"
|
||||
name: "Pavise"
|
||||
item_type: "armor"
|
||||
slot: "off_hand"
|
||||
description: "A large rectangular shield that can be planted in the ground for cover. Favored by crossbowmen."
|
||||
base_defense: 15
|
||||
base_resistance: 2
|
||||
base_value: 130
|
||||
required_level: 5
|
||||
drop_weight: 0.6
|
||||
deployable: true
|
||||
227
api/app/data/base_items/weapons.yaml
Normal file
227
api/app/data/base_items/weapons.yaml
Normal file
@@ -0,0 +1,227 @@
|
||||
# Base Weapon Templates for Procedural Generation
|
||||
#
|
||||
# These templates define the foundation that affixes attach to.
|
||||
# Example: "Dagger" + "Flaming" prefix = "Flaming Dagger"
|
||||
#
|
||||
# Template Structure:
|
||||
# template_id: Unique identifier
|
||||
# name: Base item name
|
||||
# item_type: "weapon"
|
||||
# description: Flavor text
|
||||
# base_damage: Weapon damage
|
||||
# base_value: Gold value
|
||||
# damage_type: "physical" (default)
|
||||
# crit_chance: Critical hit chance (0.0-1.0)
|
||||
# crit_multiplier: Crit damage multiplier
|
||||
# required_level: Min level to use/drop
|
||||
# drop_weight: Higher = more common (1.0 = standard)
|
||||
# min_rarity: Minimum rarity for this template
|
||||
|
||||
weapons:
|
||||
# ==================== ONE-HANDED SWORDS ====================
|
||||
dagger:
|
||||
template_id: "dagger"
|
||||
name: "Dagger"
|
||||
item_type: "weapon"
|
||||
description: "A small, quick blade for close combat"
|
||||
base_damage: 6
|
||||
base_value: 15
|
||||
damage_type: "physical"
|
||||
crit_chance: 0.08
|
||||
crit_multiplier: 2.0
|
||||
required_level: 1
|
||||
drop_weight: 1.5
|
||||
|
||||
short_sword:
|
||||
template_id: "short_sword"
|
||||
name: "Short Sword"
|
||||
item_type: "weapon"
|
||||
description: "A versatile one-handed blade"
|
||||
base_damage: 10
|
||||
base_value: 30
|
||||
damage_type: "physical"
|
||||
crit_chance: 0.06
|
||||
crit_multiplier: 2.0
|
||||
required_level: 1
|
||||
drop_weight: 1.3
|
||||
|
||||
longsword:
|
||||
template_id: "longsword"
|
||||
name: "Longsword"
|
||||
item_type: "weapon"
|
||||
description: "A standard warrior's blade"
|
||||
base_damage: 14
|
||||
base_value: 50
|
||||
damage_type: "physical"
|
||||
crit_chance: 0.05
|
||||
crit_multiplier: 2.0
|
||||
required_level: 3
|
||||
drop_weight: 1.0
|
||||
|
||||
# ==================== TWO-HANDED WEAPONS ====================
|
||||
greatsword:
|
||||
template_id: "greatsword"
|
||||
name: "Greatsword"
|
||||
item_type: "weapon"
|
||||
description: "A massive two-handed blade"
|
||||
base_damage: 22
|
||||
base_value: 100
|
||||
damage_type: "physical"
|
||||
crit_chance: 0.04
|
||||
crit_multiplier: 2.5
|
||||
required_level: 5
|
||||
drop_weight: 0.7
|
||||
min_rarity: "uncommon"
|
||||
|
||||
# ==================== AXES ====================
|
||||
hatchet:
|
||||
template_id: "hatchet"
|
||||
name: "Hatchet"
|
||||
item_type: "weapon"
|
||||
description: "A small throwing axe"
|
||||
base_damage: 8
|
||||
base_value: 20
|
||||
damage_type: "physical"
|
||||
crit_chance: 0.06
|
||||
crit_multiplier: 2.2
|
||||
required_level: 1
|
||||
drop_weight: 1.2
|
||||
|
||||
battle_axe:
|
||||
template_id: "battle_axe"
|
||||
name: "Battle Axe"
|
||||
item_type: "weapon"
|
||||
description: "A heavy axe designed for combat"
|
||||
base_damage: 16
|
||||
base_value: 60
|
||||
damage_type: "physical"
|
||||
crit_chance: 0.05
|
||||
crit_multiplier: 2.3
|
||||
required_level: 4
|
||||
drop_weight: 0.9
|
||||
|
||||
# ==================== BLUNT WEAPONS ====================
|
||||
club:
|
||||
template_id: "club"
|
||||
name: "Club"
|
||||
item_type: "weapon"
|
||||
description: "A simple wooden club"
|
||||
base_damage: 7
|
||||
base_value: 10
|
||||
damage_type: "physical"
|
||||
crit_chance: 0.04
|
||||
crit_multiplier: 2.0
|
||||
required_level: 1
|
||||
drop_weight: 1.5
|
||||
|
||||
mace:
|
||||
template_id: "mace"
|
||||
name: "Mace"
|
||||
item_type: "weapon"
|
||||
description: "A flanged mace for crushing armor"
|
||||
base_damage: 12
|
||||
base_value: 40
|
||||
damage_type: "physical"
|
||||
crit_chance: 0.05
|
||||
crit_multiplier: 2.0
|
||||
required_level: 2
|
||||
drop_weight: 1.0
|
||||
|
||||
# ==================== STAVES ====================
|
||||
quarterstaff:
|
||||
template_id: "quarterstaff"
|
||||
name: "Quarterstaff"
|
||||
item_type: "weapon"
|
||||
description: "A simple wooden staff"
|
||||
base_damage: 6
|
||||
base_value: 10
|
||||
damage_type: "physical"
|
||||
crit_chance: 0.05
|
||||
crit_multiplier: 2.0
|
||||
required_level: 1
|
||||
drop_weight: 1.2
|
||||
|
||||
wizard_staff:
|
||||
template_id: "wizard_staff"
|
||||
name: "Wizard Staff"
|
||||
item_type: "weapon"
|
||||
description: "A staff attuned to magical energy"
|
||||
base_damage: 4
|
||||
base_spell_power: 12
|
||||
base_value: 45
|
||||
damage_type: "arcane"
|
||||
crit_chance: 0.05
|
||||
crit_multiplier: 2.0
|
||||
required_level: 3
|
||||
drop_weight: 0.8
|
||||
|
||||
arcane_staff:
|
||||
template_id: "arcane_staff"
|
||||
name: "Arcane Staff"
|
||||
item_type: "weapon"
|
||||
description: "A powerful staff pulsing with arcane power"
|
||||
base_damage: 6
|
||||
base_spell_power: 18
|
||||
base_value: 90
|
||||
damage_type: "arcane"
|
||||
crit_chance: 0.06
|
||||
crit_multiplier: 2.0
|
||||
required_level: 5
|
||||
drop_weight: 0.6
|
||||
min_rarity: "uncommon"
|
||||
|
||||
# ==================== WANDS ====================
|
||||
wand:
|
||||
template_id: "wand"
|
||||
name: "Wand"
|
||||
item_type: "weapon"
|
||||
description: "A simple magical focus"
|
||||
base_damage: 2
|
||||
base_spell_power: 8
|
||||
base_value: 30
|
||||
damage_type: "arcane"
|
||||
crit_chance: 0.06
|
||||
crit_multiplier: 2.0
|
||||
required_level: 1
|
||||
drop_weight: 1.0
|
||||
|
||||
crystal_wand:
|
||||
template_id: "crystal_wand"
|
||||
name: "Crystal Wand"
|
||||
item_type: "weapon"
|
||||
description: "A wand topped with a magical crystal"
|
||||
base_damage: 3
|
||||
base_spell_power: 14
|
||||
base_value: 60
|
||||
damage_type: "arcane"
|
||||
crit_chance: 0.07
|
||||
crit_multiplier: 2.2
|
||||
required_level: 4
|
||||
drop_weight: 0.8
|
||||
|
||||
# ==================== RANGED ====================
|
||||
shortbow:
|
||||
template_id: "shortbow"
|
||||
name: "Shortbow"
|
||||
item_type: "weapon"
|
||||
description: "A compact bow for quick shots"
|
||||
base_damage: 8
|
||||
base_value: 25
|
||||
damage_type: "physical"
|
||||
crit_chance: 0.07
|
||||
crit_multiplier: 2.0
|
||||
required_level: 1
|
||||
drop_weight: 1.1
|
||||
|
||||
longbow:
|
||||
template_id: "longbow"
|
||||
name: "Longbow"
|
||||
item_type: "weapon"
|
||||
description: "A powerful bow with excellent range"
|
||||
base_damage: 14
|
||||
base_value: 55
|
||||
damage_type: "physical"
|
||||
crit_chance: 0.08
|
||||
crit_multiplier: 2.2
|
||||
required_level: 4
|
||||
drop_weight: 0.9
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user