Compare commits
39 Commits
master
...
45cfa25911
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| a0635499a7 | |||
| 2419dbeb34 | |||
| 196346165f | |||
| 20cb279793 | |||
| 4353d112f4 | |||
| 9c6eb770e5 | |||
| aaa69316c2 | |||
| bda363de76 | |||
| e198d9ac8a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,3 +35,4 @@ Thumbs.db
|
|||||||
logs/
|
logs/
|
||||||
app/logs/
|
app/logs/
|
||||||
*.log
|
*.log
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
@@ -164,8 +164,22 @@ def register_blueprints(app: Flask) -> None:
|
|||||||
app.register_blueprint(npcs_bp)
|
app.register_blueprint(npcs_bp)
|
||||||
logger.info("NPCs API blueprint registered")
|
logger.info("NPCs API blueprint registered")
|
||||||
|
|
||||||
|
# Import and register Chat API blueprint
|
||||||
|
from app.api.chat import chat_bp
|
||||||
|
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")
|
||||||
|
|
||||||
# TODO: Register additional blueprints as they are created
|
# TODO: Register additional blueprints as they are created
|
||||||
# from app.api import combat, marketplace, shop
|
# from app.api import marketplace, shop
|
||||||
# app.register_blueprint(combat.bp, url_prefix='/api/v1/combat')
|
|
||||||
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')
|
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')
|
||||||
# app.register_blueprint(shop.bp, url_prefix='/api/v1/shop')
|
# app.register_blueprint(shop.bp, url_prefix='/api/v1/shop')
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from flask import Blueprint, request, make_response, render_template, redirect,
|
|||||||
from appwrite.exception import AppwriteException
|
from appwrite.exception import AppwriteException
|
||||||
|
|
||||||
from app.services.appwrite_service import AppwriteService
|
from app.services.appwrite_service import AppwriteService
|
||||||
|
from app.services.session_cache_service import SessionCacheService
|
||||||
from app.utils.response import (
|
from app.utils.response import (
|
||||||
success_response,
|
success_response,
|
||||||
created_response,
|
created_response,
|
||||||
@@ -305,7 +306,11 @@ def api_logout():
|
|||||||
if not token:
|
if not token:
|
||||||
return unauthorized_response(message="No active session")
|
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 = AppwriteService()
|
||||||
appwrite.logout_user(session_id=token)
|
appwrite.logout_user(session_id=token)
|
||||||
|
|
||||||
@@ -340,6 +345,36 @@ def api_logout():
|
|||||||
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
|
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'])
|
@auth_bp.route('/api/v1/auth/verify-email', methods=['GET'])
|
||||||
def api_verify_email():
|
def api_verify_email():
|
||||||
"""
|
"""
|
||||||
@@ -480,6 +515,10 @@ def api_reset_password():
|
|||||||
appwrite = AppwriteService()
|
appwrite = AppwriteService()
|
||||||
appwrite.confirm_password_reset(user_id=user_id, secret=secret, password=password)
|
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)
|
logger.info("Password reset successfully", user_id=user_id)
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
|
|||||||
320
api/app/api/chat.py
Normal file
320
api/app/api/chat.py
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
"""
|
||||||
|
Chat API Endpoints.
|
||||||
|
|
||||||
|
Provides REST API for accessing player-NPC conversation history,
|
||||||
|
searching messages, and managing chat data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.utils.auth import require_auth, get_current_user
|
||||||
|
from app.services.chat_message_service import (
|
||||||
|
get_chat_message_service,
|
||||||
|
ChatMessageNotFound,
|
||||||
|
ChatMessagePermissionDenied
|
||||||
|
)
|
||||||
|
from app.services.npc_loader import get_npc_loader
|
||||||
|
from app.models.chat_message import MessageContext
|
||||||
|
from app.utils.response import (
|
||||||
|
success_response,
|
||||||
|
error_response,
|
||||||
|
not_found_response,
|
||||||
|
validation_error_response
|
||||||
|
)
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
chat_bp = Blueprint('chat', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route('/api/v1/characters/<character_id>/chats', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_conversations_summary(character_id: str):
|
||||||
|
"""
|
||||||
|
Get summary of all NPC conversations for a character.
|
||||||
|
|
||||||
|
Returns list of NPCs the character has talked to, with message counts,
|
||||||
|
last message timestamp, and preview of most recent exchange.
|
||||||
|
|
||||||
|
Path params:
|
||||||
|
character_id: Character ID
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
None
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with list of conversation summaries
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
# Get conversation summaries
|
||||||
|
chat_service = get_chat_message_service()
|
||||||
|
summaries = chat_service.get_all_conversations_summary(character_id, user.id)
|
||||||
|
|
||||||
|
# Convert to dict for JSON response
|
||||||
|
conversations = [summary.to_dict() for summary in summaries]
|
||||||
|
|
||||||
|
logger.info("Retrieved conversation summaries",
|
||||||
|
user_id=user.id,
|
||||||
|
character_id=character_id,
|
||||||
|
count=len(conversations))
|
||||||
|
|
||||||
|
return success_response({"conversations": conversations})
|
||||||
|
|
||||||
|
except ChatMessagePermissionDenied as e:
|
||||||
|
logger.warning("Permission denied getting conversations",
|
||||||
|
user_id=user.id if 'user' in locals() else None,
|
||||||
|
character_id=character_id)
|
||||||
|
return error_response(str(e), 403)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get conversations summary",
|
||||||
|
character_id=character_id,
|
||||||
|
error=str(e))
|
||||||
|
return error_response(f"Failed to retrieve conversations: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route('/api/v1/characters/<character_id>/chats/<npc_id>', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_conversation_history(character_id: str, npc_id: str):
|
||||||
|
"""
|
||||||
|
Get full conversation history between character and specific NPC.
|
||||||
|
|
||||||
|
Returns paginated list of messages ordered by timestamp DESC (most recent first).
|
||||||
|
|
||||||
|
Path params:
|
||||||
|
character_id: Character ID
|
||||||
|
npc_id: NPC ID
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
limit: Maximum messages to return (default 50, max 100)
|
||||||
|
offset: Number of messages to skip (default 0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with messages and pagination info
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
# Get query parameters
|
||||||
|
limit = request.args.get('limit', 50, type=int)
|
||||||
|
offset = request.args.get('offset', 0, type=int)
|
||||||
|
|
||||||
|
# Validate parameters
|
||||||
|
if limit < 1:
|
||||||
|
return validation_error_response("limit must be at least 1")
|
||||||
|
if offset < 0:
|
||||||
|
return validation_error_response("offset must be at least 0")
|
||||||
|
|
||||||
|
# Get conversation history
|
||||||
|
chat_service = get_chat_message_service()
|
||||||
|
messages = chat_service.get_conversation_history(
|
||||||
|
character_id=character_id,
|
||||||
|
user_id=user.id,
|
||||||
|
npc_id=npc_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get NPC name if available
|
||||||
|
npc_loader = get_npc_loader()
|
||||||
|
npc = npc_loader.load_npc(npc_id)
|
||||||
|
npc_name = npc.name if npc else npc_id.replace('_', ' ').title()
|
||||||
|
|
||||||
|
# Convert messages to dict
|
||||||
|
message_dicts = [msg.to_dict() for msg in messages]
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"npc_id": npc_id,
|
||||||
|
"npc_name": npc_name,
|
||||||
|
"total_messages": len(messages), # Count in this batch
|
||||||
|
"messages": message_dicts,
|
||||||
|
"pagination": {
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"has_more": len(messages) == limit # If we got a full batch, there might be more
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Retrieved conversation history",
|
||||||
|
user_id=user.id,
|
||||||
|
character_id=character_id,
|
||||||
|
npc_id=npc_id,
|
||||||
|
count=len(messages))
|
||||||
|
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
except ChatMessagePermissionDenied as e:
|
||||||
|
logger.warning("Permission denied getting conversation",
|
||||||
|
user_id=user.id if 'user' in locals() else None,
|
||||||
|
character_id=character_id,
|
||||||
|
npc_id=npc_id)
|
||||||
|
return error_response(str(e), 403)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get conversation history",
|
||||||
|
character_id=character_id,
|
||||||
|
npc_id=npc_id,
|
||||||
|
error=str(e))
|
||||||
|
return error_response(f"Failed to retrieve conversation: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route('/api/v1/characters/<character_id>/chats/search', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def search_messages(character_id: str):
|
||||||
|
"""
|
||||||
|
Search messages by text with optional filters.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
q (required): Search text to find in player_message and npc_response
|
||||||
|
npc_id (optional): Filter by specific NPC
|
||||||
|
context (optional): Filter by message context (dialogue, quest_offered, shop, etc.)
|
||||||
|
date_from (optional): Start date ISO format (e.g., 2025-11-25T00:00:00Z)
|
||||||
|
date_to (optional): End date ISO format
|
||||||
|
limit (optional): Maximum messages to return (default 50, max 100)
|
||||||
|
offset (optional): Number of messages to skip (default 0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with matching messages
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
# Get query parameters
|
||||||
|
search_text = request.args.get('q')
|
||||||
|
npc_id = request.args.get('npc_id')
|
||||||
|
context_str = request.args.get('context')
|
||||||
|
date_from = request.args.get('date_from')
|
||||||
|
date_to = request.args.get('date_to')
|
||||||
|
limit = request.args.get('limit', 50, type=int)
|
||||||
|
offset = request.args.get('offset', 0, type=int)
|
||||||
|
|
||||||
|
# Validate required parameters
|
||||||
|
if not search_text:
|
||||||
|
return validation_error_response("q (search text) is required")
|
||||||
|
|
||||||
|
# Validate optional parameters
|
||||||
|
if limit < 1:
|
||||||
|
return validation_error_response("limit must be at least 1")
|
||||||
|
if offset < 0:
|
||||||
|
return validation_error_response("offset must be at least 0")
|
||||||
|
|
||||||
|
# Parse context enum if provided
|
||||||
|
context = None
|
||||||
|
if context_str:
|
||||||
|
try:
|
||||||
|
context = MessageContext(context_str)
|
||||||
|
except ValueError:
|
||||||
|
valid_contexts = [c.value for c in MessageContext]
|
||||||
|
return validation_error_response(
|
||||||
|
f"Invalid context. Must be one of: {', '.join(valid_contexts)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Search messages
|
||||||
|
chat_service = get_chat_message_service()
|
||||||
|
messages = chat_service.search_messages(
|
||||||
|
character_id=character_id,
|
||||||
|
user_id=user.id,
|
||||||
|
search_text=search_text,
|
||||||
|
npc_id=npc_id,
|
||||||
|
context=context,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert messages to dict
|
||||||
|
message_dicts = [msg.to_dict() for msg in messages]
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"search_text": search_text,
|
||||||
|
"filters": {
|
||||||
|
"npc_id": npc_id,
|
||||||
|
"context": context_str,
|
||||||
|
"date_from": date_from,
|
||||||
|
"date_to": date_to
|
||||||
|
},
|
||||||
|
"total_results": len(messages),
|
||||||
|
"messages": message_dicts,
|
||||||
|
"pagination": {
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"has_more": len(messages) == limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Search completed",
|
||||||
|
user_id=user.id,
|
||||||
|
character_id=character_id,
|
||||||
|
search_text=search_text,
|
||||||
|
results=len(messages))
|
||||||
|
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
except ChatMessagePermissionDenied as e:
|
||||||
|
logger.warning("Permission denied searching messages",
|
||||||
|
user_id=user.id if 'user' in locals() else None,
|
||||||
|
character_id=character_id)
|
||||||
|
return error_response(str(e), 403)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to search messages",
|
||||||
|
character_id=character_id,
|
||||||
|
error=str(e))
|
||||||
|
return error_response(f"Search failed: {str(e)}", 500)
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route('/api/v1/characters/<character_id>/chats/<message_id>', methods=['DELETE'])
|
||||||
|
@require_auth
|
||||||
|
def delete_message(character_id: str, message_id: str):
|
||||||
|
"""
|
||||||
|
Soft delete a message (sets is_deleted=True).
|
||||||
|
|
||||||
|
Used for privacy/moderation. Message remains in database but filtered from queries.
|
||||||
|
|
||||||
|
Path params:
|
||||||
|
character_id: Character ID (for ownership validation)
|
||||||
|
message_id: Message ID to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with success confirmation
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
# Soft delete message
|
||||||
|
chat_service = get_chat_message_service()
|
||||||
|
success = chat_service.soft_delete_message(
|
||||||
|
message_id=message_id,
|
||||||
|
character_id=character_id,
|
||||||
|
user_id=user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Message deleted",
|
||||||
|
user_id=user.id,
|
||||||
|
character_id=character_id,
|
||||||
|
message_id=message_id)
|
||||||
|
|
||||||
|
return success_response({
|
||||||
|
"message_id": message_id,
|
||||||
|
"deleted": success
|
||||||
|
})
|
||||||
|
|
||||||
|
except ChatMessageNotFound as e:
|
||||||
|
logger.warning("Message not found for deletion",
|
||||||
|
message_id=message_id,
|
||||||
|
character_id=character_id)
|
||||||
|
return not_found_response(str(e))
|
||||||
|
except ChatMessagePermissionDenied as e:
|
||||||
|
logger.warning("Permission denied deleting message",
|
||||||
|
user_id=user.id if 'user' in locals() else None,
|
||||||
|
character_id=character_id,
|
||||||
|
message_id=message_id)
|
||||||
|
return error_response(str(e), 403)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete message",
|
||||||
|
message_id=message_id,
|
||||||
|
character_id=character_id,
|
||||||
|
error=str(e))
|
||||||
|
return error_response(f"Delete failed: {str(e)}", 500)
|
||||||
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
|
||||||
|
)
|
||||||
@@ -281,6 +281,7 @@ def get_npcs_at_location(location_id: str):
|
|||||||
"role": npc.role,
|
"role": npc.role,
|
||||||
"appearance": npc.appearance.brief,
|
"appearance": npc.appearance.brief,
|
||||||
"tags": npc.tags,
|
"tags": npc.tags,
|
||||||
|
"image_url": npc.image_url,
|
||||||
})
|
})
|
||||||
|
|
||||||
return success_response({
|
return success_response({
|
||||||
|
|||||||
@@ -132,23 +132,44 @@ def list_sessions():
|
|||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
user_id = user.id
|
user_id = user.id
|
||||||
session_service = get_session_service()
|
session_service = get_session_service()
|
||||||
|
character_service = get_character_service()
|
||||||
|
|
||||||
# Get user's active sessions
|
# Get user's active sessions
|
||||||
sessions = session_service.get_user_sessions(user_id, active_only=True)
|
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
|
# Build response with basic session info
|
||||||
sessions_list = []
|
sessions_list = []
|
||||||
for session in sessions:
|
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({
|
sessions_list.append({
|
||||||
'session_id': session.session_id,
|
'session_id': session.session_id,
|
||||||
'character_id': session.solo_character_id,
|
'character_id': session.solo_character_id,
|
||||||
|
'character_name': character_names.get(session.solo_character_id),
|
||||||
'turn_number': session.turn_number,
|
'turn_number': session.turn_number,
|
||||||
'status': session.status.value,
|
'status': session.status.value,
|
||||||
'created_at': session.created_at,
|
'created_at': session.created_at,
|
||||||
'last_activity': session.last_activity,
|
'last_activity': session.last_activity,
|
||||||
|
'in_combat': session.is_in_combat(),
|
||||||
'game_state': {
|
'game_state': {
|
||||||
'current_location': session.game_state.current_location,
|
'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(
|
return error_response(
|
||||||
status=409,
|
status=409,
|
||||||
code="SESSION_LIMIT_EXCEEDED",
|
code="SESSION_LIMIT_EXCEEDED",
|
||||||
message="Maximum active sessions limit reached (5). Please end an existing session first."
|
message=str(e)
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -485,10 +506,12 @@ def get_session_state(session_id: str):
|
|||||||
"character_id": session.get_character_id(),
|
"character_id": session.get_character_id(),
|
||||||
"turn_number": session.turn_number,
|
"turn_number": session.turn_number,
|
||||||
"status": session.status.value,
|
"status": session.status.value,
|
||||||
|
"in_combat": session.is_in_combat(),
|
||||||
"game_state": {
|
"game_state": {
|
||||||
"current_location": session.game_state.current_location,
|
"current_location": session.game_state.current_location,
|
||||||
"location_type": session.game_state.location_type.value,
|
"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
|
"available_actions": available_actions
|
||||||
})
|
})
|
||||||
@@ -602,3 +625,111 @@ def get_history(session_id: str):
|
|||||||
code="HISTORY_ERROR",
|
code="HISTORY_ERROR",
|
||||||
message="Failed to get conversation history"
|
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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class RateLimitTier:
|
|||||||
ai_calls_per_day: int
|
ai_calls_per_day: int
|
||||||
custom_actions_per_day: int # -1 for unlimited
|
custom_actions_per_day: int # -1 for unlimited
|
||||||
custom_action_char_limit: int
|
custom_action_char_limit: int
|
||||||
|
max_sessions: int = 1 # Maximum active game sessions allowed
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -86,6 +87,14 @@ class RateLimitingConfig:
|
|||||||
tiers: Dict[str, RateLimitTier] = field(default_factory=dict)
|
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
|
@dataclass
|
||||||
class AuthConfig:
|
class AuthConfig:
|
||||||
"""Authentication configuration."""
|
"""Authentication configuration."""
|
||||||
@@ -104,6 +113,7 @@ class AuthConfig:
|
|||||||
name_min_length: int
|
name_min_length: int
|
||||||
name_max_length: int
|
name_max_length: int
|
||||||
email_max_length: int
|
email_max_length: int
|
||||||
|
session_cache: SessionCacheConfig = field(default_factory=SessionCacheConfig)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -229,7 +239,11 @@ class Config:
|
|||||||
tiers=rate_limit_tiers
|
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'])
|
session_config = SessionConfig(**config_data['session'])
|
||||||
marketplace_config = MarketplaceConfig(**config_data['marketplace'])
|
marketplace_config = MarketplaceConfig(**config_data['marketplace'])
|
||||||
cors_config = CORSConfig(**config_data['cors'])
|
cors_config = CORSConfig(**config_data['cors'])
|
||||||
|
|||||||
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"
|
||||||
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
|
||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
excel in devastating spell damage, capable of incinerating groups of foes or freezing
|
excel in devastating spell damage, capable of incinerating groups of foes or freezing
|
||||||
enemies in place. Choose your element: embrace the flames or command the frost.
|
enemies in place. Choose your element: embrace the flames or command the frost.
|
||||||
|
|
||||||
# Base stats (total: 65)
|
# Base stats (total: 65 + luck)
|
||||||
base_stats:
|
base_stats:
|
||||||
strength: 8 # Low physical power
|
strength: 8 # Low physical power
|
||||||
dexterity: 10 # Average agility
|
dexterity: 10 # Average agility
|
||||||
@@ -16,6 +16,7 @@ base_stats:
|
|||||||
intelligence: 15 # Exceptional magical power
|
intelligence: 15 # Exceptional magical power
|
||||||
wisdom: 12 # Above average perception
|
wisdom: 12 # Above average perception
|
||||||
charisma: 11 # Above average social
|
charisma: 11 # Above average social
|
||||||
|
luck: 9 # Slight chaos magic boost
|
||||||
|
|
||||||
starting_equipment:
|
starting_equipment:
|
||||||
- worn_staff
|
- worn_staff
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
capable of becoming an elusive phantom or a master of critical strikes. Choose your path: embrace
|
capable of becoming an elusive phantom or a master of critical strikes. Choose your path: embrace
|
||||||
the shadows or perfect the killing blow.
|
the shadows or perfect the killing blow.
|
||||||
|
|
||||||
# Base stats (total: 65)
|
# Base stats (total: 65 + luck)
|
||||||
base_stats:
|
base_stats:
|
||||||
strength: 11 # Above average physical power
|
strength: 11 # Above average physical power
|
||||||
dexterity: 15 # Exceptional agility
|
dexterity: 15 # Exceptional agility
|
||||||
@@ -16,6 +16,7 @@ base_stats:
|
|||||||
intelligence: 9 # Below average magic
|
intelligence: 9 # Below average magic
|
||||||
wisdom: 10 # Average perception
|
wisdom: 10 # Average perception
|
||||||
charisma: 10 # Average social
|
charisma: 10 # Average social
|
||||||
|
luck: 12 # High luck for crits and precision
|
||||||
|
|
||||||
starting_equipment:
|
starting_equipment:
|
||||||
- rusty_dagger
|
- rusty_dagger
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
excel in supporting allies and controlling enemies through clever magic and mental manipulation.
|
excel in supporting allies and controlling enemies through clever magic and mental manipulation.
|
||||||
Choose your art: weave arcane power or bend reality itself.
|
Choose your art: weave arcane power or bend reality itself.
|
||||||
|
|
||||||
# Base stats (total: 67)
|
# Base stats (total: 67 + luck)
|
||||||
base_stats:
|
base_stats:
|
||||||
strength: 8 # Low physical power
|
strength: 8 # Low physical power
|
||||||
dexterity: 11 # Above average agility
|
dexterity: 11 # Above average agility
|
||||||
@@ -16,6 +16,7 @@ base_stats:
|
|||||||
intelligence: 13 # Above average magical power
|
intelligence: 13 # Above average magical power
|
||||||
wisdom: 11 # Above average perception
|
wisdom: 11 # Above average perception
|
||||||
charisma: 14 # High social/performance
|
charisma: 14 # High social/performance
|
||||||
|
luck: 10 # Knowledge is its own luck
|
||||||
|
|
||||||
starting_equipment:
|
starting_equipment:
|
||||||
- tome
|
- tome
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
capable of becoming a guardian angel for their allies or a righteous crusader smiting evil.
|
capable of becoming a guardian angel for their allies or a righteous crusader smiting evil.
|
||||||
Choose your calling: protect the innocent or judge the wicked.
|
Choose your calling: protect the innocent or judge the wicked.
|
||||||
|
|
||||||
# Base stats (total: 68)
|
# Base stats (total: 68 + luck)
|
||||||
base_stats:
|
base_stats:
|
||||||
strength: 9 # Below average physical power
|
strength: 9 # Below average physical power
|
||||||
dexterity: 9 # Below average agility
|
dexterity: 9 # Below average agility
|
||||||
@@ -16,6 +16,7 @@ base_stats:
|
|||||||
intelligence: 12 # Above average magical power
|
intelligence: 12 # Above average magical power
|
||||||
wisdom: 14 # High perception/divine power
|
wisdom: 14 # High perception/divine power
|
||||||
charisma: 13 # Above average social
|
charisma: 13 # Above average social
|
||||||
|
luck: 11 # Divine favor grants fortune
|
||||||
|
|
||||||
starting_equipment:
|
starting_equipment:
|
||||||
- rusty_mace
|
- rusty_mace
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
excel in draining enemies over time or overwhelming foes with undead minions.
|
excel in draining enemies over time or overwhelming foes with undead minions.
|
||||||
Choose your dark art: curse your enemies or raise an army of the dead.
|
Choose your dark art: curse your enemies or raise an army of the dead.
|
||||||
|
|
||||||
# Base stats (total: 65)
|
# Base stats (total: 65 + luck)
|
||||||
base_stats:
|
base_stats:
|
||||||
strength: 8 # Low physical power
|
strength: 8 # Low physical power
|
||||||
dexterity: 10 # Average agility
|
dexterity: 10 # Average agility
|
||||||
@@ -16,6 +16,7 @@ base_stats:
|
|||||||
intelligence: 14 # High magical power
|
intelligence: 14 # High magical power
|
||||||
wisdom: 11 # Above average perception
|
wisdom: 11 # Above average perception
|
||||||
charisma: 12 # Above average social (commands undead)
|
charisma: 12 # Above average social (commands undead)
|
||||||
|
luck: 7 # Dark arts come with a cost
|
||||||
|
|
||||||
starting_equipment:
|
starting_equipment:
|
||||||
- bone_wand
|
- bone_wand
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
capable of becoming an unyielding shield for their allies or a beacon of healing light.
|
capable of becoming an unyielding shield for their allies or a beacon of healing light.
|
||||||
Choose your oath: defend the weak or redeem the fallen.
|
Choose your oath: defend the weak or redeem the fallen.
|
||||||
|
|
||||||
# Base stats (total: 67)
|
# Base stats (total: 67 + luck)
|
||||||
base_stats:
|
base_stats:
|
||||||
strength: 12 # Above average physical power
|
strength: 12 # Above average physical power
|
||||||
dexterity: 9 # Below average agility
|
dexterity: 9 # Below average agility
|
||||||
@@ -16,6 +16,7 @@ base_stats:
|
|||||||
intelligence: 10 # Average magic
|
intelligence: 10 # Average magic
|
||||||
wisdom: 12 # Above average perception
|
wisdom: 12 # Above average perception
|
||||||
charisma: 11 # Above average social
|
charisma: 11 # Above average social
|
||||||
|
luck: 9 # Honorable, modest fortune
|
||||||
|
|
||||||
starting_equipment:
|
starting_equipment:
|
||||||
- rusty_sword
|
- rusty_sword
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
capable of becoming an unbreakable shield for their allies or a relentless damage dealer.
|
capable of becoming an unbreakable shield for their allies or a relentless damage dealer.
|
||||||
Choose your path: become a stalwart defender or a devastating weapon master.
|
Choose your path: become a stalwart defender or a devastating weapon master.
|
||||||
|
|
||||||
# Base stats (total: 65, average: 10.83)
|
# Base stats (total: 65 + luck)
|
||||||
base_stats:
|
base_stats:
|
||||||
strength: 14 # High physical power
|
strength: 14 # High physical power
|
||||||
dexterity: 10 # Average agility
|
dexterity: 10 # Average agility
|
||||||
@@ -16,6 +16,7 @@ base_stats:
|
|||||||
intelligence: 8 # Low magic
|
intelligence: 8 # Low magic
|
||||||
wisdom: 10 # Average perception
|
wisdom: 10 # Average perception
|
||||||
charisma: 9 # Below average social
|
charisma: 9 # Below average social
|
||||||
|
luck: 8 # Low luck, relies on strength
|
||||||
|
|
||||||
# Starting equipment (minimal)
|
# Starting equipment (minimal)
|
||||||
starting_equipment:
|
starting_equipment:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
can become elite marksmen with unmatched accuracy or beast masters commanding powerful
|
can become elite marksmen with unmatched accuracy or beast masters commanding powerful
|
||||||
animal companions. Choose your path: perfect your aim or unleash the wild.
|
animal companions. Choose your path: perfect your aim or unleash the wild.
|
||||||
|
|
||||||
# Base stats (total: 66)
|
# Base stats (total: 66 + luck)
|
||||||
base_stats:
|
base_stats:
|
||||||
strength: 10 # Average physical power
|
strength: 10 # Average physical power
|
||||||
dexterity: 14 # High agility
|
dexterity: 14 # High agility
|
||||||
@@ -16,6 +16,7 @@ base_stats:
|
|||||||
intelligence: 9 # Below average magic
|
intelligence: 9 # Below average magic
|
||||||
wisdom: 13 # Above average perception
|
wisdom: 13 # Above average perception
|
||||||
charisma: 9 # Below average social
|
charisma: 9 # Below average social
|
||||||
|
luck: 10 # Average luck, self-reliant
|
||||||
|
|
||||||
starting_equipment:
|
starting_equipment:
|
||||||
- rusty_bow
|
- rusty_bow
|
||||||
|
|||||||
59
api/app/data/enemies/bandit.yaml
Normal file
59
api/app/data/enemies/bandit.yaml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Bandit - Medium humanoid with weapon
|
||||||
|
# A highway robber armed with sword and dagger
|
||||||
|
|
||||||
|
enemy_id: bandit
|
||||||
|
name: Bandit Rogue
|
||||||
|
description: >
|
||||||
|
A rough-looking human in worn leather armor, their face partially hidden
|
||||||
|
by a tattered hood. They fight with a chipped sword and keep a dagger
|
||||||
|
ready for backstabs. Desperation has made them dangerous.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 12
|
||||||
|
dexterity: 14
|
||||||
|
constitution: 10
|
||||||
|
intelligence: 10
|
||||||
|
wisdom: 8
|
||||||
|
charisma: 8
|
||||||
|
luck: 10
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- quick_strike
|
||||||
|
- dirty_trick
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: bandit_sword
|
||||||
|
drop_chance: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- item_id: leather_armor
|
||||||
|
drop_chance: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- item_id: lockpick
|
||||||
|
drop_chance: 0.25
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
- item_id: gold_coin
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 5
|
||||||
|
quantity_max: 20
|
||||||
|
|
||||||
|
experience_reward: 35
|
||||||
|
gold_reward_min: 10
|
||||||
|
gold_reward_max: 30
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- humanoid
|
||||||
|
- rogue
|
||||||
|
- armed
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- wilderness
|
||||||
|
- road
|
||||||
|
|
||||||
|
base_damage: 8
|
||||||
|
crit_chance: 0.12
|
||||||
|
flee_chance: 0.45
|
||||||
56
api/app/data/enemies/dire_wolf.yaml
Normal file
56
api/app/data/enemies/dire_wolf.yaml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Dire Wolf - Medium beast enemy
|
||||||
|
# A large, ferocious predator
|
||||||
|
|
||||||
|
enemy_id: dire_wolf
|
||||||
|
name: Dire Wolf
|
||||||
|
description: >
|
||||||
|
A massive wolf the size of a horse, with matted black fur and eyes
|
||||||
|
that glow with predatory intelligence. Its fangs are as long as daggers,
|
||||||
|
and its growl rumbles like distant thunder.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 14
|
||||||
|
dexterity: 14
|
||||||
|
constitution: 12
|
||||||
|
intelligence: 4
|
||||||
|
wisdom: 10
|
||||||
|
charisma: 6
|
||||||
|
luck: 8
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- savage_bite
|
||||||
|
- pack_howl
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: wolf_pelt
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- item_id: wolf_fang
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- item_id: beast_meat
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
|
||||||
|
experience_reward: 40
|
||||||
|
gold_reward_min: 0
|
||||||
|
gold_reward_max: 5
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- beast
|
||||||
|
- wolf
|
||||||
|
- large
|
||||||
|
- pack
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- forest
|
||||||
|
- wilderness
|
||||||
|
|
||||||
|
base_damage: 10
|
||||||
|
crit_chance: 0.10
|
||||||
|
flee_chance: 0.40
|
||||||
50
api/app/data/enemies/goblin.yaml
Normal file
50
api/app/data/enemies/goblin.yaml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Goblin - Easy melee enemy (STR-focused)
|
||||||
|
# A small, cunning creature that attacks in groups
|
||||||
|
|
||||||
|
enemy_id: goblin
|
||||||
|
name: Goblin Scout
|
||||||
|
description: >
|
||||||
|
A small, green-skinned creature with pointed ears and sharp teeth.
|
||||||
|
Goblins are cowardly alone but dangerous in groups, using crude weapons
|
||||||
|
and dirty tactics to overwhelm their prey.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 8
|
||||||
|
dexterity: 12
|
||||||
|
constitution: 6
|
||||||
|
intelligence: 6
|
||||||
|
wisdom: 6
|
||||||
|
charisma: 4
|
||||||
|
luck: 8
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: rusty_dagger
|
||||||
|
drop_chance: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- item_id: gold_coin
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
|
||||||
|
experience_reward: 15
|
||||||
|
gold_reward_min: 2
|
||||||
|
gold_reward_max: 8
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- humanoid
|
||||||
|
- goblinoid
|
||||||
|
- small
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- forest
|
||||||
|
- wilderness
|
||||||
|
- dungeon
|
||||||
|
|
||||||
|
base_damage: 4
|
||||||
|
crit_chance: 0.05
|
||||||
|
flee_chance: 0.60
|
||||||
90
api/app/data/enemies/goblin_chieftain.yaml
Normal file
90
api/app/data/enemies/goblin_chieftain.yaml
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Goblin Chieftain - Hard variant, elite tribe leader
|
||||||
|
# A cunning and powerful goblin leader, adorned with trophies.
|
||||||
|
# Commands respect through fear and violence, drops quality loot.
|
||||||
|
|
||||||
|
enemy_id: goblin_chieftain
|
||||||
|
name: Goblin Chieftain
|
||||||
|
description: >
|
||||||
|
A large, scarred goblin wearing a crown of teeth and bones.
|
||||||
|
The chieftain has clawed its way to leadership through countless
|
||||||
|
battles and betrayals. It wields a well-maintained weapon stolen
|
||||||
|
from a fallen adventurer and commands its tribe with an iron fist.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 16
|
||||||
|
dexterity: 12
|
||||||
|
constitution: 14
|
||||||
|
intelligence: 10
|
||||||
|
wisdom: 10
|
||||||
|
charisma: 12
|
||||||
|
luck: 12
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- shield_bash
|
||||||
|
- intimidating_shout
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
# Static drops - guaranteed materials
|
||||||
|
- loot_type: static
|
||||||
|
item_id: goblin_ear
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 3
|
||||||
|
- loot_type: static
|
||||||
|
item_id: goblin_chieftain_token
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: goblin_war_paint
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
|
||||||
|
# Consumable drops
|
||||||
|
- loot_type: static
|
||||||
|
item_id: health_potion_medium
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: elixir_of_strength
|
||||||
|
drop_chance: 0.10
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Procedural equipment drops - higher chance and rarity bonus
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: weapon
|
||||||
|
drop_chance: 0.25
|
||||||
|
rarity_bonus: 0.10
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: armor
|
||||||
|
drop_chance: 0.15
|
||||||
|
rarity_bonus: 0.05
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 75
|
||||||
|
gold_reward_min: 20
|
||||||
|
gold_reward_max: 50
|
||||||
|
difficulty: hard
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- humanoid
|
||||||
|
- goblinoid
|
||||||
|
- leader
|
||||||
|
- elite
|
||||||
|
- armed
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- forest
|
||||||
|
- wilderness
|
||||||
|
- dungeon
|
||||||
|
|
||||||
|
base_damage: 14
|
||||||
|
crit_chance: 0.15
|
||||||
|
flee_chance: 0.25
|
||||||
61
api/app/data/enemies/goblin_scout.yaml
Normal file
61
api/app/data/enemies/goblin_scout.yaml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Goblin Scout - Easy variant, agile but fragile
|
||||||
|
# A fast, cowardly goblin that serves as a lookout for its tribe.
|
||||||
|
# Quick to flee, drops minor loot and the occasional small potion.
|
||||||
|
|
||||||
|
enemy_id: goblin_scout
|
||||||
|
name: Goblin Scout
|
||||||
|
description: >
|
||||||
|
A small, wiry goblin with oversized ears and beady yellow eyes.
|
||||||
|
Goblin scouts are the first line of awareness for their tribes,
|
||||||
|
often found lurking in shadows or perched in trees. They prefer
|
||||||
|
to run rather than fight, but will attack if cornered.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 6
|
||||||
|
dexterity: 14
|
||||||
|
constitution: 5
|
||||||
|
intelligence: 6
|
||||||
|
wisdom: 10
|
||||||
|
charisma: 4
|
||||||
|
luck: 10
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
# Static drops - materials and consumables
|
||||||
|
- loot_type: static
|
||||||
|
item_id: goblin_ear
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: goblin_trinket
|
||||||
|
drop_chance: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: health_potion_small
|
||||||
|
drop_chance: 0.08
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 10
|
||||||
|
gold_reward_min: 1
|
||||||
|
gold_reward_max: 4
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- humanoid
|
||||||
|
- goblinoid
|
||||||
|
- small
|
||||||
|
- scout
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- forest
|
||||||
|
- wilderness
|
||||||
|
- dungeon
|
||||||
|
|
||||||
|
base_damage: 3
|
||||||
|
crit_chance: 0.08
|
||||||
|
flee_chance: 0.70
|
||||||
57
api/app/data/enemies/goblin_shaman.yaml
Normal file
57
api/app/data/enemies/goblin_shaman.yaml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Goblin Shaman - Easy caster enemy (INT-focused)
|
||||||
|
# A goblin spellcaster that provides magical support
|
||||||
|
|
||||||
|
enemy_id: goblin_shaman
|
||||||
|
name: Goblin Shaman
|
||||||
|
description: >
|
||||||
|
A hunched goblin wrapped in tattered robes, clutching a staff adorned
|
||||||
|
with bones and feathers. It mutters dark incantations and hurls bolts
|
||||||
|
of sickly green fire at its enemies.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 4
|
||||||
|
dexterity: 10
|
||||||
|
constitution: 6
|
||||||
|
intelligence: 12
|
||||||
|
wisdom: 10
|
||||||
|
charisma: 6
|
||||||
|
luck: 10
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- fire_bolt
|
||||||
|
- minor_heal
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: shaman_staff
|
||||||
|
drop_chance: 0.10
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- item_id: mana_potion_small
|
||||||
|
drop_chance: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- item_id: gold_coin
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 3
|
||||||
|
quantity_max: 8
|
||||||
|
|
||||||
|
experience_reward: 25
|
||||||
|
gold_reward_min: 5
|
||||||
|
gold_reward_max: 15
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- humanoid
|
||||||
|
- goblinoid
|
||||||
|
- caster
|
||||||
|
- small
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- forest
|
||||||
|
- wilderness
|
||||||
|
- dungeon
|
||||||
|
|
||||||
|
base_damage: 3
|
||||||
|
crit_chance: 0.08
|
||||||
|
flee_chance: 0.55
|
||||||
75
api/app/data/enemies/goblin_warrior.yaml
Normal file
75
api/app/data/enemies/goblin_warrior.yaml
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Goblin Warrior - Medium variant, trained fighter
|
||||||
|
# A battle-hardened goblin wielding crude but effective weapons.
|
||||||
|
# More dangerous than scouts, fights in organized groups.
|
||||||
|
|
||||||
|
enemy_id: goblin_warrior
|
||||||
|
name: Goblin Warrior
|
||||||
|
description: >
|
||||||
|
A muscular goblin clad in scavenged armor and wielding a crude
|
||||||
|
but deadly weapon. Goblin warriors are the backbone of any goblin
|
||||||
|
warband, trained to fight rather than flee. They attack with
|
||||||
|
surprising ferocity and coordination.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 12
|
||||||
|
dexterity: 10
|
||||||
|
constitution: 10
|
||||||
|
intelligence: 6
|
||||||
|
wisdom: 6
|
||||||
|
charisma: 4
|
||||||
|
luck: 8
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- shield_bash
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
# Static drops - materials and consumables
|
||||||
|
- loot_type: static
|
||||||
|
item_id: goblin_ear
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: goblin_war_paint
|
||||||
|
drop_chance: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: health_potion_small
|
||||||
|
drop_chance: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: iron_ore
|
||||||
|
drop_chance: 0.10
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
|
||||||
|
# Procedural equipment drops
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: weapon
|
||||||
|
drop_chance: 0.08
|
||||||
|
rarity_bonus: 0.0
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 25
|
||||||
|
gold_reward_min: 5
|
||||||
|
gold_reward_max: 15
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- humanoid
|
||||||
|
- goblinoid
|
||||||
|
- warrior
|
||||||
|
- armed
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- forest
|
||||||
|
- wilderness
|
||||||
|
- dungeon
|
||||||
|
|
||||||
|
base_damage: 8
|
||||||
|
crit_chance: 0.10
|
||||||
|
flee_chance: 0.45
|
||||||
58
api/app/data/enemies/harpy.yaml
Normal file
58
api/app/data/enemies/harpy.yaml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Harpy - Easy flying humanoid
|
||||||
|
# A vicious bird-woman that attacks from above
|
||||||
|
|
||||||
|
enemy_id: harpy
|
||||||
|
name: Harpy
|
||||||
|
description: >
|
||||||
|
A creature with the body of a vulture and the head and torso
|
||||||
|
of a woman, though twisted by cruelty into something inhuman.
|
||||||
|
Matted feathers cover her body, and her hands end in razor-sharp
|
||||||
|
talons. She shrieks with maddening fury as she dives at her prey.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 8
|
||||||
|
dexterity: 14
|
||||||
|
constitution: 8
|
||||||
|
intelligence: 6
|
||||||
|
wisdom: 10
|
||||||
|
charisma: 10
|
||||||
|
luck: 8
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- talon_slash
|
||||||
|
- dive_attack
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: harpy_feather
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 5
|
||||||
|
- item_id: harpy_talon
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- item_id: gold_coin
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 10
|
||||||
|
|
||||||
|
experience_reward: 22
|
||||||
|
gold_reward_min: 3
|
||||||
|
gold_reward_max: 12
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- monstrosity
|
||||||
|
- harpy
|
||||||
|
- flying
|
||||||
|
- female
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- mountain
|
||||||
|
- cliff
|
||||||
|
- ruins
|
||||||
|
|
||||||
|
base_damage: 6
|
||||||
|
crit_chance: 0.10
|
||||||
|
flee_chance: 0.55
|
||||||
119
api/app/data/enemies/harpy_matriarch.yaml
Normal file
119
api/app/data/enemies/harpy_matriarch.yaml
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Harpy Matriarch - Hard elite leader
|
||||||
|
# The ancient ruler of a harpy flock
|
||||||
|
|
||||||
|
enemy_id: harpy_matriarch
|
||||||
|
name: Harpy Matriarch
|
||||||
|
description: >
|
||||||
|
An ancient harpy of terrible beauty and cruelty, her plumage
|
||||||
|
a striking mix of midnight black and blood red. She towers
|
||||||
|
over her lesser kin, her voice carrying both enchanting allure
|
||||||
|
and devastating power. The Matriarch rules her flock absolutely,
|
||||||
|
and her nest is decorated with the bones and treasures of
|
||||||
|
countless victims.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 12
|
||||||
|
dexterity: 16
|
||||||
|
constitution: 14
|
||||||
|
intelligence: 12
|
||||||
|
wisdom: 14
|
||||||
|
charisma: 20
|
||||||
|
luck: 12
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- talon_slash
|
||||||
|
- dive_attack
|
||||||
|
- stunning_screech
|
||||||
|
- luring_song
|
||||||
|
- sonic_blast
|
||||||
|
- call_flock
|
||||||
|
- wing_buffet
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
# Static drops - guaranteed materials
|
||||||
|
- loot_type: static
|
||||||
|
item_id: harpy_feather
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 5
|
||||||
|
quantity_max: 10
|
||||||
|
- loot_type: static
|
||||||
|
item_id: harpy_talon
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
- loot_type: static
|
||||||
|
item_id: matriarch_plume
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: screamer_vocal_cords
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Nest treasures
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 30
|
||||||
|
quantity_max: 80
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gemstone
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: silver_ring
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Consumables
|
||||||
|
- loot_type: static
|
||||||
|
item_id: health_potion_medium
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: elixir_of_grace
|
||||||
|
drop_chance: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Procedural equipment
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: accessory
|
||||||
|
drop_chance: 0.25
|
||||||
|
rarity_bonus: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: armor
|
||||||
|
drop_chance: 0.20
|
||||||
|
rarity_bonus: 0.10
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 90
|
||||||
|
gold_reward_min: 40
|
||||||
|
gold_reward_max: 100
|
||||||
|
difficulty: hard
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- monstrosity
|
||||||
|
- harpy
|
||||||
|
- flying
|
||||||
|
- leader
|
||||||
|
- elite
|
||||||
|
- sonic
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- mountain
|
||||||
|
- cliff
|
||||||
|
- ruins
|
||||||
|
|
||||||
|
base_damage: 12
|
||||||
|
crit_chance: 0.15
|
||||||
|
flee_chance: 0.20
|
||||||
64
api/app/data/enemies/harpy_scout.yaml
Normal file
64
api/app/data/enemies/harpy_scout.yaml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Harpy Scout - Easy fast variant
|
||||||
|
# A swift harpy that spots prey from great distances
|
||||||
|
|
||||||
|
enemy_id: harpy_scout
|
||||||
|
name: Harpy Scout
|
||||||
|
description: >
|
||||||
|
A sleek harpy with dark plumage that blends with the sky.
|
||||||
|
Scouts are the eyes of their flock, ranging far ahead to
|
||||||
|
spot potential prey. They are faster and more agile than
|
||||||
|
common harpies, preferring quick strikes and retreat over
|
||||||
|
prolonged combat.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 6
|
||||||
|
dexterity: 18
|
||||||
|
constitution: 6
|
||||||
|
intelligence: 8
|
||||||
|
wisdom: 12
|
||||||
|
charisma: 8
|
||||||
|
luck: 10
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- talon_slash
|
||||||
|
- dive_attack
|
||||||
|
- evasive_flight
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: harpy_feather
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 3
|
||||||
|
quantity_max: 6
|
||||||
|
- loot_type: static
|
||||||
|
item_id: swift_wing_feather
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 0.45
|
||||||
|
quantity_min: 3
|
||||||
|
quantity_max: 12
|
||||||
|
|
||||||
|
experience_reward: 25
|
||||||
|
gold_reward_min: 5
|
||||||
|
gold_reward_max: 15
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- monstrosity
|
||||||
|
- harpy
|
||||||
|
- flying
|
||||||
|
- scout
|
||||||
|
- fast
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- mountain
|
||||||
|
- cliff
|
||||||
|
- wilderness
|
||||||
|
|
||||||
|
base_damage: 5
|
||||||
|
crit_chance: 0.15
|
||||||
|
flee_chance: 0.65
|
||||||
76
api/app/data/enemies/harpy_screamer.yaml
Normal file
76
api/app/data/enemies/harpy_screamer.yaml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Harpy Screamer - Medium sonic attacker
|
||||||
|
# A harpy whose voice is a weapon
|
||||||
|
|
||||||
|
enemy_id: harpy_screamer
|
||||||
|
name: Harpy Screamer
|
||||||
|
description: >
|
||||||
|
A harpy with an unnaturally large throat that bulges when
|
||||||
|
she takes breath. Her voice is her deadliest weapon, capable
|
||||||
|
of shattering stone and stunning prey into helplessness.
|
||||||
|
The screamer's song lures victims close before unleashing
|
||||||
|
a devastating sonic assault.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 6
|
||||||
|
dexterity: 12
|
||||||
|
constitution: 10
|
||||||
|
intelligence: 8
|
||||||
|
wisdom: 12
|
||||||
|
charisma: 16
|
||||||
|
luck: 10
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- talon_slash
|
||||||
|
- stunning_screech
|
||||||
|
- luring_song
|
||||||
|
- sonic_blast
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: harpy_feather
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
- loot_type: static
|
||||||
|
item_id: harpy_talon
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: screamer_vocal_cords
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 0.55
|
||||||
|
quantity_min: 5
|
||||||
|
quantity_max: 18
|
||||||
|
- loot_type: static
|
||||||
|
item_id: earwax_plug
|
||||||
|
drop_chance: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
|
||||||
|
experience_reward: 40
|
||||||
|
gold_reward_min: 10
|
||||||
|
gold_reward_max: 25
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- monstrosity
|
||||||
|
- harpy
|
||||||
|
- flying
|
||||||
|
- sonic
|
||||||
|
- dangerous
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- mountain
|
||||||
|
- cliff
|
||||||
|
- ruins
|
||||||
|
- coast
|
||||||
|
|
||||||
|
base_damage: 5
|
||||||
|
crit_chance: 0.12
|
||||||
|
flee_chance: 0.45
|
||||||
64
api/app/data/enemies/imp.yaml
Normal file
64
api/app/data/enemies/imp.yaml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Imp - Easy minor demon
|
||||||
|
# A small, mischievous fiend from the lower planes
|
||||||
|
|
||||||
|
enemy_id: imp
|
||||||
|
name: Imp
|
||||||
|
description: >
|
||||||
|
A tiny red-skinned devil no larger than a cat, with leathery
|
||||||
|
bat wings, a barbed tail, and small curved horns. Its beady
|
||||||
|
yellow eyes gleam with malicious intelligence, and it cackles
|
||||||
|
as it hurls tiny bolts of hellfire at its victims.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 4
|
||||||
|
dexterity: 16
|
||||||
|
constitution: 6
|
||||||
|
intelligence: 10
|
||||||
|
wisdom: 10
|
||||||
|
charisma: 8
|
||||||
|
luck: 12
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- fire_bolt
|
||||||
|
- invisibility
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: imp_horn
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- item_id: hellfire_ember
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- item_id: demon_blood
|
||||||
|
drop_chance: 0.25
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- item_id: gold_coin
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 8
|
||||||
|
|
||||||
|
experience_reward: 18
|
||||||
|
gold_reward_min: 3
|
||||||
|
gold_reward_max: 10
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- fiend
|
||||||
|
- demon
|
||||||
|
- imp
|
||||||
|
- small
|
||||||
|
- flying
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- ruins
|
||||||
|
- hellscape
|
||||||
|
- volcano
|
||||||
|
|
||||||
|
base_damage: 4
|
||||||
|
crit_chance: 0.10
|
||||||
|
flee_chance: 0.60
|
||||||
76
api/app/data/enemies/imp_fiery.yaml
Normal file
76
api/app/data/enemies/imp_fiery.yaml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Fiery Imp - Medium fire-focused variant
|
||||||
|
# An imp that has absorbed excess hellfire
|
||||||
|
|
||||||
|
enemy_id: imp_fiery
|
||||||
|
name: Fiery Imp
|
||||||
|
description: >
|
||||||
|
An imp wreathed in flickering flames, its body glowing like
|
||||||
|
hot coals. Flames dance along its wings and trail from its
|
||||||
|
barbed tail. It has absorbed so much hellfire that its very
|
||||||
|
touch ignites whatever it touches, and it can unleash
|
||||||
|
devastating bursts of flame.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 6
|
||||||
|
dexterity: 14
|
||||||
|
constitution: 10
|
||||||
|
intelligence: 12
|
||||||
|
wisdom: 10
|
||||||
|
charisma: 10
|
||||||
|
luck: 12
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- fire_bolt
|
||||||
|
- flame_burst
|
||||||
|
- fire_shield
|
||||||
|
- immolate
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: imp_horn
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: hellfire_ember
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 3
|
||||||
|
- loot_type: static
|
||||||
|
item_id: demon_blood
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: fire_essence
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 0.45
|
||||||
|
quantity_min: 5
|
||||||
|
quantity_max: 15
|
||||||
|
|
||||||
|
experience_reward: 35
|
||||||
|
gold_reward_min: 8
|
||||||
|
gold_reward_max: 20
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- fiend
|
||||||
|
- demon
|
||||||
|
- imp
|
||||||
|
- small
|
||||||
|
- flying
|
||||||
|
- fire
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- hellscape
|
||||||
|
- volcano
|
||||||
|
|
||||||
|
base_damage: 6
|
||||||
|
crit_chance: 0.12
|
||||||
|
flee_chance: 0.50
|
||||||
116
api/app/data/enemies/imp_overlord.yaml
Normal file
116
api/app/data/enemies/imp_overlord.yaml
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Imp Overlord - Hard elite variant
|
||||||
|
# A greater imp that commands lesser fiends
|
||||||
|
|
||||||
|
enemy_id: imp_overlord
|
||||||
|
name: Imp Overlord
|
||||||
|
description: >
|
||||||
|
A massive imp standing three feet tall, its body crackling
|
||||||
|
with infernal power. Unlike its lesser kin, the Overlord
|
||||||
|
has earned power through cunning deals and brutal dominance.
|
||||||
|
It wears a tiny crown of blackened iron and commands squads
|
||||||
|
of lesser imps to do its bidding, all while hurling deadly
|
||||||
|
magic at its enemies.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 10
|
||||||
|
dexterity: 16
|
||||||
|
constitution: 14
|
||||||
|
intelligence: 18
|
||||||
|
wisdom: 14
|
||||||
|
charisma: 16
|
||||||
|
luck: 14
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- fire_bolt
|
||||||
|
- flame_burst
|
||||||
|
- invisibility
|
||||||
|
- summon_imps
|
||||||
|
- hellfire_blast
|
||||||
|
- dark_pact
|
||||||
|
- magic_shield
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
# Static drops - guaranteed materials
|
||||||
|
- loot_type: static
|
||||||
|
item_id: imp_horn
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
- loot_type: static
|
||||||
|
item_id: hellfire_ember
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 3
|
||||||
|
quantity_max: 5
|
||||||
|
- loot_type: static
|
||||||
|
item_id: demon_blood
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 3
|
||||||
|
- loot_type: static
|
||||||
|
item_id: overlord_crown
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: infernal_contract
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Consumables
|
||||||
|
- loot_type: static
|
||||||
|
item_id: mana_potion_medium
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: elixir_of_intellect
|
||||||
|
drop_chance: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Treasure
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 25
|
||||||
|
quantity_max: 60
|
||||||
|
|
||||||
|
# Procedural equipment
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: weapon
|
||||||
|
drop_chance: 0.20
|
||||||
|
rarity_bonus: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: accessory
|
||||||
|
drop_chance: 0.25
|
||||||
|
rarity_bonus: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 85
|
||||||
|
gold_reward_min: 35
|
||||||
|
gold_reward_max: 80
|
||||||
|
difficulty: hard
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- fiend
|
||||||
|
- demon
|
||||||
|
- imp
|
||||||
|
- leader
|
||||||
|
- elite
|
||||||
|
- flying
|
||||||
|
- caster
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- ruins
|
||||||
|
- hellscape
|
||||||
|
- volcano
|
||||||
|
|
||||||
|
base_damage: 10
|
||||||
|
crit_chance: 0.15
|
||||||
|
flee_chance: 0.25
|
||||||
77
api/app/data/enemies/imp_trickster.yaml
Normal file
77
api/app/data/enemies/imp_trickster.yaml
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Imp Trickster - Medium cunning variant
|
||||||
|
# A devious imp that excels in deception and mischief
|
||||||
|
|
||||||
|
enemy_id: imp_trickster
|
||||||
|
name: Imp Trickster
|
||||||
|
description: >
|
||||||
|
A clever imp with a knowing smirk and eyes that gleam with
|
||||||
|
cunning. It prefers tricks and illusions to direct combat,
|
||||||
|
creating phantom doubles, stealing equipment, and leading
|
||||||
|
adventurers into traps. It chatters constantly, mocking its
|
||||||
|
victims in a high-pitched voice.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 4
|
||||||
|
dexterity: 18
|
||||||
|
constitution: 6
|
||||||
|
intelligence: 16
|
||||||
|
wisdom: 12
|
||||||
|
charisma: 14
|
||||||
|
luck: 16
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- fire_bolt
|
||||||
|
- invisibility
|
||||||
|
- mirror_image
|
||||||
|
- steal_item
|
||||||
|
- confusion
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: imp_horn
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: hellfire_ember
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: trickster_charm
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: arcane_dust
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 10
|
||||||
|
quantity_max: 30
|
||||||
|
|
||||||
|
experience_reward: 40
|
||||||
|
gold_reward_min: 15
|
||||||
|
gold_reward_max: 35
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- fiend
|
||||||
|
- demon
|
||||||
|
- imp
|
||||||
|
- small
|
||||||
|
- flying
|
||||||
|
- trickster
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- ruins
|
||||||
|
- hellscape
|
||||||
|
|
||||||
|
base_damage: 4
|
||||||
|
crit_chance: 0.15
|
||||||
|
flee_chance: 0.65
|
||||||
58
api/app/data/enemies/kobold.yaml
Normal file
58
api/app/data/enemies/kobold.yaml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Kobold - Easy small reptilian humanoid
|
||||||
|
# A cunning, trap-loving creature that serves dragons
|
||||||
|
|
||||||
|
enemy_id: kobold
|
||||||
|
name: Kobold
|
||||||
|
description: >
|
||||||
|
A small, dog-like reptilian creature with rusty orange scales
|
||||||
|
and a long snout filled with sharp teeth. Kobolds are cowardly
|
||||||
|
individually but cunning in groups, preferring traps and ambushes
|
||||||
|
to fair fights. They worship dragons with fanatical devotion.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 6
|
||||||
|
dexterity: 14
|
||||||
|
constitution: 6
|
||||||
|
intelligence: 8
|
||||||
|
wisdom: 8
|
||||||
|
charisma: 4
|
||||||
|
luck: 10
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- pack_tactics
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: kobold_scale
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
- item_id: crude_trap_parts
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- item_id: gold_coin
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 5
|
||||||
|
|
||||||
|
experience_reward: 12
|
||||||
|
gold_reward_min: 2
|
||||||
|
gold_reward_max: 8
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- humanoid
|
||||||
|
- kobold
|
||||||
|
- reptilian
|
||||||
|
- small
|
||||||
|
- pack
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- cave
|
||||||
|
- mine
|
||||||
|
|
||||||
|
base_damage: 4
|
||||||
|
crit_chance: 0.06
|
||||||
|
flee_chance: 0.65
|
||||||
76
api/app/data/enemies/kobold_sorcerer.yaml
Normal file
76
api/app/data/enemies/kobold_sorcerer.yaml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Kobold Sorcerer - Medium caster variant
|
||||||
|
# A kobold with innate draconic magic
|
||||||
|
|
||||||
|
enemy_id: kobold_sorcerer
|
||||||
|
name: Kobold Sorcerer
|
||||||
|
description: >
|
||||||
|
A kobold with scales that shimmer with an inner light, marking
|
||||||
|
it as one blessed by dragon blood. Small horns sprout from its
|
||||||
|
head, and its eyes glow with arcane power. Sorcerers are rare
|
||||||
|
among kobolds and treated with reverence, channeling the magic
|
||||||
|
of their dragon masters.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 4
|
||||||
|
dexterity: 12
|
||||||
|
constitution: 8
|
||||||
|
intelligence: 14
|
||||||
|
wisdom: 12
|
||||||
|
charisma: 12
|
||||||
|
luck: 12
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- fire_bolt
|
||||||
|
- dragon_breath
|
||||||
|
- magic_shield
|
||||||
|
- minor_heal
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: kobold_scale
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: dragon_scale_fragment
|
||||||
|
drop_chance: 0.25
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: mana_potion_small
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: arcane_dust
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 5
|
||||||
|
quantity_max: 15
|
||||||
|
|
||||||
|
experience_reward: 35
|
||||||
|
gold_reward_min: 10
|
||||||
|
gold_reward_max: 25
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- humanoid
|
||||||
|
- kobold
|
||||||
|
- reptilian
|
||||||
|
- small
|
||||||
|
- caster
|
||||||
|
- draconic
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- cave
|
||||||
|
- mine
|
||||||
|
|
||||||
|
base_damage: 4
|
||||||
|
crit_chance: 0.10
|
||||||
|
flee_chance: 0.50
|
||||||
91
api/app/data/enemies/kobold_taskmaster.yaml
Normal file
91
api/app/data/enemies/kobold_taskmaster.yaml
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Kobold Taskmaster - Hard leader variant
|
||||||
|
# A brutal kobold leader that drives its minions to fight
|
||||||
|
|
||||||
|
enemy_id: kobold_taskmaster
|
||||||
|
name: Kobold Taskmaster
|
||||||
|
description: >
|
||||||
|
A larger-than-average kobold with scarred scales and a cruel
|
||||||
|
gleam in its eyes. It carries a whip in one hand and a curved
|
||||||
|
blade in the other, driving lesser kobolds before it with
|
||||||
|
threats and violence. Taskmasters answer only to the dragon
|
||||||
|
their warren serves, and rule their kin through fear.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 12
|
||||||
|
dexterity: 14
|
||||||
|
constitution: 12
|
||||||
|
intelligence: 12
|
||||||
|
wisdom: 10
|
||||||
|
charisma: 14
|
||||||
|
luck: 10
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- whip_crack
|
||||||
|
- rally_minions
|
||||||
|
- sneak_attack
|
||||||
|
- intimidating_shout
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
# Static drops
|
||||||
|
- loot_type: static
|
||||||
|
item_id: kobold_scale
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
- loot_type: static
|
||||||
|
item_id: taskmaster_whip
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: dragon_scale_fragment
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: kobold_chief_token
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Consumables
|
||||||
|
- loot_type: static
|
||||||
|
item_id: health_potion_small
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: alchemist_fire
|
||||||
|
drop_chance: 0.25
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
|
||||||
|
# Procedural equipment
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: weapon
|
||||||
|
drop_chance: 0.20
|
||||||
|
rarity_bonus: 0.10
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 60
|
||||||
|
gold_reward_min: 20
|
||||||
|
gold_reward_max: 45
|
||||||
|
difficulty: hard
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- humanoid
|
||||||
|
- kobold
|
||||||
|
- reptilian
|
||||||
|
- leader
|
||||||
|
- elite
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- cave
|
||||||
|
- mine
|
||||||
|
|
||||||
|
base_damage: 10
|
||||||
|
crit_chance: 0.12
|
||||||
|
flee_chance: 0.30
|
||||||
73
api/app/data/enemies/kobold_trapper.yaml
Normal file
73
api/app/data/enemies/kobold_trapper.yaml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Kobold Trapper - Easy specialist variant
|
||||||
|
# A kobold expert in traps and ambush tactics
|
||||||
|
|
||||||
|
enemy_id: kobold_trapper
|
||||||
|
name: Kobold Trapper
|
||||||
|
description: >
|
||||||
|
A kobold with a bandolier of alchemical vials and pouches full
|
||||||
|
of trap components. Its scales are stained with various chemicals,
|
||||||
|
and it moves with practiced stealth. Trappers are valued members
|
||||||
|
of any kobold warren, turning simple tunnels into death mazes.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 6
|
||||||
|
dexterity: 16
|
||||||
|
constitution: 6
|
||||||
|
intelligence: 12
|
||||||
|
wisdom: 10
|
||||||
|
charisma: 4
|
||||||
|
luck: 12
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- lay_trap
|
||||||
|
- alchemist_fire
|
||||||
|
- sneak_attack
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: kobold_scale
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: trap_kit
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: alchemist_fire
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: caltrops
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 8
|
||||||
|
|
||||||
|
experience_reward: 20
|
||||||
|
gold_reward_min: 5
|
||||||
|
gold_reward_max: 15
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- humanoid
|
||||||
|
- kobold
|
||||||
|
- reptilian
|
||||||
|
- small
|
||||||
|
- trapper
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- cave
|
||||||
|
- mine
|
||||||
|
|
||||||
|
base_damage: 5
|
||||||
|
crit_chance: 0.12
|
||||||
|
flee_chance: 0.60
|
||||||
64
api/app/data/enemies/lizardfolk.yaml
Normal file
64
api/app/data/enemies/lizardfolk.yaml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Lizardfolk - Easy reptilian humanoid
|
||||||
|
# A cold-blooded tribal warrior from the swamps
|
||||||
|
|
||||||
|
enemy_id: lizardfolk
|
||||||
|
name: Lizardfolk
|
||||||
|
description: >
|
||||||
|
A muscular humanoid covered in green and brown scales, with
|
||||||
|
a long tail and a snout filled with sharp teeth. Lizardfolk
|
||||||
|
are pragmatic hunters who view all creatures as either
|
||||||
|
predator, prey, or irrelevant. This one carries a bone-tipped
|
||||||
|
spear and a shield made from a giant turtle shell.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 12
|
||||||
|
dexterity: 10
|
||||||
|
constitution: 12
|
||||||
|
intelligence: 6
|
||||||
|
wisdom: 10
|
||||||
|
charisma: 4
|
||||||
|
luck: 6
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- bite
|
||||||
|
- tail_swipe
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: lizard_scale
|
||||||
|
drop_chance: 0.65
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 5
|
||||||
|
- item_id: lizardfolk_spear
|
||||||
|
drop_chance: 0.25
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- item_id: beast_meat
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- item_id: gold_coin
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 8
|
||||||
|
|
||||||
|
experience_reward: 25
|
||||||
|
gold_reward_min: 3
|
||||||
|
gold_reward_max: 12
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- humanoid
|
||||||
|
- lizardfolk
|
||||||
|
- reptilian
|
||||||
|
- tribal
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- swamp
|
||||||
|
- river
|
||||||
|
- coast
|
||||||
|
- jungle
|
||||||
|
|
||||||
|
base_damage: 7
|
||||||
|
crit_chance: 0.08
|
||||||
|
flee_chance: 0.40
|
||||||
109
api/app/data/enemies/lizardfolk_champion.yaml
Normal file
109
api/app/data/enemies/lizardfolk_champion.yaml
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Lizardfolk Champion - Hard elite warrior
|
||||||
|
# The mightiest warrior of a lizardfolk tribe
|
||||||
|
|
||||||
|
enemy_id: lizardfolk_champion
|
||||||
|
name: Lizardfolk Champion
|
||||||
|
description: >
|
||||||
|
A massive lizardfolk standing over seven feet tall, its body
|
||||||
|
rippling with corded muscle beneath scarred, battle-hardened
|
||||||
|
scales. The Champion has proven itself through countless hunts
|
||||||
|
and trials, earning the right to wield the tribe's sacred
|
||||||
|
weapons. Its cold, calculating eyes assess every opponent
|
||||||
|
as prey to be efficiently slaughtered.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 18
|
||||||
|
dexterity: 12
|
||||||
|
constitution: 16
|
||||||
|
intelligence: 8
|
||||||
|
wisdom: 12
|
||||||
|
charisma: 10
|
||||||
|
luck: 8
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- bite
|
||||||
|
- tail_swipe
|
||||||
|
- cleave
|
||||||
|
- shield_bash
|
||||||
|
- frenzy
|
||||||
|
- primal_roar
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
# Static drops - guaranteed materials
|
||||||
|
- loot_type: static
|
||||||
|
item_id: lizard_scale
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 5
|
||||||
|
quantity_max: 10
|
||||||
|
- loot_type: static
|
||||||
|
item_id: champion_fang
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: sacred_lizardfolk_weapon
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: tribal_champion_token
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Consumables
|
||||||
|
- loot_type: static
|
||||||
|
item_id: health_potion_medium
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: elixir_of_strength
|
||||||
|
drop_chance: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Treasure
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 20
|
||||||
|
quantity_max: 55
|
||||||
|
|
||||||
|
# Procedural equipment
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: weapon
|
||||||
|
drop_chance: 0.25
|
||||||
|
rarity_bonus: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: armor
|
||||||
|
drop_chance: 0.20
|
||||||
|
rarity_bonus: 0.10
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 80
|
||||||
|
gold_reward_min: 30
|
||||||
|
gold_reward_max: 70
|
||||||
|
difficulty: hard
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- humanoid
|
||||||
|
- lizardfolk
|
||||||
|
- reptilian
|
||||||
|
- warrior
|
||||||
|
- leader
|
||||||
|
- elite
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- swamp
|
||||||
|
- river
|
||||||
|
- jungle
|
||||||
|
- dungeon
|
||||||
|
|
||||||
|
base_damage: 14
|
||||||
|
crit_chance: 0.12
|
||||||
|
flee_chance: 0.15
|
||||||
75
api/app/data/enemies/lizardfolk_hunter.yaml
Normal file
75
api/app/data/enemies/lizardfolk_hunter.yaml
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Lizardfolk Hunter - Easy ranged variant
|
||||||
|
# A skilled tracker and ambush predator
|
||||||
|
|
||||||
|
enemy_id: lizardfolk_hunter
|
||||||
|
name: Lizardfolk Hunter
|
||||||
|
description: >
|
||||||
|
A lean lizardfolk with mottled scales that blend perfectly
|
||||||
|
with swamp vegetation. Hunters are the elite scouts of their
|
||||||
|
tribes, tracking prey through marshes and striking from hiding
|
||||||
|
with poisoned javelins. Their patient, predatory nature makes
|
||||||
|
them terrifyingly effective ambushers.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 10
|
||||||
|
dexterity: 14
|
||||||
|
constitution: 10
|
||||||
|
intelligence: 8
|
||||||
|
wisdom: 14
|
||||||
|
charisma: 4
|
||||||
|
luck: 8
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- javelin_throw
|
||||||
|
- poison_dart
|
||||||
|
- camouflage
|
||||||
|
- sneak_attack
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: lizard_scale
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
- loot_type: static
|
||||||
|
item_id: poison_dart
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 5
|
||||||
|
- loot_type: static
|
||||||
|
item_id: hunter_javelin
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: swamp_poison
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 3
|
||||||
|
quantity_max: 12
|
||||||
|
|
||||||
|
experience_reward: 30
|
||||||
|
gold_reward_min: 5
|
||||||
|
gold_reward_max: 18
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- humanoid
|
||||||
|
- lizardfolk
|
||||||
|
- reptilian
|
||||||
|
- hunter
|
||||||
|
- ranged
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- swamp
|
||||||
|
- river
|
||||||
|
- jungle
|
||||||
|
|
||||||
|
base_damage: 6
|
||||||
|
crit_chance: 0.12
|
||||||
|
flee_chance: 0.45
|
||||||
89
api/app/data/enemies/lizardfolk_shaman.yaml
Normal file
89
api/app/data/enemies/lizardfolk_shaman.yaml
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Lizardfolk Shaman - Medium caster variant
|
||||||
|
# A spiritual leader who communes with primal spirits
|
||||||
|
|
||||||
|
enemy_id: lizardfolk_shaman
|
||||||
|
name: Lizardfolk Shaman
|
||||||
|
description: >
|
||||||
|
An elderly lizardfolk adorned with feathers, bones, and
|
||||||
|
mystical totems. The shaman communes with the spirits of
|
||||||
|
the swamp, calling upon ancestral power to curse enemies
|
||||||
|
and bless allies. Its scales have faded to pale green with
|
||||||
|
age, but its eyes burn with primal wisdom and power.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 8
|
||||||
|
dexterity: 10
|
||||||
|
constitution: 12
|
||||||
|
intelligence: 10
|
||||||
|
wisdom: 16
|
||||||
|
charisma: 12
|
||||||
|
luck: 10
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- spirit_bolt
|
||||||
|
- entangling_vines
|
||||||
|
- healing_waters
|
||||||
|
- curse_of_weakness
|
||||||
|
- summon_swamp_creature
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: lizard_scale
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
- loot_type: static
|
||||||
|
item_id: shaman_totem
|
||||||
|
drop_chance: 0.45
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: spirit_essence
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: swamp_herb
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
|
||||||
|
# Consumables
|
||||||
|
- loot_type: static
|
||||||
|
item_id: mana_potion_small
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: health_potion_small
|
||||||
|
drop_chance: 0.25
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 8
|
||||||
|
quantity_max: 25
|
||||||
|
|
||||||
|
experience_reward: 45
|
||||||
|
gold_reward_min: 12
|
||||||
|
gold_reward_max: 35
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- humanoid
|
||||||
|
- lizardfolk
|
||||||
|
- reptilian
|
||||||
|
- shaman
|
||||||
|
- caster
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- swamp
|
||||||
|
- river
|
||||||
|
- jungle
|
||||||
|
|
||||||
|
base_damage: 5
|
||||||
|
crit_chance: 0.10
|
||||||
|
flee_chance: 0.35
|
||||||
64
api/app/data/enemies/ogre.yaml
Normal file
64
api/app/data/enemies/ogre.yaml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Ogre - Medium brutish giant
|
||||||
|
# A dim-witted but dangerous giant humanoid
|
||||||
|
|
||||||
|
enemy_id: ogre
|
||||||
|
name: Ogre
|
||||||
|
description: >
|
||||||
|
A hulking brute standing ten feet tall with grayish-green skin,
|
||||||
|
a protruding gut, and a slack-jawed face of dull malevolence.
|
||||||
|
Ogres are simple creatures driven by hunger and greed, but their
|
||||||
|
immense strength makes them deadly opponents. This one carries
|
||||||
|
a massive club made from a tree trunk.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 18
|
||||||
|
dexterity: 6
|
||||||
|
constitution: 16
|
||||||
|
intelligence: 4
|
||||||
|
wisdom: 6
|
||||||
|
charisma: 4
|
||||||
|
luck: 6
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- crushing_blow
|
||||||
|
- grab
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: ogre_hide
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- item_id: ogre_tooth
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
- item_id: beast_meat
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
- item_id: gold_coin
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 10
|
||||||
|
quantity_max: 30
|
||||||
|
|
||||||
|
experience_reward: 55
|
||||||
|
gold_reward_min: 15
|
||||||
|
gold_reward_max: 40
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- giant
|
||||||
|
- ogre
|
||||||
|
- large
|
||||||
|
- brute
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- wilderness
|
||||||
|
- cave
|
||||||
|
- swamp
|
||||||
|
- dungeon
|
||||||
|
|
||||||
|
base_damage: 14
|
||||||
|
crit_chance: 0.08
|
||||||
|
flee_chance: 0.20
|
||||||
85
api/app/data/enemies/ogre_brute.yaml
Normal file
85
api/app/data/enemies/ogre_brute.yaml
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Ogre Brute - Medium armored variant
|
||||||
|
# An ogre equipped with scavenged armor and weapons
|
||||||
|
|
||||||
|
enemy_id: ogre_brute
|
||||||
|
name: Ogre Brute
|
||||||
|
description: >
|
||||||
|
A massive ogre wearing a patchwork of stolen armor plates
|
||||||
|
and wielding a crude greataxe forged by enslaved smiths.
|
||||||
|
Smarter and more disciplined than common ogres, brutes
|
||||||
|
serve as shock troops for warlords or guard valuable
|
||||||
|
territory. Its armor bears the dents and bloodstains
|
||||||
|
of many battles.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 20
|
||||||
|
dexterity: 6
|
||||||
|
constitution: 18
|
||||||
|
intelligence: 6
|
||||||
|
wisdom: 6
|
||||||
|
charisma: 4
|
||||||
|
luck: 6
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- crushing_blow
|
||||||
|
- cleave
|
||||||
|
- intimidating_shout
|
||||||
|
- grab
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: ogre_hide
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 3
|
||||||
|
- loot_type: static
|
||||||
|
item_id: ogre_tooth
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
- loot_type: static
|
||||||
|
item_id: iron_ore
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 20
|
||||||
|
quantity_max: 50
|
||||||
|
|
||||||
|
# Procedural equipment - scavenged gear
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: weapon
|
||||||
|
drop_chance: 0.15
|
||||||
|
rarity_bonus: 0.0
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: armor
|
||||||
|
drop_chance: 0.12
|
||||||
|
rarity_bonus: 0.0
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 70
|
||||||
|
gold_reward_min: 25
|
||||||
|
gold_reward_max: 55
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- giant
|
||||||
|
- ogre
|
||||||
|
- large
|
||||||
|
- brute
|
||||||
|
- armed
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- wilderness
|
||||||
|
- cave
|
||||||
|
- dungeon
|
||||||
|
|
||||||
|
base_damage: 16
|
||||||
|
crit_chance: 0.10
|
||||||
|
flee_chance: 0.15
|
||||||
118
api/app/data/enemies/ogre_chieftain.yaml
Normal file
118
api/app/data/enemies/ogre_chieftain.yaml
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Ogre Chieftain - Hard elite leader
|
||||||
|
# The massive ruler of an ogre tribe
|
||||||
|
|
||||||
|
enemy_id: ogre_chieftain
|
||||||
|
name: Ogre Chieftain
|
||||||
|
description: >
|
||||||
|
A mountain of muscle and fury standing nearly fifteen feet
|
||||||
|
tall, this ancient ogre has crushed all challengers to claim
|
||||||
|
leadership of its tribe. Covered in ritual scars and trophy
|
||||||
|
piercings, the Chieftain wears a necklace of skulls from
|
||||||
|
its greatest kills. It wields a massive maul that has ended
|
||||||
|
the lives of countless heroes.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 22
|
||||||
|
dexterity: 8
|
||||||
|
constitution: 22
|
||||||
|
intelligence: 8
|
||||||
|
wisdom: 10
|
||||||
|
charisma: 12
|
||||||
|
luck: 8
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- crushing_blow
|
||||||
|
- cleave
|
||||||
|
- ground_slam
|
||||||
|
- intimidating_shout
|
||||||
|
- berserker_rage
|
||||||
|
- grab
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
# Static drops - guaranteed materials
|
||||||
|
- loot_type: static
|
||||||
|
item_id: ogre_hide
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 3
|
||||||
|
quantity_max: 5
|
||||||
|
- loot_type: static
|
||||||
|
item_id: ogre_tooth
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 3
|
||||||
|
quantity_max: 6
|
||||||
|
- loot_type: static
|
||||||
|
item_id: chieftain_skull_necklace
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: ogre_warlord_token
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Consumables
|
||||||
|
- loot_type: static
|
||||||
|
item_id: health_potion_large
|
||||||
|
drop_chance: 0.45
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: elixir_of_strength
|
||||||
|
drop_chance: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: elixir_of_fortitude
|
||||||
|
drop_chance: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Treasure
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 50
|
||||||
|
quantity_max: 120
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gemstone
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
|
||||||
|
# Procedural equipment
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: weapon
|
||||||
|
drop_chance: 0.30
|
||||||
|
rarity_bonus: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: armor
|
||||||
|
drop_chance: 0.25
|
||||||
|
rarity_bonus: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 140
|
||||||
|
gold_reward_min: 60
|
||||||
|
gold_reward_max: 150
|
||||||
|
difficulty: hard
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- giant
|
||||||
|
- ogre
|
||||||
|
- large
|
||||||
|
- leader
|
||||||
|
- elite
|
||||||
|
- brute
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- wilderness
|
||||||
|
- cave
|
||||||
|
- dungeon
|
||||||
|
|
||||||
|
base_damage: 20
|
||||||
|
crit_chance: 0.12
|
||||||
|
flee_chance: 0.08
|
||||||
107
api/app/data/enemies/ogre_magi.yaml
Normal file
107
api/app/data/enemies/ogre_magi.yaml
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Ogre Magi - Hard caster variant
|
||||||
|
# A rare ogre with innate magical abilities
|
||||||
|
|
||||||
|
enemy_id: ogre_magi
|
||||||
|
name: Ogre Magi
|
||||||
|
description: >
|
||||||
|
A towering ogre with blue-tinged skin and eyes that glow
|
||||||
|
with arcane power. Unlike its brutish kin, the magi possesses
|
||||||
|
cunning intelligence and dangerous magical abilities. It wears
|
||||||
|
robes of stolen finery and carries a staff topped with a
|
||||||
|
glowing crystal. Ogre magi are natural leaders, using their
|
||||||
|
magic and intelligence to dominate lesser giants.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 16
|
||||||
|
dexterity: 8
|
||||||
|
constitution: 16
|
||||||
|
intelligence: 14
|
||||||
|
wisdom: 12
|
||||||
|
charisma: 12
|
||||||
|
luck: 10
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- crushing_blow
|
||||||
|
- frost_bolt
|
||||||
|
- darkness
|
||||||
|
- invisibility
|
||||||
|
- cone_of_cold
|
||||||
|
- fly
|
||||||
|
- regeneration
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: ogre_hide
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: magi_crystal
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: arcane_dust
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
- loot_type: static
|
||||||
|
item_id: frost_essence
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
|
||||||
|
# Consumables
|
||||||
|
- loot_type: static
|
||||||
|
item_id: mana_potion_medium
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: health_potion_medium
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Treasure
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 30
|
||||||
|
quantity_max: 70
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gemstone
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
|
||||||
|
# Procedural equipment
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: weapon
|
||||||
|
drop_chance: 0.20
|
||||||
|
rarity_bonus: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 95
|
||||||
|
gold_reward_min: 40
|
||||||
|
gold_reward_max: 90
|
||||||
|
difficulty: hard
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- giant
|
||||||
|
- ogre
|
||||||
|
- large
|
||||||
|
- caster
|
||||||
|
- intelligent
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- wilderness
|
||||||
|
- cave
|
||||||
|
- dungeon
|
||||||
|
- ruins
|
||||||
|
|
||||||
|
base_damage: 12
|
||||||
|
crit_chance: 0.12
|
||||||
|
flee_chance: 0.25
|
||||||
62
api/app/data/enemies/orc_berserker.yaml
Normal file
62
api/app/data/enemies/orc_berserker.yaml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Orc Berserker - Hard heavy hitter
|
||||||
|
# A fearsome orc warrior in a battle rage
|
||||||
|
|
||||||
|
enemy_id: orc_berserker
|
||||||
|
name: Orc Berserker
|
||||||
|
description: >
|
||||||
|
A towering mass of green muscle and fury, covered in tribal war paint
|
||||||
|
and scars from countless battles. Foam flecks at the corners of its
|
||||||
|
mouth as it swings a massive greataxe with terrifying speed. In its
|
||||||
|
battle rage, it feels no pain and shows no mercy.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 18
|
||||||
|
dexterity: 10
|
||||||
|
constitution: 16
|
||||||
|
intelligence: 6
|
||||||
|
wisdom: 6
|
||||||
|
charisma: 4
|
||||||
|
luck: 8
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- cleave
|
||||||
|
- berserker_rage
|
||||||
|
- intimidating_shout
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: orc_greataxe
|
||||||
|
drop_chance: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- item_id: orc_war_paint
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- item_id: beast_hide_armor
|
||||||
|
drop_chance: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- item_id: gold_coin
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 15
|
||||||
|
quantity_max: 40
|
||||||
|
|
||||||
|
experience_reward: 80
|
||||||
|
gold_reward_min: 20
|
||||||
|
gold_reward_max: 50
|
||||||
|
difficulty: hard
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- humanoid
|
||||||
|
- orc
|
||||||
|
- berserker
|
||||||
|
- large
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- wilderness
|
||||||
|
|
||||||
|
base_damage: 15
|
||||||
|
crit_chance: 0.15
|
||||||
|
flee_chance: 0.30
|
||||||
50
api/app/data/enemies/rat.yaml
Normal file
50
api/app/data/enemies/rat.yaml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Giant Rat - Very easy enemy for starting areas (town, village, tavern)
|
||||||
|
# A basic enemy for new players to learn combat mechanics
|
||||||
|
|
||||||
|
enemy_id: rat
|
||||||
|
name: Giant Rat
|
||||||
|
description: >
|
||||||
|
A mangy rat the size of a small dog. These vermin infest cellars,
|
||||||
|
sewers, and dark corners of civilization. Weak alone but annoying in packs.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 4
|
||||||
|
dexterity: 14
|
||||||
|
constitution: 4
|
||||||
|
intelligence: 2
|
||||||
|
wisdom: 8
|
||||||
|
charisma: 2
|
||||||
|
luck: 6
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: rat_tail
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- item_id: gold_coin
|
||||||
|
drop_chance: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
|
||||||
|
experience_reward: 5
|
||||||
|
gold_reward_min: 0
|
||||||
|
gold_reward_max: 2
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- beast
|
||||||
|
- vermin
|
||||||
|
- small
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- town
|
||||||
|
- village
|
||||||
|
- tavern
|
||||||
|
- dungeon
|
||||||
|
|
||||||
|
base_damage: 2
|
||||||
|
crit_chance: 0.03
|
||||||
|
flee_chance: 0.80
|
||||||
57
api/app/data/enemies/skeleton_warrior.yaml
Normal file
57
api/app/data/enemies/skeleton_warrior.yaml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Skeleton Warrior - Medium undead melee
|
||||||
|
# An animated skeleton wielding ancient weapons
|
||||||
|
|
||||||
|
enemy_id: skeleton_warrior
|
||||||
|
name: Skeleton Warrior
|
||||||
|
description: >
|
||||||
|
The animated remains of a long-dead soldier, held together by dark magic.
|
||||||
|
Its empty eye sockets glow with pale blue fire, and it wields a rusted
|
||||||
|
but deadly sword with unnatural precision. It knows no fear and feels no pain.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 12
|
||||||
|
dexterity: 10
|
||||||
|
constitution: 10
|
||||||
|
intelligence: 4
|
||||||
|
wisdom: 6
|
||||||
|
charisma: 2
|
||||||
|
luck: 6
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- shield_bash
|
||||||
|
- bone_rattle
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: ancient_sword
|
||||||
|
drop_chance: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- item_id: bone_fragment
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 5
|
||||||
|
- item_id: soul_essence
|
||||||
|
drop_chance: 0.10
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 45
|
||||||
|
gold_reward_min: 0
|
||||||
|
gold_reward_max: 10
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- undead
|
||||||
|
- skeleton
|
||||||
|
- armed
|
||||||
|
- fearless
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- crypt
|
||||||
|
- ruins
|
||||||
|
- dungeon
|
||||||
|
|
||||||
|
base_damage: 9
|
||||||
|
crit_chance: 0.08
|
||||||
|
flee_chance: 0.50
|
||||||
55
api/app/data/enemies/slime.yaml
Normal file
55
api/app/data/enemies/slime.yaml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Slime - Easy ooze creature
|
||||||
|
# A simple gelatinous creature that dissolves organic matter
|
||||||
|
|
||||||
|
enemy_id: slime
|
||||||
|
name: Green Slime
|
||||||
|
description: >
|
||||||
|
A quivering mass of translucent green ooze about the size of
|
||||||
|
a large dog. It moves by flowing across surfaces, extending
|
||||||
|
pseudopods to sense its surroundings. Its acidic body dissolves
|
||||||
|
organic matter on contact, making it a dangerous opponent
|
||||||
|
despite its mindless nature.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 8
|
||||||
|
dexterity: 4
|
||||||
|
constitution: 14
|
||||||
|
intelligence: 1
|
||||||
|
wisdom: 4
|
||||||
|
charisma: 1
|
||||||
|
luck: 4
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- acid_touch
|
||||||
|
- split
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: slime_residue
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
- item_id: acid_gland
|
||||||
|
drop_chance: 0.25
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 15
|
||||||
|
gold_reward_min: 0
|
||||||
|
gold_reward_max: 3
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- ooze
|
||||||
|
- slime
|
||||||
|
- acidic
|
||||||
|
- mindless
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- cave
|
||||||
|
- sewer
|
||||||
|
|
||||||
|
base_damage: 5
|
||||||
|
crit_chance: 0.02
|
||||||
|
flee_chance: 0.10
|
||||||
84
api/app/data/enemies/slime_giant.yaml
Normal file
84
api/app/data/enemies/slime_giant.yaml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Giant Slime - Medium large variant
|
||||||
|
# A massive slime that has consumed many victims
|
||||||
|
|
||||||
|
enemy_id: slime_giant
|
||||||
|
name: Giant Slime
|
||||||
|
description: >
|
||||||
|
A massive blob of semi-transparent ooze the size of a wagon,
|
||||||
|
filled with the partially dissolved remains of its many victims.
|
||||||
|
Bones, armor, and the occasional glint of treasure can be seen
|
||||||
|
slowly dissolving within its acidic mass. It can engulf entire
|
||||||
|
creatures, digesting them alive.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 14
|
||||||
|
dexterity: 2
|
||||||
|
constitution: 20
|
||||||
|
intelligence: 1
|
||||||
|
wisdom: 4
|
||||||
|
charisma: 1
|
||||||
|
luck: 6
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- acid_touch
|
||||||
|
- engulf
|
||||||
|
- split
|
||||||
|
- slam
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: slime_residue
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 3
|
||||||
|
quantity_max: 6
|
||||||
|
- loot_type: static
|
||||||
|
item_id: acid_gland
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 3
|
||||||
|
- loot_type: static
|
||||||
|
item_id: undigested_treasure
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 10
|
||||||
|
quantity_max: 30
|
||||||
|
|
||||||
|
# Procedural - partially digested equipment
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: weapon
|
||||||
|
drop_chance: 0.15
|
||||||
|
rarity_bonus: -0.10
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: armor
|
||||||
|
drop_chance: 0.15
|
||||||
|
rarity_bonus: -0.10
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 55
|
||||||
|
gold_reward_min: 15
|
||||||
|
gold_reward_max: 40
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- ooze
|
||||||
|
- slime
|
||||||
|
- acidic
|
||||||
|
- large
|
||||||
|
- mindless
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- cave
|
||||||
|
- sewer
|
||||||
|
|
||||||
|
base_damage: 10
|
||||||
|
crit_chance: 0.04
|
||||||
|
flee_chance: 0.00
|
||||||
122
api/app/data/enemies/slime_king.yaml
Normal file
122
api/app/data/enemies/slime_king.yaml
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Slime King - Hard elite variant
|
||||||
|
# An ancient slime of immense size and cunning
|
||||||
|
|
||||||
|
enemy_id: slime_king
|
||||||
|
name: Slime King
|
||||||
|
description: >
|
||||||
|
A gargantuan ooze that fills entire chambers, its body a
|
||||||
|
churning mass of emerald gel studded with the treasures and
|
||||||
|
bones of centuries of victims. Unlike lesser slimes, the
|
||||||
|
King possesses a malevolent intelligence, having absorbed
|
||||||
|
the minds of countless victims. A crown of corroded gold
|
||||||
|
floats within its core, the last remnant of a king it
|
||||||
|
consumed long ago.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 18
|
||||||
|
dexterity: 2
|
||||||
|
constitution: 24
|
||||||
|
intelligence: 8
|
||||||
|
wisdom: 10
|
||||||
|
charisma: 4
|
||||||
|
luck: 10
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- acid_touch
|
||||||
|
- engulf
|
||||||
|
- split
|
||||||
|
- slam
|
||||||
|
- acid_wave
|
||||||
|
- spawn_slimes
|
||||||
|
- regeneration
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
# Static drops - guaranteed materials
|
||||||
|
- loot_type: static
|
||||||
|
item_id: royal_slime_essence
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: slime_residue
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 5
|
||||||
|
quantity_max: 10
|
||||||
|
- loot_type: static
|
||||||
|
item_id: acid_gland
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 3
|
||||||
|
quantity_max: 5
|
||||||
|
- loot_type: static
|
||||||
|
item_id: corroded_crown
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Treasure from victims
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 50
|
||||||
|
quantity_max: 150
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gemstone
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
|
||||||
|
# Consumables
|
||||||
|
- loot_type: static
|
||||||
|
item_id: health_potion_large
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: elixir_of_fortitude
|
||||||
|
drop_chance: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Procedural equipment
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: weapon
|
||||||
|
drop_chance: 0.30
|
||||||
|
rarity_bonus: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: armor
|
||||||
|
drop_chance: 0.30
|
||||||
|
rarity_bonus: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: accessory
|
||||||
|
drop_chance: 0.25
|
||||||
|
rarity_bonus: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 150
|
||||||
|
gold_reward_min: 75
|
||||||
|
gold_reward_max: 200
|
||||||
|
difficulty: hard
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- ooze
|
||||||
|
- slime
|
||||||
|
- acidic
|
||||||
|
- large
|
||||||
|
- elite
|
||||||
|
- boss
|
||||||
|
- intelligent
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- cave
|
||||||
|
- sewer
|
||||||
|
|
||||||
|
base_damage: 16
|
||||||
|
crit_chance: 0.08
|
||||||
|
flee_chance: 0.00
|
||||||
70
api/app/data/enemies/slime_toxic.yaml
Normal file
70
api/app/data/enemies/slime_toxic.yaml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Toxic Slime - Medium poisonous variant
|
||||||
|
# A slime that has absorbed toxic substances
|
||||||
|
|
||||||
|
enemy_id: slime_toxic
|
||||||
|
name: Toxic Slime
|
||||||
|
description: >
|
||||||
|
A sickly purple ooze that bubbles and hisses as it moves.
|
||||||
|
This slime has absorbed countless poisonous substances,
|
||||||
|
becoming a living vat of toxins. Its mere presence fouls
|
||||||
|
the air, and physical contact can cause paralysis or death.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 10
|
||||||
|
dexterity: 4
|
||||||
|
constitution: 16
|
||||||
|
intelligence: 1
|
||||||
|
wisdom: 4
|
||||||
|
charisma: 1
|
||||||
|
luck: 4
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- acid_touch
|
||||||
|
- poison_spray
|
||||||
|
- toxic_aura
|
||||||
|
- split
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: toxic_residue
|
||||||
|
drop_chance: 0.75
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
- loot_type: static
|
||||||
|
item_id: acid_gland
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: poison_sac
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: antidote
|
||||||
|
drop_chance: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 35
|
||||||
|
gold_reward_min: 0
|
||||||
|
gold_reward_max: 5
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- ooze
|
||||||
|
- slime
|
||||||
|
- acidic
|
||||||
|
- poisonous
|
||||||
|
- mindless
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- cave
|
||||||
|
- sewer
|
||||||
|
- swamp
|
||||||
|
|
||||||
|
base_damage: 7
|
||||||
|
crit_chance: 0.04
|
||||||
|
flee_chance: 0.05
|
||||||
56
api/app/data/enemies/spider.yaml
Normal file
56
api/app/data/enemies/spider.yaml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Spider - Easy beast enemy (DEX-focused)
|
||||||
|
# A common dungeon-dwelling predator that lurks in shadows
|
||||||
|
|
||||||
|
enemy_id: spider
|
||||||
|
name: Giant Spider
|
||||||
|
description: >
|
||||||
|
A spider the size of a large dog, with glistening black chitin and
|
||||||
|
eight beady eyes that reflect the faintest light. It moves with
|
||||||
|
unsettling speed, its fangs dripping with paralyzing venom.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 8
|
||||||
|
dexterity: 14
|
||||||
|
constitution: 8
|
||||||
|
intelligence: 2
|
||||||
|
wisdom: 10
|
||||||
|
charisma: 2
|
||||||
|
luck: 6
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- venomous_bite
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: spider_silk
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
- item_id: spider_venom
|
||||||
|
drop_chance: 0.25
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- item_id: spider_fang
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
|
||||||
|
experience_reward: 20
|
||||||
|
gold_reward_min: 0
|
||||||
|
gold_reward_max: 5
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- beast
|
||||||
|
- spider
|
||||||
|
- venomous
|
||||||
|
- ambusher
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- cave
|
||||||
|
- forest
|
||||||
|
|
||||||
|
base_damage: 5
|
||||||
|
crit_chance: 0.08
|
||||||
|
flee_chance: 0.50
|
||||||
90
api/app/data/enemies/spider_broodmother.yaml
Normal file
90
api/app/data/enemies/spider_broodmother.yaml
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Spider Broodmother - Hard elite spider
|
||||||
|
# A massive spider that rules over an entire nest
|
||||||
|
|
||||||
|
enemy_id: spider_broodmother
|
||||||
|
name: Spider Broodmother
|
||||||
|
description: >
|
||||||
|
A horrifying arachnid the size of a cart, her bloated abdomen
|
||||||
|
pulsing with unborn spawn. Ancient and cunning, the Broodmother
|
||||||
|
has survived countless adventurers, decorating her web with their
|
||||||
|
bones. She fights with terrifying intelligence and can call her
|
||||||
|
children to swarm any threat.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 16
|
||||||
|
dexterity: 12
|
||||||
|
constitution: 18
|
||||||
|
intelligence: 6
|
||||||
|
wisdom: 14
|
||||||
|
charisma: 4
|
||||||
|
luck: 10
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- venomous_bite
|
||||||
|
- web_trap
|
||||||
|
- summon_hatchlings
|
||||||
|
- poison_spray
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
# Static drops - guaranteed materials
|
||||||
|
- loot_type: static
|
||||||
|
item_id: spider_silk
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 5
|
||||||
|
quantity_max: 10
|
||||||
|
- loot_type: static
|
||||||
|
item_id: broodmother_fang
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: potent_spider_venom
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
- loot_type: static
|
||||||
|
item_id: spider_egg_sac
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Consumable drops
|
||||||
|
- loot_type: static
|
||||||
|
item_id: health_potion_medium
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: antidote
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
|
||||||
|
# Procedural equipment drops
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: armor
|
||||||
|
drop_chance: 0.20
|
||||||
|
rarity_bonus: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 100
|
||||||
|
gold_reward_min: 25
|
||||||
|
gold_reward_max: 60
|
||||||
|
difficulty: hard
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- beast
|
||||||
|
- spider
|
||||||
|
- elite
|
||||||
|
- large
|
||||||
|
- venomous
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- cave
|
||||||
|
|
||||||
|
base_damage: 14
|
||||||
|
crit_chance: 0.12
|
||||||
|
flee_chance: 0.15
|
||||||
51
api/app/data/enemies/spider_hatchling.yaml
Normal file
51
api/app/data/enemies/spider_hatchling.yaml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Spider Hatchling - Easy swarm creature
|
||||||
|
# Young spiders that attack in overwhelming numbers
|
||||||
|
|
||||||
|
enemy_id: spider_hatchling
|
||||||
|
name: Spider Hatchling
|
||||||
|
description: >
|
||||||
|
A swarm of palm-sized spiderlings with pale, translucent bodies.
|
||||||
|
Individually weak, they overwhelm prey through sheer numbers,
|
||||||
|
skittering over victims in a horrifying wave of legs and fangs.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 4
|
||||||
|
dexterity: 16
|
||||||
|
constitution: 4
|
||||||
|
intelligence: 1
|
||||||
|
wisdom: 6
|
||||||
|
charisma: 1
|
||||||
|
luck: 4
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- swarm_attack
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: spider_silk
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- item_id: small_spider_fang
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 5
|
||||||
|
|
||||||
|
experience_reward: 10
|
||||||
|
gold_reward_min: 0
|
||||||
|
gold_reward_max: 2
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- beast
|
||||||
|
- spider
|
||||||
|
- swarm
|
||||||
|
- small
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- cave
|
||||||
|
|
||||||
|
base_damage: 3
|
||||||
|
crit_chance: 0.05
|
||||||
|
flee_chance: 0.70
|
||||||
66
api/app/data/enemies/spider_venomous.yaml
Normal file
66
api/app/data/enemies/spider_venomous.yaml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Venomous Spider - Medium deadly variant
|
||||||
|
# A spider with especially potent venom
|
||||||
|
|
||||||
|
enemy_id: spider_venomous
|
||||||
|
name: Venomous Spider
|
||||||
|
description: >
|
||||||
|
A mottled brown and red spider with swollen venom glands visible
|
||||||
|
beneath its translucent exoskeleton. Its bite delivers a toxin
|
||||||
|
that burns through the veins like liquid fire, leaving victims
|
||||||
|
paralyzed and helpless.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 10
|
||||||
|
dexterity: 14
|
||||||
|
constitution: 10
|
||||||
|
intelligence: 2
|
||||||
|
wisdom: 12
|
||||||
|
charisma: 2
|
||||||
|
luck: 8
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- venomous_bite
|
||||||
|
- poison_spray
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: spider_silk
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
- loot_type: static
|
||||||
|
item_id: potent_spider_venom
|
||||||
|
drop_chance: 0.45
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: spider_fang
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: antidote
|
||||||
|
drop_chance: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 35
|
||||||
|
gold_reward_min: 0
|
||||||
|
gold_reward_max: 8
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- beast
|
||||||
|
- spider
|
||||||
|
- venomous
|
||||||
|
- dangerous
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- dungeon
|
||||||
|
- cave
|
||||||
|
- swamp
|
||||||
|
|
||||||
|
base_damage: 7
|
||||||
|
crit_chance: 0.10
|
||||||
|
flee_chance: 0.40
|
||||||
59
api/app/data/enemies/troll.yaml
Normal file
59
api/app/data/enemies/troll.yaml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Troll - Medium regenerating brute
|
||||||
|
# A hulking creature with remarkable healing abilities
|
||||||
|
|
||||||
|
enemy_id: troll
|
||||||
|
name: Troll
|
||||||
|
description: >
|
||||||
|
A towering creature with mottled green skin, long arms that
|
||||||
|
nearly drag on the ground, and a hunched posture. Its beady
|
||||||
|
yellow eyes peer out from beneath a heavy brow, and its mouth
|
||||||
|
is filled with jagged, broken teeth. Most terrifying is its
|
||||||
|
ability to regenerate wounds almost instantly.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 16
|
||||||
|
dexterity: 8
|
||||||
|
constitution: 18
|
||||||
|
intelligence: 4
|
||||||
|
wisdom: 6
|
||||||
|
charisma: 4
|
||||||
|
luck: 6
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- regeneration
|
||||||
|
- rending_claws
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: troll_hide
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- item_id: troll_blood
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- item_id: beast_meat
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
|
||||||
|
experience_reward: 50
|
||||||
|
gold_reward_min: 5
|
||||||
|
gold_reward_max: 20
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- giant
|
||||||
|
- troll
|
||||||
|
- regenerating
|
||||||
|
- large
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- swamp
|
||||||
|
- forest
|
||||||
|
- cave
|
||||||
|
|
||||||
|
base_damage: 12
|
||||||
|
crit_chance: 0.08
|
||||||
|
flee_chance: 0.25
|
||||||
68
api/app/data/enemies/troll_cave.yaml
Normal file
68
api/app/data/enemies/troll_cave.yaml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Cave Troll - Medium variant adapted to underground life
|
||||||
|
# A blind but deadly subterranean hunter
|
||||||
|
|
||||||
|
enemy_id: troll_cave
|
||||||
|
name: Cave Troll
|
||||||
|
description: >
|
||||||
|
A pale, eyeless troll that has adapted to life in the deepest
|
||||||
|
caves. Its skin is white and rubbery, its ears enlarged to
|
||||||
|
hunt by sound alone. What it lacks in sight it makes up for
|
||||||
|
with uncanny hearing and a savage ferocity when cornered
|
||||||
|
in its territory.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 18
|
||||||
|
dexterity: 6
|
||||||
|
constitution: 20
|
||||||
|
intelligence: 4
|
||||||
|
wisdom: 10
|
||||||
|
charisma: 2
|
||||||
|
luck: 6
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- regeneration
|
||||||
|
- rending_claws
|
||||||
|
- echo_sense
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: cave_troll_hide
|
||||||
|
drop_chance: 0.65
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: troll_blood
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 3
|
||||||
|
- loot_type: static
|
||||||
|
item_id: glowing_mushroom
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
- loot_type: static
|
||||||
|
item_id: cave_crystal
|
||||||
|
drop_chance: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 60
|
||||||
|
gold_reward_min: 5
|
||||||
|
gold_reward_max: 25
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- giant
|
||||||
|
- troll
|
||||||
|
- regenerating
|
||||||
|
- large
|
||||||
|
- blind
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- cave
|
||||||
|
- dungeon
|
||||||
|
|
||||||
|
base_damage: 14
|
||||||
|
crit_chance: 0.06
|
||||||
|
flee_chance: 0.20
|
||||||
75
api/app/data/enemies/troll_shaman.yaml
Normal file
75
api/app/data/enemies/troll_shaman.yaml
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Troll Shaman - Medium caster variant
|
||||||
|
# A rare troll with crude magical abilities
|
||||||
|
|
||||||
|
enemy_id: troll_shaman
|
||||||
|
name: Troll Shaman
|
||||||
|
description: >
|
||||||
|
An ancient troll draped in bones, feathers, and tribal fetishes.
|
||||||
|
Rare among its kind, this creature has developed a primitive
|
||||||
|
understanding of dark magic, calling upon primal spirits to
|
||||||
|
curse its enemies and heal its allies. Its regeneration is
|
||||||
|
enhanced by the dark powers it commands.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 12
|
||||||
|
dexterity: 8
|
||||||
|
constitution: 16
|
||||||
|
intelligence: 10
|
||||||
|
wisdom: 14
|
||||||
|
charisma: 8
|
||||||
|
luck: 10
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- regeneration
|
||||||
|
- curse_of_weakness
|
||||||
|
- dark_heal
|
||||||
|
- spirit_bolt
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: troll_hide
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: troll_blood
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 3
|
||||||
|
- loot_type: static
|
||||||
|
item_id: shaman_fetish
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: mana_potion_small
|
||||||
|
drop_chance: 0.25
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: dark_essence
|
||||||
|
drop_chance: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 65
|
||||||
|
gold_reward_min: 15
|
||||||
|
gold_reward_max: 35
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- giant
|
||||||
|
- troll
|
||||||
|
- regenerating
|
||||||
|
- caster
|
||||||
|
- shaman
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- swamp
|
||||||
|
- forest
|
||||||
|
- cave
|
||||||
|
|
||||||
|
base_damage: 8
|
||||||
|
crit_chance: 0.10
|
||||||
|
flee_chance: 0.30
|
||||||
101
api/app/data/enemies/troll_warlord.yaml
Normal file
101
api/app/data/enemies/troll_warlord.yaml
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Troll Warlord - Hard elite variant
|
||||||
|
# A massive, battle-scarred troll that leads others of its kind
|
||||||
|
|
||||||
|
enemy_id: troll_warlord
|
||||||
|
name: Troll Warlord
|
||||||
|
description: >
|
||||||
|
A massive troll standing nearly twelve feet tall, its body
|
||||||
|
covered in ritual scars and the marks of a hundred battles.
|
||||||
|
It wears crude armor made from the shields and weapons of
|
||||||
|
fallen warriors, and wields a massive club made from an
|
||||||
|
entire tree trunk. Its regeneration is legendary, and it
|
||||||
|
commands lesser trolls through sheer brutality.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 20
|
||||||
|
dexterity: 8
|
||||||
|
constitution: 22
|
||||||
|
intelligence: 6
|
||||||
|
wisdom: 8
|
||||||
|
charisma: 10
|
||||||
|
luck: 8
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- regeneration
|
||||||
|
- rending_claws
|
||||||
|
- ground_slam
|
||||||
|
- intimidating_shout
|
||||||
|
- berserker_rage
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
# Static drops - guaranteed materials
|
||||||
|
- loot_type: static
|
||||||
|
item_id: troll_hide
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 3
|
||||||
|
quantity_max: 5
|
||||||
|
- loot_type: static
|
||||||
|
item_id: troll_blood
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 3
|
||||||
|
quantity_max: 5
|
||||||
|
- loot_type: static
|
||||||
|
item_id: troll_warlord_trophy
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: regeneration_gland
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Consumables
|
||||||
|
- loot_type: static
|
||||||
|
item_id: health_potion_large
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: elixir_of_fortitude
|
||||||
|
drop_chance: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Procedural equipment
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: weapon
|
||||||
|
drop_chance: 0.25
|
||||||
|
rarity_bonus: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: armor
|
||||||
|
drop_chance: 0.20
|
||||||
|
rarity_bonus: 0.10
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 120
|
||||||
|
gold_reward_min: 30
|
||||||
|
gold_reward_max: 75
|
||||||
|
difficulty: hard
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- giant
|
||||||
|
- troll
|
||||||
|
- regenerating
|
||||||
|
- large
|
||||||
|
- leader
|
||||||
|
- elite
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- swamp
|
||||||
|
- forest
|
||||||
|
- cave
|
||||||
|
- dungeon
|
||||||
|
|
||||||
|
base_damage: 18
|
||||||
|
crit_chance: 0.12
|
||||||
|
flee_chance: 0.10
|
||||||
61
api/app/data/enemies/wraith.yaml
Normal file
61
api/app/data/enemies/wraith.yaml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Wraith - Medium incorporeal undead
|
||||||
|
# A malevolent spirit of hatred and despair
|
||||||
|
|
||||||
|
enemy_id: wraith
|
||||||
|
name: Wraith
|
||||||
|
description: >
|
||||||
|
A shadowy figure shrouded in tattered darkness, its form
|
||||||
|
only vaguely humanoid. Where its face should be, two points
|
||||||
|
of cold blue light burn with hatred for the living. The
|
||||||
|
wraith's touch drains the very life force from its victims,
|
||||||
|
leaving them weakened and hollow.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 4
|
||||||
|
dexterity: 14
|
||||||
|
constitution: 8
|
||||||
|
intelligence: 10
|
||||||
|
wisdom: 12
|
||||||
|
charisma: 14
|
||||||
|
luck: 10
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- life_drain
|
||||||
|
- incorporeal_movement
|
||||||
|
- create_spawn
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: soul_essence
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- item_id: shadow_residue
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
- item_id: death_essence
|
||||||
|
drop_chance: 0.25
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 45
|
||||||
|
gold_reward_min: 0
|
||||||
|
gold_reward_max: 10
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- undead
|
||||||
|
- incorporeal
|
||||||
|
- wraith
|
||||||
|
- drain
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- crypt
|
||||||
|
- ruins
|
||||||
|
- dungeon
|
||||||
|
- graveyard
|
||||||
|
|
||||||
|
base_damage: 8
|
||||||
|
crit_chance: 0.10
|
||||||
|
flee_chance: 0.30
|
||||||
85
api/app/data/enemies/wraith_banshee.yaml
Normal file
85
api/app/data/enemies/wraith_banshee.yaml
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Banshee - Hard wailing spirit variant
|
||||||
|
# A female spirit whose scream can kill
|
||||||
|
|
||||||
|
enemy_id: wraith_banshee
|
||||||
|
name: Banshee
|
||||||
|
description: >
|
||||||
|
The spirit of a woman who died in terrible anguish, her face
|
||||||
|
frozen in an eternal scream of grief and rage. Her form is
|
||||||
|
more distinct than other wraiths, wearing tattered remnants
|
||||||
|
of a burial gown. When she wails, the sound is so filled
|
||||||
|
with despair that it can stop hearts and shatter minds.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 4
|
||||||
|
dexterity: 12
|
||||||
|
constitution: 10
|
||||||
|
intelligence: 12
|
||||||
|
wisdom: 14
|
||||||
|
charisma: 18
|
||||||
|
luck: 12
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- life_drain
|
||||||
|
- incorporeal_movement
|
||||||
|
- wail_of_despair
|
||||||
|
- horrifying_visage
|
||||||
|
- keening
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: soul_essence
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 3
|
||||||
|
- loot_type: static
|
||||||
|
item_id: banshee_tear
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: death_essence
|
||||||
|
drop_chance: 0.45
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: ectoplasm
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
|
||||||
|
# Sometimes carries jewelry from her mortal life
|
||||||
|
- loot_type: static
|
||||||
|
item_id: silver_ring
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gemstone
|
||||||
|
drop_chance: 0.25
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 75
|
||||||
|
gold_reward_min: 5
|
||||||
|
gold_reward_max: 25
|
||||||
|
difficulty: hard
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- undead
|
||||||
|
- incorporeal
|
||||||
|
- wraith
|
||||||
|
- banshee
|
||||||
|
- sonic
|
||||||
|
- female
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- crypt
|
||||||
|
- ruins
|
||||||
|
- graveyard
|
||||||
|
- haunted
|
||||||
|
|
||||||
|
base_damage: 6
|
||||||
|
crit_chance: 0.12
|
||||||
|
flee_chance: 0.20
|
||||||
139
api/app/data/enemies/wraith_lord.yaml
Normal file
139
api/app/data/enemies/wraith_lord.yaml
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Wraith Lord - Hard elite undead commander
|
||||||
|
# An ancient and powerful wraith that rules over lesser spirits
|
||||||
|
|
||||||
|
enemy_id: wraith_lord
|
||||||
|
name: Wraith Lord
|
||||||
|
description: >
|
||||||
|
An ancient spirit of terrible power, once a great lord or
|
||||||
|
mage in life, now a being of pure malevolence. The Wraith
|
||||||
|
Lord retains its intelligence and ambition, commanding
|
||||||
|
legions of lesser undead from its shadow throne. Its form
|
||||||
|
is more defined than common wraiths, wearing spectral armor
|
||||||
|
and wielding a blade of condensed darkness that severs both
|
||||||
|
flesh and soul.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 8
|
||||||
|
dexterity: 14
|
||||||
|
constitution: 14
|
||||||
|
intelligence: 16
|
||||||
|
wisdom: 16
|
||||||
|
charisma: 20
|
||||||
|
luck: 14
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- soul_blade
|
||||||
|
- life_drain
|
||||||
|
- incorporeal_movement
|
||||||
|
- create_spawn
|
||||||
|
- command_undead
|
||||||
|
- death_wave
|
||||||
|
- dark_aura
|
||||||
|
- fear
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
# Static drops - guaranteed materials
|
||||||
|
- loot_type: static
|
||||||
|
item_id: soul_essence
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 3
|
||||||
|
quantity_max: 5
|
||||||
|
- loot_type: static
|
||||||
|
item_id: shadow_residue
|
||||||
|
drop_chance: 1.0
|
||||||
|
quantity_min: 4
|
||||||
|
quantity_max: 8
|
||||||
|
- loot_type: static
|
||||||
|
item_id: death_essence
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 3
|
||||||
|
- loot_type: static
|
||||||
|
item_id: wraith_lord_crown
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: spectral_blade_shard
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Treasure from mortal life
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gold_coin
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 40
|
||||||
|
quantity_max: 100
|
||||||
|
- loot_type: static
|
||||||
|
item_id: gemstone
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
- loot_type: static
|
||||||
|
item_id: ancient_jewelry
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Consumables
|
||||||
|
- loot_type: static
|
||||||
|
item_id: health_potion_large
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: mana_potion_large
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: elixir_of_wisdom
|
||||||
|
drop_chance: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
# Procedural equipment
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: weapon
|
||||||
|
drop_chance: 0.30
|
||||||
|
rarity_bonus: 0.25
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: armor
|
||||||
|
drop_chance: 0.25
|
||||||
|
rarity_bonus: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: procedural
|
||||||
|
item_type: accessory
|
||||||
|
drop_chance: 0.30
|
||||||
|
rarity_bonus: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 130
|
||||||
|
gold_reward_min: 50
|
||||||
|
gold_reward_max: 125
|
||||||
|
difficulty: hard
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- undead
|
||||||
|
- incorporeal
|
||||||
|
- wraith
|
||||||
|
- leader
|
||||||
|
- elite
|
||||||
|
- boss
|
||||||
|
- intelligent
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- crypt
|
||||||
|
- ruins
|
||||||
|
- dungeon
|
||||||
|
- haunted
|
||||||
|
|
||||||
|
base_damage: 14
|
||||||
|
crit_chance: 0.15
|
||||||
|
flee_chance: 0.05
|
||||||
72
api/app/data/enemies/wraith_shadow.yaml
Normal file
72
api/app/data/enemies/wraith_shadow.yaml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Shadow Wraith - Medium stealth variant
|
||||||
|
# A wraith that lurks in darkness, striking unseen
|
||||||
|
|
||||||
|
enemy_id: wraith_shadow
|
||||||
|
name: Shadow Wraith
|
||||||
|
description: >
|
||||||
|
A wraith so deeply attuned to darkness that it becomes nearly
|
||||||
|
invisible in shadow. It moves silently through dim places,
|
||||||
|
reaching out to touch the living and drain their strength
|
||||||
|
before fading back into the gloom. Only in bright light does
|
||||||
|
its terrible form become visible.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 4
|
||||||
|
dexterity: 18
|
||||||
|
constitution: 8
|
||||||
|
intelligence: 10
|
||||||
|
wisdom: 14
|
||||||
|
charisma: 12
|
||||||
|
luck: 12
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- life_drain
|
||||||
|
- incorporeal_movement
|
||||||
|
- shadow_step
|
||||||
|
- darkness
|
||||||
|
- sneak_attack
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: soul_essence
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: shadow_residue
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
- loot_type: static
|
||||||
|
item_id: shadow_heart
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: dark_essence
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 50
|
||||||
|
gold_reward_min: 0
|
||||||
|
gold_reward_max: 12
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- undead
|
||||||
|
- incorporeal
|
||||||
|
- wraith
|
||||||
|
- shadow
|
||||||
|
- stealthy
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- crypt
|
||||||
|
- ruins
|
||||||
|
- dungeon
|
||||||
|
- cave
|
||||||
|
|
||||||
|
base_damage: 7
|
||||||
|
crit_chance: 0.15
|
||||||
|
flee_chance: 0.35
|
||||||
58
api/app/data/enemies/zombie.yaml
Normal file
58
api/app/data/enemies/zombie.yaml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Zombie - Easy undead shambler
|
||||||
|
# A reanimated corpse driven by dark magic
|
||||||
|
|
||||||
|
enemy_id: zombie
|
||||||
|
name: Zombie
|
||||||
|
description: >
|
||||||
|
A shambling corpse with rotting flesh hanging from its bones.
|
||||||
|
Its eyes are milky and unfocused, but it is drawn inexorably
|
||||||
|
toward the living, driven by an insatiable hunger. It moves
|
||||||
|
slowly but relentlessly, and feels no pain.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 10
|
||||||
|
dexterity: 4
|
||||||
|
constitution: 12
|
||||||
|
intelligence: 2
|
||||||
|
wisdom: 4
|
||||||
|
charisma: 2
|
||||||
|
luck: 4
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- infectious_bite
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: rotting_flesh
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- item_id: bone_fragment
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
- item_id: tattered_cloth
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
|
||||||
|
experience_reward: 18
|
||||||
|
gold_reward_min: 0
|
||||||
|
gold_reward_max: 5
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- undead
|
||||||
|
- zombie
|
||||||
|
- shambler
|
||||||
|
- fearless
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- crypt
|
||||||
|
- ruins
|
||||||
|
- dungeon
|
||||||
|
- graveyard
|
||||||
|
|
||||||
|
base_damage: 6
|
||||||
|
crit_chance: 0.05
|
||||||
|
flee_chance: 0.00
|
||||||
69
api/app/data/enemies/zombie_brute.yaml
Normal file
69
api/app/data/enemies/zombie_brute.yaml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Zombie Brute - Medium powerful variant
|
||||||
|
# A massive reanimated corpse, possibly a former warrior
|
||||||
|
|
||||||
|
enemy_id: zombie_brute
|
||||||
|
name: Zombie Brute
|
||||||
|
description: >
|
||||||
|
The reanimated corpse of what was once a mighty warrior or
|
||||||
|
perhaps an ogre. Standing head and shoulders above normal
|
||||||
|
zombies, its massive frame is swollen with death-bloat and
|
||||||
|
unnatural strength. It swings its fists like clubs, crushing
|
||||||
|
anything in its path.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 16
|
||||||
|
dexterity: 4
|
||||||
|
constitution: 18
|
||||||
|
intelligence: 2
|
||||||
|
wisdom: 4
|
||||||
|
charisma: 2
|
||||||
|
luck: 4
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- crushing_blow
|
||||||
|
- infectious_bite
|
||||||
|
- undead_fortitude
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- loot_type: static
|
||||||
|
item_id: rotting_flesh
|
||||||
|
drop_chance: 0.80
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
- loot_type: static
|
||||||
|
item_id: bone_fragment
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 5
|
||||||
|
- loot_type: static
|
||||||
|
item_id: death_essence
|
||||||
|
drop_chance: 0.20
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- loot_type: static
|
||||||
|
item_id: iron_ore
|
||||||
|
drop_chance: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
|
||||||
|
experience_reward: 45
|
||||||
|
gold_reward_min: 5
|
||||||
|
gold_reward_max: 15
|
||||||
|
difficulty: medium
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- undead
|
||||||
|
- zombie
|
||||||
|
- brute
|
||||||
|
- large
|
||||||
|
- fearless
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- crypt
|
||||||
|
- ruins
|
||||||
|
- dungeon
|
||||||
|
|
||||||
|
base_damage: 12
|
||||||
|
crit_chance: 0.08
|
||||||
|
flee_chance: 0.00
|
||||||
85
api/app/data/enemies/zombie_plague.yaml
Normal file
85
api/app/data/enemies/zombie_plague.yaml
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Plague Zombie - Hard infectious variant
|
||||||
|
# A zombie carrying a deadly disease that spreads with each bite
|
||||||
|
|
||||||
|
enemy_id: zombie_plague
|
||||||
|
name: Plague Zombie
|
||||||
|
description: >
|
||||||
|
A bloated, pustule-covered corpse that exudes a noxious green
|
||||||
|
miasma. Every wound on its body weeps infectious fluid, and
|
||||||
|
its mere presence causes nausea in the living. Those bitten
|
||||||
|
by a plague zombie rarely survive the infection, and those
|
||||||
|
that die often rise again to join the horde.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 12
|
||||||
|
dexterity: 6
|
||||||
|
constitution: 16
|
||||||
|
intelligence: 2
|
||||||
|
wisdom: 6
|
||||||
|
charisma: 2
|
||||||
|
luck: 6
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- infectious_bite
|
||||||
|
- plague_breath
|
||||||
|
- death_burst
|
||||||
|
- disease_aura
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
# Static drops
|
||||||
|
- loot_type: static
|
||||||
|
item_id: rotting_flesh
|
||||||
|
drop_chance: 0.90
|
||||||
|
quantity_min: 3
|
||||||
|
quantity_max: 5
|
||||||
|
- loot_type: static
|
||||||
|
item_id: plague_ichor
|
||||||
|
drop_chance: 0.60
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 3
|
||||||
|
- loot_type: static
|
||||||
|
item_id: bone_fragment
|
||||||
|
drop_chance: 0.70
|
||||||
|
quantity_min: 2
|
||||||
|
quantity_max: 4
|
||||||
|
- loot_type: static
|
||||||
|
item_id: death_essence
|
||||||
|
drop_chance: 0.35
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
|
||||||
|
# Consumables
|
||||||
|
- loot_type: static
|
||||||
|
item_id: antidote
|
||||||
|
drop_chance: 0.30
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
- loot_type: static
|
||||||
|
item_id: cure_disease_potion
|
||||||
|
drop_chance: 0.15
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
|
||||||
|
experience_reward: 70
|
||||||
|
gold_reward_min: 10
|
||||||
|
gold_reward_max: 30
|
||||||
|
difficulty: hard
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- undead
|
||||||
|
- zombie
|
||||||
|
- plague
|
||||||
|
- infectious
|
||||||
|
- fearless
|
||||||
|
- dangerous
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- crypt
|
||||||
|
- ruins
|
||||||
|
- dungeon
|
||||||
|
- swamp
|
||||||
|
|
||||||
|
base_damage: 10
|
||||||
|
crit_chance: 0.10
|
||||||
|
flee_chance: 0.00
|
||||||
53
api/app/data/enemies/zombie_shambler.yaml
Normal file
53
api/app/data/enemies/zombie_shambler.yaml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Zombie Shambler - Easy weak variant
|
||||||
|
# A decrepit zombie barely holding together
|
||||||
|
|
||||||
|
enemy_id: zombie_shambler
|
||||||
|
name: Zombie Shambler
|
||||||
|
description: >
|
||||||
|
A decrepit corpse in an advanced state of decay, missing limbs
|
||||||
|
and dragging itself forward with single-minded determination.
|
||||||
|
What it lacks in strength it makes up for in persistence,
|
||||||
|
continuing to crawl toward prey even when reduced to a torso.
|
||||||
|
|
||||||
|
base_stats:
|
||||||
|
strength: 6
|
||||||
|
dexterity: 2
|
||||||
|
constitution: 8
|
||||||
|
intelligence: 1
|
||||||
|
wisdom: 2
|
||||||
|
charisma: 1
|
||||||
|
luck: 2
|
||||||
|
|
||||||
|
abilities:
|
||||||
|
- basic_attack
|
||||||
|
- grasp
|
||||||
|
|
||||||
|
loot_table:
|
||||||
|
- item_id: rotting_flesh
|
||||||
|
drop_chance: 0.50
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 1
|
||||||
|
- item_id: bone_fragment
|
||||||
|
drop_chance: 0.40
|
||||||
|
quantity_min: 1
|
||||||
|
quantity_max: 2
|
||||||
|
|
||||||
|
experience_reward: 10
|
||||||
|
gold_reward_min: 0
|
||||||
|
gold_reward_max: 2
|
||||||
|
difficulty: easy
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- undead
|
||||||
|
- zombie
|
||||||
|
- shambler
|
||||||
|
- weak
|
||||||
|
|
||||||
|
location_tags:
|
||||||
|
- crypt
|
||||||
|
- ruins
|
||||||
|
- graveyard
|
||||||
|
|
||||||
|
base_damage: 4
|
||||||
|
crit_chance: 0.02
|
||||||
|
flee_chance: 0.00
|
||||||
@@ -3,6 +3,7 @@ npc_id: npc_blacksmith_hilda
|
|||||||
name: Hilda Ironforge
|
name: Hilda Ironforge
|
||||||
role: blacksmith
|
role: blacksmith
|
||||||
location_id: crossville_village
|
location_id: crossville_village
|
||||||
|
image_url: /static/images/npcs/crossville/blacksmith_hilda.png
|
||||||
|
|
||||||
personality:
|
personality:
|
||||||
traits:
|
traits:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ npc_id: npc_grom_ironbeard
|
|||||||
name: Grom Ironbeard
|
name: Grom Ironbeard
|
||||||
role: bartender
|
role: bartender
|
||||||
location_id: crossville_tavern
|
location_id: crossville_tavern
|
||||||
|
image_url: /static/images/npcs/crossville/grom_ironbeard.png
|
||||||
|
|
||||||
personality:
|
personality:
|
||||||
traits:
|
traits:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ npc_id: npc_mayor_aldric
|
|||||||
name: Mayor Aldric Thornwood
|
name: Mayor Aldric Thornwood
|
||||||
role: mayor
|
role: mayor
|
||||||
location_id: crossville_village
|
location_id: crossville_village
|
||||||
|
image_url: /static/images/npcs/crossville/mayor_aldric.png
|
||||||
|
|
||||||
personality:
|
personality:
|
||||||
traits:
|
traits:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ npc_id: npc_mira_swiftfoot
|
|||||||
name: Mira Swiftfoot
|
name: Mira Swiftfoot
|
||||||
role: rogue
|
role: rogue
|
||||||
location_id: crossville_tavern
|
location_id: crossville_tavern
|
||||||
|
image_url: /static/images/npcs/crossville/mira_swiftfoot.png
|
||||||
|
|
||||||
personality:
|
personality:
|
||||||
traits:
|
traits:
|
||||||
|
|||||||
161
api/app/data/static_items/consumables.yaml
Normal file
161
api/app/data/static_items/consumables.yaml
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Consumable items that drop from enemies or are purchased from vendors
|
||||||
|
# These items have effects_on_use that trigger when consumed
|
||||||
|
|
||||||
|
items:
|
||||||
|
# ==========================================================================
|
||||||
|
# Health Potions
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
health_potion_small:
|
||||||
|
name: "Small Health Potion"
|
||||||
|
item_type: consumable
|
||||||
|
rarity: common
|
||||||
|
description: "A small vial of red liquid that restores a modest amount of health."
|
||||||
|
value: 25
|
||||||
|
is_tradeable: true
|
||||||
|
effects_on_use:
|
||||||
|
- effect_id: heal_small
|
||||||
|
name: "Minor Healing"
|
||||||
|
effect_type: hot
|
||||||
|
power: 30
|
||||||
|
duration: 1
|
||||||
|
stacks: 1
|
||||||
|
|
||||||
|
health_potion_medium:
|
||||||
|
name: "Health Potion"
|
||||||
|
item_type: consumable
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A standard healing potion used by adventurers across the realm."
|
||||||
|
value: 75
|
||||||
|
is_tradeable: true
|
||||||
|
effects_on_use:
|
||||||
|
- effect_id: heal_medium
|
||||||
|
name: "Healing"
|
||||||
|
effect_type: hot
|
||||||
|
power: 75
|
||||||
|
duration: 1
|
||||||
|
stacks: 1
|
||||||
|
|
||||||
|
health_potion_large:
|
||||||
|
name: "Large Health Potion"
|
||||||
|
item_type: consumable
|
||||||
|
rarity: rare
|
||||||
|
description: "A potent healing draught that restores significant health."
|
||||||
|
value: 150
|
||||||
|
is_tradeable: true
|
||||||
|
effects_on_use:
|
||||||
|
- effect_id: heal_large
|
||||||
|
name: "Major Healing"
|
||||||
|
effect_type: hot
|
||||||
|
power: 150
|
||||||
|
duration: 1
|
||||||
|
stacks: 1
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Mana Potions
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
mana_potion_small:
|
||||||
|
name: "Small Mana Potion"
|
||||||
|
item_type: consumable
|
||||||
|
rarity: common
|
||||||
|
description: "A small vial of blue liquid that restores mana."
|
||||||
|
value: 25
|
||||||
|
is_tradeable: true
|
||||||
|
# Note: MP restoration would need custom effect type or game logic
|
||||||
|
|
||||||
|
mana_potion_medium:
|
||||||
|
name: "Mana Potion"
|
||||||
|
item_type: consumable
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A standard mana potion favored by spellcasters."
|
||||||
|
value: 75
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Status Effect Cures
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
antidote:
|
||||||
|
name: "Antidote"
|
||||||
|
item_type: consumable
|
||||||
|
rarity: common
|
||||||
|
description: "A bitter herbal remedy that cures poison effects."
|
||||||
|
value: 30
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
smelling_salts:
|
||||||
|
name: "Smelling Salts"
|
||||||
|
item_type: consumable
|
||||||
|
rarity: uncommon
|
||||||
|
description: "Pungent salts that can revive unconscious allies or cure stun."
|
||||||
|
value: 40
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Combat Buffs
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
elixir_of_strength:
|
||||||
|
name: "Elixir of Strength"
|
||||||
|
item_type: consumable
|
||||||
|
rarity: rare
|
||||||
|
description: "A powerful elixir that temporarily increases strength."
|
||||||
|
value: 100
|
||||||
|
is_tradeable: true
|
||||||
|
effects_on_use:
|
||||||
|
- effect_id: str_buff
|
||||||
|
name: "Strength Boost"
|
||||||
|
effect_type: buff
|
||||||
|
power: 5
|
||||||
|
duration: 5
|
||||||
|
stacks: 1
|
||||||
|
|
||||||
|
elixir_of_agility:
|
||||||
|
name: "Elixir of Agility"
|
||||||
|
item_type: consumable
|
||||||
|
rarity: rare
|
||||||
|
description: "A shimmering elixir that enhances reflexes and speed."
|
||||||
|
value: 100
|
||||||
|
is_tradeable: true
|
||||||
|
effects_on_use:
|
||||||
|
- effect_id: dex_buff
|
||||||
|
name: "Agility Boost"
|
||||||
|
effect_type: buff
|
||||||
|
power: 5
|
||||||
|
duration: 5
|
||||||
|
stacks: 1
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Food Items (simple healing, no combat use)
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
ration:
|
||||||
|
name: "Trail Ration"
|
||||||
|
item_type: consumable
|
||||||
|
rarity: common
|
||||||
|
description: "Dried meat, hardtack, and nuts. Sustains an adventurer on long journeys."
|
||||||
|
value: 5
|
||||||
|
is_tradeable: true
|
||||||
|
effects_on_use:
|
||||||
|
- effect_id: ration_heal
|
||||||
|
name: "Nourishment"
|
||||||
|
effect_type: hot
|
||||||
|
power: 10
|
||||||
|
duration: 1
|
||||||
|
stacks: 1
|
||||||
|
|
||||||
|
cooked_meat:
|
||||||
|
name: "Cooked Meat"
|
||||||
|
item_type: consumable
|
||||||
|
rarity: common
|
||||||
|
description: "Freshly cooked meat that restores health."
|
||||||
|
value: 15
|
||||||
|
is_tradeable: true
|
||||||
|
effects_on_use:
|
||||||
|
- effect_id: meat_heal
|
||||||
|
name: "Hearty Meal"
|
||||||
|
effect_type: hot
|
||||||
|
power: 20
|
||||||
|
duration: 1
|
||||||
|
stacks: 1
|
||||||
138
api/app/data/static_items/equipment.yaml
Normal file
138
api/app/data/static_items/equipment.yaml
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Starting Equipment - Basic items given to new characters based on their class
|
||||||
|
# These are all common-quality items suitable for Level 1 characters
|
||||||
|
|
||||||
|
items:
|
||||||
|
# ==================== WEAPONS ====================
|
||||||
|
|
||||||
|
# Melee Weapons
|
||||||
|
rusty_sword:
|
||||||
|
name: Rusty Sword
|
||||||
|
item_type: weapon
|
||||||
|
rarity: common
|
||||||
|
description: >
|
||||||
|
A battered old sword showing signs of age and neglect.
|
||||||
|
Its edge is dull but it can still cut.
|
||||||
|
value: 5
|
||||||
|
damage: 4
|
||||||
|
damage_type: physical
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
rusty_mace:
|
||||||
|
name: Rusty Mace
|
||||||
|
item_type: weapon
|
||||||
|
rarity: common
|
||||||
|
description: >
|
||||||
|
A worn mace with a tarnished head. The weight still
|
||||||
|
makes it effective for crushing blows.
|
||||||
|
value: 5
|
||||||
|
damage: 5
|
||||||
|
damage_type: physical
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
rusty_dagger:
|
||||||
|
name: Rusty Dagger
|
||||||
|
item_type: weapon
|
||||||
|
rarity: common
|
||||||
|
description: >
|
||||||
|
A corroded dagger with a chipped blade. Quick and
|
||||||
|
deadly in the right hands despite its condition.
|
||||||
|
value: 4
|
||||||
|
damage: 3
|
||||||
|
damage_type: physical
|
||||||
|
crit_chance: 0.10 # Daggers have higher crit chance
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
rusty_knife:
|
||||||
|
name: Rusty Knife
|
||||||
|
item_type: weapon
|
||||||
|
rarity: common
|
||||||
|
description: >
|
||||||
|
A simple utility knife, more tool than weapon. Every
|
||||||
|
adventurer keeps one handy for various tasks.
|
||||||
|
value: 2
|
||||||
|
damage: 2
|
||||||
|
damage_type: physical
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
# Ranged Weapons
|
||||||
|
rusty_bow:
|
||||||
|
name: Rusty Bow
|
||||||
|
item_type: weapon
|
||||||
|
rarity: common
|
||||||
|
description: >
|
||||||
|
An old hunting bow with a frayed string. It still fires
|
||||||
|
true enough for an aspiring ranger.
|
||||||
|
value: 5
|
||||||
|
damage: 4
|
||||||
|
damage_type: physical
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
# Magical Weapons (spell_power instead of damage)
|
||||||
|
worn_staff:
|
||||||
|
name: Worn Staff
|
||||||
|
item_type: weapon
|
||||||
|
rarity: common
|
||||||
|
description: >
|
||||||
|
A gnarled wooden staff weathered by time. Faint traces
|
||||||
|
of arcane energy still pulse through its core.
|
||||||
|
value: 6
|
||||||
|
damage: 2 # Low physical damage for staff strikes
|
||||||
|
spell_power: 4 # Boosts spell damage
|
||||||
|
damage_type: arcane
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
bone_wand:
|
||||||
|
name: Bone Wand
|
||||||
|
item_type: weapon
|
||||||
|
rarity: common
|
||||||
|
description: >
|
||||||
|
A wand carved from ancient bone, cold to the touch.
|
||||||
|
It resonates with dark energy.
|
||||||
|
value: 6
|
||||||
|
damage: 1 # Minimal physical damage
|
||||||
|
spell_power: 5 # Higher spell power for dedicated casters
|
||||||
|
damage_type: shadow # Dark/shadow magic for necromancy
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
tome:
|
||||||
|
name: Worn Tome
|
||||||
|
item_type: weapon
|
||||||
|
rarity: common
|
||||||
|
description: >
|
||||||
|
A leather-bound book filled with faded notes and arcane
|
||||||
|
formulas. Knowledge is power made manifest.
|
||||||
|
value: 6
|
||||||
|
damage: 1 # Can bonk someone with it
|
||||||
|
spell_power: 4 # Boosts spell damage
|
||||||
|
damage_type: arcane
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
# ==================== ARMOR ====================
|
||||||
|
|
||||||
|
cloth_armor:
|
||||||
|
name: Cloth Armor
|
||||||
|
item_type: armor
|
||||||
|
rarity: common
|
||||||
|
description: >
|
||||||
|
Simple padded cloth garments offering minimal protection.
|
||||||
|
Better than nothing, barely.
|
||||||
|
value: 5
|
||||||
|
defense: 2
|
||||||
|
resistance: 1
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
# ==================== SHIELDS/ACCESSORIES ====================
|
||||||
|
|
||||||
|
rusty_shield:
|
||||||
|
name: Rusty Shield
|
||||||
|
item_type: armor
|
||||||
|
rarity: common
|
||||||
|
description: >
|
||||||
|
A battered wooden shield with a rusted metal rim.
|
||||||
|
It can still block a blow or two.
|
||||||
|
value: 5
|
||||||
|
defense: 3
|
||||||
|
resistance: 0
|
||||||
|
stat_bonuses:
|
||||||
|
constitution: 1
|
||||||
|
is_tradeable: true
|
||||||
219
api/app/data/static_items/materials.yaml
Normal file
219
api/app/data/static_items/materials.yaml
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# Trophy items, crafting materials, and quest items dropped by enemies
|
||||||
|
# These items don't have combat effects but are used for quests, crafting, or selling
|
||||||
|
|
||||||
|
items:
|
||||||
|
# ==========================================================================
|
||||||
|
# Goblin Drops
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
goblin_ear:
|
||||||
|
name: "Goblin Ear"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: common
|
||||||
|
description: "A severed goblin ear. Proof of a kill, sometimes collected for bounties."
|
||||||
|
value: 2
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
goblin_trinket:
|
||||||
|
name: "Goblin Trinket"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: common
|
||||||
|
description: "A crude piece of jewelry stolen by a goblin. Worth a few coins."
|
||||||
|
value: 8
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
goblin_war_paint:
|
||||||
|
name: "Goblin War Paint"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: uncommon
|
||||||
|
description: "Pungent red and black paint used by goblin warriors before battle."
|
||||||
|
value: 15
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
goblin_chieftain_token:
|
||||||
|
name: "Chieftain's Token"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: rare
|
||||||
|
description: "A carved bone token marking the authority of a goblin chieftain."
|
||||||
|
value: 50
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Wolf/Beast Drops
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
wolf_pelt:
|
||||||
|
name: "Wolf Pelt"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: common
|
||||||
|
description: "A fur pelt from a wolf. Useful for crafting or selling to tanners."
|
||||||
|
value: 10
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
dire_wolf_fang:
|
||||||
|
name: "Dire Wolf Fang"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A large fang from a dire wolf. Prized by craftsmen for weapon making."
|
||||||
|
value: 25
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
beast_hide:
|
||||||
|
name: "Beast Hide"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: common
|
||||||
|
description: "Thick hide from a large beast. Can be tanned into leather."
|
||||||
|
value: 12
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Vermin Drops
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
rat_tail:
|
||||||
|
name: "Rat Tail"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: common
|
||||||
|
description: "A scaly tail from a giant rat. Sometimes collected for pest control bounties."
|
||||||
|
value: 1
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Undead Drops
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
skeleton_bone:
|
||||||
|
name: "Skeleton Bone"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: common
|
||||||
|
description: "A bone from an animated skeleton. Retains faint magical energy."
|
||||||
|
value: 5
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
bone_dust:
|
||||||
|
name: "Bone Dust"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: common
|
||||||
|
description: "Powdered bone from undead remains. Used in alchemy and rituals."
|
||||||
|
value: 8
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
skull_fragment:
|
||||||
|
name: "Skull Fragment"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A piece of an undead skull, still crackling with dark energy."
|
||||||
|
value: 20
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Orc Drops
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
orc_tusk:
|
||||||
|
name: "Orc Tusk"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A large tusk from an orc warrior. A trophy prized by collectors."
|
||||||
|
value: 25
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
orc_war_banner:
|
||||||
|
name: "Orc War Banner"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: rare
|
||||||
|
description: "A bloodstained banner torn from an orc warband. Proof of a hard fight."
|
||||||
|
value: 45
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
berserker_charm:
|
||||||
|
name: "Berserker Charm"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: rare
|
||||||
|
description: "A crude charm worn by orc berserkers. Said to enhance rage."
|
||||||
|
value: 60
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Bandit Drops
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
bandit_mask:
|
||||||
|
name: "Bandit Mask"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: common
|
||||||
|
description: "A cloth mask worn by bandits to conceal their identity."
|
||||||
|
value: 8
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
stolen_coin_pouch:
|
||||||
|
name: "Stolen Coin Pouch"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: common
|
||||||
|
description: "A small pouch of coins stolen by bandits. Should be returned."
|
||||||
|
value: 15
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
wanted_poster:
|
||||||
|
name: "Wanted Poster"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A crumpled wanted poster. May lead to bounty opportunities."
|
||||||
|
value: 5
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Generic/Currency Items
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
gold_coin:
|
||||||
|
name: "Gold Coin"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: common
|
||||||
|
description: "A single gold coin. Standard currency across the realm."
|
||||||
|
value: 1
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
silver_coin:
|
||||||
|
name: "Silver Coin"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: common
|
||||||
|
description: "A silver coin worth less than gold but still useful."
|
||||||
|
value: 1
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Crafting Materials (Generic)
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
iron_ore:
|
||||||
|
name: "Iron Ore"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: common
|
||||||
|
description: "Raw iron ore that can be smelted into ingots."
|
||||||
|
value: 10
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
leather_scraps:
|
||||||
|
name: "Leather Scraps"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: common
|
||||||
|
description: "Scraps of leather useful for crafting and repairs."
|
||||||
|
value: 5
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
cloth_scraps:
|
||||||
|
name: "Cloth Scraps"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: common
|
||||||
|
description: "Torn cloth that can be sewn into bandages or used for crafting."
|
||||||
|
value: 3
|
||||||
|
is_tradeable: true
|
||||||
|
|
||||||
|
magic_essence:
|
||||||
|
name: "Magic Essence"
|
||||||
|
item_type: quest_item
|
||||||
|
rarity: uncommon
|
||||||
|
description: "Crystallized magical energy. Used in enchanting and alchemy."
|
||||||
|
value: 30
|
||||||
|
is_tradeable: true
|
||||||
@@ -9,6 +9,7 @@ from app.models.enums import (
|
|||||||
EffectType,
|
EffectType,
|
||||||
DamageType,
|
DamageType,
|
||||||
ItemType,
|
ItemType,
|
||||||
|
ItemRarity,
|
||||||
StatType,
|
StatType,
|
||||||
AbilityType,
|
AbilityType,
|
||||||
CombatStatus,
|
CombatStatus,
|
||||||
@@ -53,6 +54,7 @@ __all__ = [
|
|||||||
"EffectType",
|
"EffectType",
|
||||||
"DamageType",
|
"DamageType",
|
||||||
"ItemType",
|
"ItemType",
|
||||||
|
"ItemRarity",
|
||||||
"StatType",
|
"StatType",
|
||||||
"AbilityType",
|
"AbilityType",
|
||||||
"CombatStatus",
|
"CombatStatus",
|
||||||
|
|||||||
305
api/app/models/affixes.py
Normal file
305
api/app/models/affixes.py
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
"""
|
||||||
|
Item affix system for procedural item generation.
|
||||||
|
|
||||||
|
This module defines affixes (prefixes and suffixes) that can be attached to items
|
||||||
|
to provide stat bonuses and generate Diablo-style names like "Flaming Dagger of Strength".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
from app.models.enums import AffixType, AffixTier, DamageType, ItemRarity
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Affix:
|
||||||
|
"""
|
||||||
|
Represents a single item affix (prefix or suffix).
|
||||||
|
|
||||||
|
Affixes provide stat bonuses and contribute to item naming.
|
||||||
|
Prefixes appear before the item name: "Flaming Dagger"
|
||||||
|
Suffixes appear after the item name: "Dagger of Strength"
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
affix_id: Unique identifier (e.g., "flaming", "of_strength")
|
||||||
|
name: Display name for the affix (e.g., "Flaming", "of Strength")
|
||||||
|
affix_type: PREFIX or SUFFIX
|
||||||
|
tier: MINOR, MAJOR, or LEGENDARY (determines bonus magnitude)
|
||||||
|
description: Human-readable description of the affix effect
|
||||||
|
|
||||||
|
Stat Bonuses:
|
||||||
|
stat_bonuses: Dict mapping stat name to bonus value
|
||||||
|
Example: {"strength": 2, "constitution": 1}
|
||||||
|
defense_bonus: Direct defense bonus
|
||||||
|
resistance_bonus: Direct resistance bonus
|
||||||
|
|
||||||
|
Weapon Properties (PREFIX only, elemental):
|
||||||
|
damage_bonus: Flat damage bonus added to weapon
|
||||||
|
damage_type: Elemental damage type (fire, ice, etc.)
|
||||||
|
elemental_ratio: Portion of damage converted to elemental (0.0-1.0)
|
||||||
|
crit_chance_bonus: Added to weapon crit chance
|
||||||
|
crit_multiplier_bonus: Added to crit damage multiplier
|
||||||
|
|
||||||
|
Restrictions:
|
||||||
|
allowed_item_types: Empty list = all types allowed
|
||||||
|
required_rarity: Minimum rarity to roll this affix (for legendary-only)
|
||||||
|
"""
|
||||||
|
|
||||||
|
affix_id: str
|
||||||
|
name: str
|
||||||
|
affix_type: AffixType
|
||||||
|
tier: AffixTier
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
# Stat bonuses (applies to any item)
|
||||||
|
stat_bonuses: Dict[str, int] = field(default_factory=dict)
|
||||||
|
defense_bonus: int = 0
|
||||||
|
resistance_bonus: int = 0
|
||||||
|
|
||||||
|
# Weapon-specific bonuses
|
||||||
|
damage_bonus: int = 0
|
||||||
|
damage_type: Optional[DamageType] = None
|
||||||
|
elemental_ratio: float = 0.0
|
||||||
|
crit_chance_bonus: float = 0.0
|
||||||
|
crit_multiplier_bonus: float = 0.0
|
||||||
|
|
||||||
|
# Restrictions
|
||||||
|
allowed_item_types: List[str] = field(default_factory=list)
|
||||||
|
required_rarity: Optional[str] = None
|
||||||
|
|
||||||
|
def applies_elemental_damage(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if this affix converts damage to elemental.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if affix adds elemental damage component
|
||||||
|
"""
|
||||||
|
return self.damage_type is not None and self.elemental_ratio > 0.0
|
||||||
|
|
||||||
|
def is_legendary_only(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if this affix only rolls on legendary items.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if affix requires legendary rarity
|
||||||
|
"""
|
||||||
|
return self.required_rarity == "legendary"
|
||||||
|
|
||||||
|
def can_apply_to(self, item_type: str, rarity: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if this affix can be applied to an item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_type: Type of item ("weapon", "armor", etc.)
|
||||||
|
rarity: Item rarity ("common", "rare", "epic", "legendary")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if affix can be applied, False otherwise
|
||||||
|
"""
|
||||||
|
# Check rarity requirement
|
||||||
|
if self.required_rarity and rarity != self.required_rarity:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check item type restriction
|
||||||
|
if self.allowed_item_types and item_type not in self.allowed_item_types:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Serialize affix to dictionary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing all affix data
|
||||||
|
"""
|
||||||
|
data = asdict(self)
|
||||||
|
data["affix_type"] = self.affix_type.value
|
||||||
|
data["tier"] = self.tier.value
|
||||||
|
if self.damage_type:
|
||||||
|
data["damage_type"] = self.damage_type.value
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> 'Affix':
|
||||||
|
"""
|
||||||
|
Deserialize affix from dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary containing affix data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Affix instance
|
||||||
|
"""
|
||||||
|
affix_type = AffixType(data["affix_type"])
|
||||||
|
tier = AffixTier(data["tier"])
|
||||||
|
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
affix_id=data["affix_id"],
|
||||||
|
name=data["name"],
|
||||||
|
affix_type=affix_type,
|
||||||
|
tier=tier,
|
||||||
|
description=data.get("description", ""),
|
||||||
|
stat_bonuses=data.get("stat_bonuses", {}),
|
||||||
|
defense_bonus=data.get("defense_bonus", 0),
|
||||||
|
resistance_bonus=data.get("resistance_bonus", 0),
|
||||||
|
damage_bonus=data.get("damage_bonus", 0),
|
||||||
|
damage_type=damage_type,
|
||||||
|
elemental_ratio=data.get("elemental_ratio", 0.0),
|
||||||
|
crit_chance_bonus=data.get("crit_chance_bonus", 0.0),
|
||||||
|
crit_multiplier_bonus=data.get("crit_multiplier_bonus", 0.0),
|
||||||
|
allowed_item_types=data.get("allowed_item_types", []),
|
||||||
|
required_rarity=data.get("required_rarity"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of the affix."""
|
||||||
|
bonuses = []
|
||||||
|
if self.stat_bonuses:
|
||||||
|
bonuses.append(f"stats={self.stat_bonuses}")
|
||||||
|
if self.damage_bonus:
|
||||||
|
bonuses.append(f"dmg+{self.damage_bonus}")
|
||||||
|
if self.defense_bonus:
|
||||||
|
bonuses.append(f"def+{self.defense_bonus}")
|
||||||
|
if self.applies_elemental_damage():
|
||||||
|
bonuses.append(f"{self.damage_type.value}={self.elemental_ratio:.0%}")
|
||||||
|
|
||||||
|
bonus_str = ", ".join(bonuses) if bonuses else "no bonuses"
|
||||||
|
return f"Affix({self.name}, {self.affix_type.value}, {self.tier.value}, {bonus_str})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BaseItemTemplate:
|
||||||
|
"""
|
||||||
|
Template for base items used in procedural generation.
|
||||||
|
|
||||||
|
Base items define the foundation (e.g., "Dagger", "Longsword", "Chainmail")
|
||||||
|
that affixes attach to during item generation.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
template_id: Unique identifier (e.g., "dagger", "longsword")
|
||||||
|
name: Display name (e.g., "Dagger", "Longsword")
|
||||||
|
item_type: Category ("weapon", "armor")
|
||||||
|
description: Flavor text for the base item
|
||||||
|
|
||||||
|
Base Stats:
|
||||||
|
base_damage: Base weapon damage (weapons only)
|
||||||
|
base_defense: Base armor defense (armor only)
|
||||||
|
base_resistance: Base magic resistance (armor only)
|
||||||
|
base_value: Base gold value before rarity/affix modifiers
|
||||||
|
|
||||||
|
Weapon Properties:
|
||||||
|
damage_type: Primary damage type (usually "physical")
|
||||||
|
crit_chance: Base critical hit chance
|
||||||
|
crit_multiplier: Base critical damage multiplier
|
||||||
|
|
||||||
|
Generation:
|
||||||
|
required_level: Minimum character level for this template
|
||||||
|
drop_weight: Weighting for random selection (higher = more common)
|
||||||
|
min_rarity: Minimum rarity this template can generate at
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_id: str
|
||||||
|
name: str
|
||||||
|
item_type: str # "weapon" or "armor"
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
# Base stats
|
||||||
|
base_damage: int = 0
|
||||||
|
base_spell_power: int = 0 # For magical weapons (staves, wands)
|
||||||
|
base_defense: int = 0
|
||||||
|
base_resistance: int = 0
|
||||||
|
base_value: int = 10
|
||||||
|
|
||||||
|
# Weapon properties
|
||||||
|
damage_type: str = "physical"
|
||||||
|
crit_chance: float = 0.05
|
||||||
|
crit_multiplier: float = 2.0
|
||||||
|
|
||||||
|
# Generation settings
|
||||||
|
required_level: int = 1
|
||||||
|
drop_weight: float = 1.0
|
||||||
|
min_rarity: str = "common"
|
||||||
|
|
||||||
|
def can_generate_at_rarity(self, rarity: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if this template can generate at a given rarity.
|
||||||
|
|
||||||
|
Some templates (like greatswords) may only drop at rare+.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rarity: Target rarity to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if template can generate at this rarity
|
||||||
|
"""
|
||||||
|
rarity_order = ["common", "uncommon", "rare", "epic", "legendary"]
|
||||||
|
min_index = rarity_order.index(self.min_rarity)
|
||||||
|
target_index = rarity_order.index(rarity)
|
||||||
|
return target_index >= min_index
|
||||||
|
|
||||||
|
def can_drop_for_level(self, character_level: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if this template can drop for a character level.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_level: Character's current level
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if template can drop for this level
|
||||||
|
"""
|
||||||
|
return character_level >= self.required_level
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Serialize template to dictionary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing all template data
|
||||||
|
"""
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> 'BaseItemTemplate':
|
||||||
|
"""
|
||||||
|
Deserialize template from dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary containing template data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BaseItemTemplate instance
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
template_id=data["template_id"],
|
||||||
|
name=data["name"],
|
||||||
|
item_type=data["item_type"],
|
||||||
|
description=data.get("description", ""),
|
||||||
|
base_damage=data.get("base_damage", 0),
|
||||||
|
base_spell_power=data.get("base_spell_power", 0),
|
||||||
|
base_defense=data.get("base_defense", 0),
|
||||||
|
base_resistance=data.get("base_resistance", 0),
|
||||||
|
base_value=data.get("base_value", 10),
|
||||||
|
damage_type=data.get("damage_type", "physical"),
|
||||||
|
crit_chance=data.get("crit_chance", 0.05),
|
||||||
|
crit_multiplier=data.get("crit_multiplier", 2.0),
|
||||||
|
required_level=data.get("required_level", 1),
|
||||||
|
drop_weight=data.get("drop_weight", 1.0),
|
||||||
|
min_rarity=data.get("min_rarity", "common"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of the template."""
|
||||||
|
if self.item_type == "weapon":
|
||||||
|
return (
|
||||||
|
f"BaseItemTemplate({self.name}, weapon, dmg={self.base_damage}, "
|
||||||
|
f"crit={self.crit_chance*100:.0f}%, lvl={self.required_level})"
|
||||||
|
)
|
||||||
|
elif self.item_type == "armor":
|
||||||
|
return (
|
||||||
|
f"BaseItemTemplate({self.name}, armor, def={self.base_defense}, "
|
||||||
|
f"res={self.base_resistance}, lvl={self.required_level})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return f"BaseItemTemplate({self.name}, {self.item_type})"
|
||||||
@@ -13,7 +13,7 @@ from app.models.stats import Stats
|
|||||||
from app.models.items import Item
|
from app.models.items import Item
|
||||||
from app.models.skills import PlayerClass, SkillNode
|
from app.models.skills import PlayerClass, SkillNode
|
||||||
from app.models.effects import Effect
|
from app.models.effects import Effect
|
||||||
from app.models.enums import EffectType, StatType
|
from app.models.enums import EffectType, StatType, ItemType
|
||||||
from app.models.origins import Origin
|
from app.models.origins import Origin
|
||||||
|
|
||||||
|
|
||||||
@@ -70,8 +70,20 @@ class Character:
|
|||||||
current_location: Optional[str] = None # Set to origin starting location on creation
|
current_location: Optional[str] = None # Set to origin starting location on creation
|
||||||
|
|
||||||
# NPC interaction tracking (persists across sessions)
|
# NPC interaction tracking (persists across sessions)
|
||||||
# Each entry: {npc_id: {interaction_count, relationship_level, dialogue_history, ...}}
|
# Each entry: {
|
||||||
# dialogue_history: List[{player_line: str, npc_response: str}]
|
# npc_id: str,
|
||||||
|
# first_met: str (ISO timestamp),
|
||||||
|
# last_interaction: str (ISO timestamp),
|
||||||
|
# interaction_count: int,
|
||||||
|
# revealed_secrets: List[int],
|
||||||
|
# relationship_level: int (0-100, 50=neutral),
|
||||||
|
# custom_flags: Dict[str, Any],
|
||||||
|
# recent_messages: List[Dict] (last 3 messages for AI context),
|
||||||
|
# format: [{player_message: str, npc_response: str, timestamp: str}, ...],
|
||||||
|
# total_messages: int (total conversation message count),
|
||||||
|
# dialogue_history: List[Dict] (DEPRECATED, use ChatMessageService for full history)
|
||||||
|
# }
|
||||||
|
# Full conversation history stored in chat_messages collection (unlimited)
|
||||||
npc_interactions: Dict[str, Dict] = field(default_factory=dict)
|
npc_interactions: Dict[str, Dict] = field(default_factory=dict)
|
||||||
|
|
||||||
def get_effective_stats(self, active_effects: Optional[List[Effect]] = None) -> Stats:
|
def get_effective_stats(self, active_effects: Optional[List[Effect]] = None) -> Stats:
|
||||||
@@ -80,7 +92,11 @@ class Character:
|
|||||||
|
|
||||||
This is the CRITICAL METHOD that combines:
|
This is the CRITICAL METHOD that combines:
|
||||||
1. Base stats (from character)
|
1. Base stats (from character)
|
||||||
2. Equipment bonuses (from equipped items)
|
2. Equipment bonuses (from equipped items):
|
||||||
|
- stat_bonuses dict applied to corresponding stats
|
||||||
|
- Weapon damage added to damage_bonus
|
||||||
|
- Weapon spell_power added to spell_power_bonus
|
||||||
|
- Armor defense/resistance added to defense_bonus/resistance_bonus
|
||||||
3. Skill tree bonuses (from unlocked skills)
|
3. Skill tree bonuses (from unlocked skills)
|
||||||
4. Active effect modifiers (buffs/debuffs)
|
4. Active effect modifiers (buffs/debuffs)
|
||||||
|
|
||||||
@@ -88,18 +104,30 @@ class Character:
|
|||||||
active_effects: Currently active effects on this character (from combat)
|
active_effects: Currently active effects on this character (from combat)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Stats instance with all modifiers applied
|
Stats instance with all modifiers applied (including computed
|
||||||
|
damage, defense, resistance properties that incorporate bonuses)
|
||||||
"""
|
"""
|
||||||
# Start with a copy of base stats
|
# Start with a copy of base stats
|
||||||
effective = self.base_stats.copy()
|
effective = self.base_stats.copy()
|
||||||
|
|
||||||
# Apply equipment bonuses
|
# Apply equipment bonuses
|
||||||
for item in self.equipped.values():
|
for item in self.equipped.values():
|
||||||
|
# Apply stat bonuses from item (e.g., +3 strength)
|
||||||
for stat_name, bonus in item.stat_bonuses.items():
|
for stat_name, bonus in item.stat_bonuses.items():
|
||||||
if hasattr(effective, stat_name):
|
if hasattr(effective, stat_name):
|
||||||
current_value = getattr(effective, stat_name)
|
current_value = getattr(effective, stat_name)
|
||||||
setattr(effective, stat_name, current_value + bonus)
|
setattr(effective, stat_name, current_value + bonus)
|
||||||
|
|
||||||
|
# Add weapon damage and spell_power to bonus fields
|
||||||
|
if item.item_type == ItemType.WEAPON:
|
||||||
|
effective.damage_bonus += item.damage
|
||||||
|
effective.spell_power_bonus += item.spell_power
|
||||||
|
|
||||||
|
# Add armor defense and resistance to bonus fields
|
||||||
|
if item.item_type == ItemType.ARMOR:
|
||||||
|
effective.defense_bonus += item.defense
|
||||||
|
effective.resistance_bonus += item.resistance
|
||||||
|
|
||||||
# Apply skill tree bonuses
|
# Apply skill tree bonuses
|
||||||
skill_bonuses = self._get_skill_bonuses()
|
skill_bonuses = self._get_skill_bonuses()
|
||||||
for stat_name, bonus in skill_bonuses.items():
|
for stat_name, bonus in skill_bonuses.items():
|
||||||
|
|||||||
172
api/app/models/chat_message.py
Normal file
172
api/app/models/chat_message.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"""
|
||||||
|
Chat Message Data Models.
|
||||||
|
|
||||||
|
This module defines the data structures for player-NPC chat messages,
|
||||||
|
stored in the Appwrite chat_messages collection for unlimited conversation history.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class MessageContext(Enum):
|
||||||
|
"""
|
||||||
|
Context type for chat messages.
|
||||||
|
|
||||||
|
Indicates the type of interaction that generated this message,
|
||||||
|
useful for filtering and quest/faction tracking.
|
||||||
|
"""
|
||||||
|
DIALOGUE = "dialogue" # General conversation
|
||||||
|
QUEST_OFFERED = "quest_offered" # Quest offering dialogue
|
||||||
|
QUEST_COMPLETED = "quest_completed" # Quest completion dialogue
|
||||||
|
SHOP = "shop" # Merchant transaction
|
||||||
|
LOCATION_REVEALED = "location_revealed" # New location discovered through chat
|
||||||
|
LORE = "lore" # Lore/backstory reveals
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChatMessage:
|
||||||
|
"""
|
||||||
|
Represents a single message exchange between a player and an NPC.
|
||||||
|
|
||||||
|
This is the core data model for the chat log system. Each message
|
||||||
|
represents a complete exchange: what the player said and how the NPC responded.
|
||||||
|
|
||||||
|
Stored in: Appwrite chat_messages collection
|
||||||
|
Indexed by: character_id, npc_id, timestamp, session_id, context
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
message_id: Unique identifier (UUID)
|
||||||
|
character_id: Player's character ID
|
||||||
|
npc_id: NPC identifier
|
||||||
|
player_message: What the player said to the NPC
|
||||||
|
npc_response: NPC's reply
|
||||||
|
timestamp: When the message was created (ISO 8601)
|
||||||
|
session_id: Game session reference (optional, for session-based queries)
|
||||||
|
location_id: Where conversation happened (optional)
|
||||||
|
context: Type of interaction (dialogue, quest, shop, etc.)
|
||||||
|
metadata: Extensible JSON field for quest_id, faction_id, item_id, etc.
|
||||||
|
is_deleted: Soft delete flag (for privacy/moderation)
|
||||||
|
"""
|
||||||
|
message_id: str
|
||||||
|
character_id: str
|
||||||
|
npc_id: str
|
||||||
|
player_message: str
|
||||||
|
npc_response: str
|
||||||
|
timestamp: str # ISO 8601 format
|
||||||
|
context: MessageContext
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
location_id: Optional[str] = None
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
is_deleted: bool = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create(
|
||||||
|
character_id: str,
|
||||||
|
npc_id: str,
|
||||||
|
player_message: str,
|
||||||
|
npc_response: str,
|
||||||
|
context: MessageContext = MessageContext.DIALOGUE,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
location_id: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None
|
||||||
|
) -> "ChatMessage":
|
||||||
|
"""
|
||||||
|
Factory method to create a new ChatMessage with auto-generated ID and timestamp.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_id: Player's character ID
|
||||||
|
npc_id: NPC identifier
|
||||||
|
player_message: What the player said
|
||||||
|
npc_response: NPC's reply
|
||||||
|
context: Type of interaction (default: DIALOGUE)
|
||||||
|
session_id: Optional game session reference
|
||||||
|
location_id: Optional location where conversation happened
|
||||||
|
metadata: Optional extensible metadata (quest_id, faction_id, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New ChatMessage instance with generated ID and current timestamp
|
||||||
|
"""
|
||||||
|
return ChatMessage(
|
||||||
|
message_id=str(uuid.uuid4()),
|
||||||
|
character_id=character_id,
|
||||||
|
npc_id=npc_id,
|
||||||
|
player_message=player_message,
|
||||||
|
npc_response=npc_response,
|
||||||
|
timestamp=datetime.utcnow().isoformat() + "Z",
|
||||||
|
context=context,
|
||||||
|
session_id=session_id,
|
||||||
|
location_id=location_id,
|
||||||
|
metadata=metadata or {},
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert ChatMessage to dictionary for JSON serialization.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary representation with MessageContext converted to string
|
||||||
|
"""
|
||||||
|
data = asdict(self)
|
||||||
|
data["context"] = self.context.value # Convert enum to string
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: Dict[str, Any]) -> "ChatMessage":
|
||||||
|
"""
|
||||||
|
Create ChatMessage from dictionary (Appwrite document).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary from Appwrite document
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChatMessage instance
|
||||||
|
"""
|
||||||
|
# Convert context string to enum
|
||||||
|
if isinstance(data.get("context"), str):
|
||||||
|
data["context"] = MessageContext(data["context"])
|
||||||
|
|
||||||
|
return ChatMessage(**data)
|
||||||
|
|
||||||
|
def to_preview(self) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Convert to lightweight preview format for character.npc_interactions.recent_messages.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with only player_message, npc_response, timestamp
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"player_message": self.player_message,
|
||||||
|
"npc_response": self.npc_response,
|
||||||
|
"timestamp": self.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConversationSummary:
|
||||||
|
"""
|
||||||
|
Summary of all messages with a specific NPC.
|
||||||
|
|
||||||
|
Used for the "conversations list" UI to show all NPCs
|
||||||
|
the character has talked to.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
npc_id: NPC identifier
|
||||||
|
npc_name: NPC display name (fetched from NPC data)
|
||||||
|
last_message_timestamp: When the last message was sent
|
||||||
|
message_count: Total number of messages exchanged
|
||||||
|
recent_preview: Short preview of most recent NPC response
|
||||||
|
"""
|
||||||
|
npc_id: str
|
||||||
|
npc_name: str
|
||||||
|
last_message_timestamp: str
|
||||||
|
message_count: int
|
||||||
|
recent_preview: str
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary for JSON serialization."""
|
||||||
|
return asdict(self)
|
||||||
@@ -12,7 +12,7 @@ import random
|
|||||||
from app.models.stats import Stats
|
from app.models.stats import Stats
|
||||||
from app.models.effects import Effect
|
from app.models.effects import Effect
|
||||||
from app.models.abilities import Ability
|
from app.models.abilities import Ability
|
||||||
from app.models.enums import CombatStatus, EffectType
|
from app.models.enums import CombatStatus, EffectType, DamageType
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -36,6 +36,12 @@ class Combatant:
|
|||||||
abilities: Available abilities for this combatant
|
abilities: Available abilities for this combatant
|
||||||
cooldowns: Map of ability_id to turns remaining
|
cooldowns: Map of ability_id to turns remaining
|
||||||
initiative: Turn order value (rolled at combat start)
|
initiative: Turn order value (rolled at combat start)
|
||||||
|
weapon_crit_chance: Critical hit chance from equipped weapon
|
||||||
|
weapon_crit_multiplier: Critical hit damage multiplier
|
||||||
|
weapon_damage_type: Primary damage type of weapon
|
||||||
|
elemental_damage_type: Secondary damage type for elemental weapons
|
||||||
|
physical_ratio: Portion of damage that is physical (0.0-1.0)
|
||||||
|
elemental_ratio: Portion of damage that is elemental (0.0-1.0)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
combatant_id: str
|
combatant_id: str
|
||||||
@@ -51,6 +57,16 @@ class Combatant:
|
|||||||
cooldowns: Dict[str, int] = field(default_factory=dict)
|
cooldowns: Dict[str, int] = field(default_factory=dict)
|
||||||
initiative: int = 0
|
initiative: int = 0
|
||||||
|
|
||||||
|
# Weapon properties (for combat calculations)
|
||||||
|
weapon_crit_chance: float = 0.05
|
||||||
|
weapon_crit_multiplier: float = 2.0
|
||||||
|
weapon_damage_type: Optional[DamageType] = None
|
||||||
|
|
||||||
|
# Elemental weapon properties (for split damage)
|
||||||
|
elemental_damage_type: Optional[DamageType] = None
|
||||||
|
physical_ratio: float = 1.0
|
||||||
|
elemental_ratio: float = 0.0
|
||||||
|
|
||||||
def is_alive(self) -> bool:
|
def is_alive(self) -> bool:
|
||||||
"""Check if combatant is still alive."""
|
"""Check if combatant is still alive."""
|
||||||
return self.current_hp > 0
|
return self.current_hp > 0
|
||||||
@@ -228,6 +244,12 @@ class Combatant:
|
|||||||
"abilities": self.abilities,
|
"abilities": self.abilities,
|
||||||
"cooldowns": self.cooldowns,
|
"cooldowns": self.cooldowns,
|
||||||
"initiative": self.initiative,
|
"initiative": self.initiative,
|
||||||
|
"weapon_crit_chance": self.weapon_crit_chance,
|
||||||
|
"weapon_crit_multiplier": self.weapon_crit_multiplier,
|
||||||
|
"weapon_damage_type": self.weapon_damage_type.value if self.weapon_damage_type else None,
|
||||||
|
"elemental_damage_type": self.elemental_damage_type.value if self.elemental_damage_type else None,
|
||||||
|
"physical_ratio": self.physical_ratio,
|
||||||
|
"elemental_ratio": self.elemental_ratio,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -236,6 +258,15 @@ class Combatant:
|
|||||||
stats = Stats.from_dict(data["stats"])
|
stats = Stats.from_dict(data["stats"])
|
||||||
active_effects = [Effect.from_dict(e) for e in data.get("active_effects", [])]
|
active_effects = [Effect.from_dict(e) for e in data.get("active_effects", [])]
|
||||||
|
|
||||||
|
# Parse damage types
|
||||||
|
weapon_damage_type = None
|
||||||
|
if data.get("weapon_damage_type"):
|
||||||
|
weapon_damage_type = DamageType(data["weapon_damage_type"])
|
||||||
|
|
||||||
|
elemental_damage_type = None
|
||||||
|
if data.get("elemental_damage_type"):
|
||||||
|
elemental_damage_type = DamageType(data["elemental_damage_type"])
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
combatant_id=data["combatant_id"],
|
combatant_id=data["combatant_id"],
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
@@ -249,6 +280,12 @@ class Combatant:
|
|||||||
abilities=data.get("abilities", []),
|
abilities=data.get("abilities", []),
|
||||||
cooldowns=data.get("cooldowns", {}),
|
cooldowns=data.get("cooldowns", {}),
|
||||||
initiative=data.get("initiative", 0),
|
initiative=data.get("initiative", 0),
|
||||||
|
weapon_crit_chance=data.get("weapon_crit_chance", 0.05),
|
||||||
|
weapon_crit_multiplier=data.get("weapon_crit_multiplier", 2.0),
|
||||||
|
weapon_damage_type=weapon_damage_type,
|
||||||
|
elemental_damage_type=elemental_damage_type,
|
||||||
|
physical_ratio=data.get("physical_ratio", 1.0),
|
||||||
|
elemental_ratio=data.get("elemental_ratio", 0.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -312,15 +349,33 @@ class CombatEncounter:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def advance_turn(self) -> None:
|
def advance_turn(self) -> None:
|
||||||
"""Advance to the next combatant's turn."""
|
"""Advance to the next alive combatant's turn, skipping dead combatants."""
|
||||||
|
# Track starting position to detect full cycle
|
||||||
|
start_index = self.current_turn_index
|
||||||
|
rounds_advanced = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
self.current_turn_index += 1
|
self.current_turn_index += 1
|
||||||
|
|
||||||
# If we've cycled through all combatants, start a new round
|
# If we've cycled through all combatants, start a new round
|
||||||
if self.current_turn_index >= len(self.turn_order):
|
if self.current_turn_index >= len(self.turn_order):
|
||||||
self.current_turn_index = 0
|
self.current_turn_index = 0
|
||||||
self.round_number += 1
|
self.round_number += 1
|
||||||
|
rounds_advanced += 1
|
||||||
self.log_action("round_start", None, f"Round {self.round_number} begins")
|
self.log_action("round_start", None, f"Round {self.round_number} begins")
|
||||||
|
|
||||||
|
# Get the current combatant
|
||||||
|
current = self.get_current_combatant()
|
||||||
|
|
||||||
|
# If combatant is alive, their turn starts
|
||||||
|
if current and current.is_alive():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Safety check: if we've gone through all combatants twice without finding
|
||||||
|
# someone alive, break to avoid infinite loop (combat should end)
|
||||||
|
if rounds_advanced >= 2:
|
||||||
|
break
|
||||||
|
|
||||||
def start_turn(self) -> List[Dict[str, Any]]:
|
def start_turn(self) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Process the start of a turn.
|
Process the start of a turn.
|
||||||
|
|||||||
@@ -86,7 +86,12 @@ class Effect:
|
|||||||
|
|
||||||
elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
||||||
# Buff/Debuff: modify stats
|
# Buff/Debuff: modify stats
|
||||||
result["stat_affected"] = self.stat_affected.value if self.stat_affected else None
|
# Handle stat_affected being Enum or string
|
||||||
|
if self.stat_affected:
|
||||||
|
stat_value = self.stat_affected.value if hasattr(self.stat_affected, 'value') else self.stat_affected
|
||||||
|
else:
|
||||||
|
stat_value = None
|
||||||
|
result["stat_affected"] = stat_value
|
||||||
result["stat_modifier"] = self.power * self.stacks
|
result["stat_modifier"] = self.power * self.stacks
|
||||||
if self.effect_type == EffectType.BUFF:
|
if self.effect_type == EffectType.BUFF:
|
||||||
result["message"] = f"{self.name} increases {result['stat_affected']} by {result['stat_modifier']}"
|
result["message"] = f"{self.name} increases {result['stat_affected']} by {result['stat_modifier']}"
|
||||||
@@ -159,9 +164,17 @@ class Effect:
|
|||||||
Dictionary containing all effect data
|
Dictionary containing all effect data
|
||||||
"""
|
"""
|
||||||
data = asdict(self)
|
data = asdict(self)
|
||||||
|
# Handle effect_type (could be Enum or string)
|
||||||
|
if hasattr(self.effect_type, 'value'):
|
||||||
data["effect_type"] = self.effect_type.value
|
data["effect_type"] = self.effect_type.value
|
||||||
|
else:
|
||||||
|
data["effect_type"] = self.effect_type
|
||||||
|
# Handle stat_affected (could be Enum, string, or None)
|
||||||
if self.stat_affected:
|
if self.stat_affected:
|
||||||
|
if hasattr(self.stat_affected, 'value'):
|
||||||
data["stat_affected"] = self.stat_affected.value
|
data["stat_affected"] = self.stat_affected.value
|
||||||
|
else:
|
||||||
|
data["stat_affected"] = self.stat_affected
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -193,16 +206,21 @@ class Effect:
|
|||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""String representation of the effect."""
|
"""String representation of the effect."""
|
||||||
|
# Helper to safely get value from Enum or string
|
||||||
|
def safe_value(obj):
|
||||||
|
return obj.value if hasattr(obj, 'value') else obj
|
||||||
|
|
||||||
if self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
if self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
||||||
|
stat_str = safe_value(self.stat_affected) if self.stat_affected else 'N/A'
|
||||||
return (
|
return (
|
||||||
f"Effect({self.name}, {self.effect_type.value}, "
|
f"Effect({self.name}, {safe_value(self.effect_type)}, "
|
||||||
f"{self.stat_affected.value if self.stat_affected else 'N/A'} "
|
f"{stat_str} "
|
||||||
f"{'+' if self.effect_type == EffectType.BUFF else '-'}{self.power * self.stacks}, "
|
f"{'+' if self.effect_type == EffectType.BUFF else '-'}{self.power * self.stacks}, "
|
||||||
f"{self.duration}t, {self.stacks}x)"
|
f"{self.duration}t, {self.stacks}x)"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return (
|
return (
|
||||||
f"Effect({self.name}, {self.effect_type.value}, "
|
f"Effect({self.name}, {safe_value(self.effect_type)}, "
|
||||||
f"power={self.power * self.stacks}, "
|
f"power={self.power * self.stacks}, "
|
||||||
f"duration={self.duration}t, stacks={self.stacks}x)"
|
f"duration={self.duration}t, stacks={self.stacks}x)"
|
||||||
)
|
)
|
||||||
|
|||||||
282
api/app/models/enemy.py
Normal file
282
api/app/models/enemy.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
"""
|
||||||
|
Enemy data models for combat encounters.
|
||||||
|
|
||||||
|
This module defines the EnemyTemplate dataclass representing enemies/monsters
|
||||||
|
that can be encountered in combat. Enemy definitions are loaded from YAML files
|
||||||
|
for data-driven game design.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from app.models.stats import Stats
|
||||||
|
|
||||||
|
|
||||||
|
class EnemyDifficulty(Enum):
|
||||||
|
"""Enemy difficulty levels for scaling and encounter building."""
|
||||||
|
EASY = "easy"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HARD = "hard"
|
||||||
|
BOSS = "boss"
|
||||||
|
|
||||||
|
|
||||||
|
class LootType(Enum):
|
||||||
|
"""
|
||||||
|
Types of loot drops in enemy loot tables.
|
||||||
|
|
||||||
|
STATIC: Fixed item_id reference (consumables, quest items, materials)
|
||||||
|
PROCEDURAL: Procedurally generated equipment (weapons, armor with affixes)
|
||||||
|
"""
|
||||||
|
STATIC = "static"
|
||||||
|
PROCEDURAL = "procedural"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LootEntry:
|
||||||
|
"""
|
||||||
|
Single entry in an enemy's loot table.
|
||||||
|
|
||||||
|
Supports two types of loot:
|
||||||
|
|
||||||
|
STATIC loot (default):
|
||||||
|
- item_id references a predefined item (health_potion, gold_coin, etc.)
|
||||||
|
- quantity_min/max define stack size
|
||||||
|
|
||||||
|
PROCEDURAL loot:
|
||||||
|
- item_type specifies "weapon" or "armor"
|
||||||
|
- rarity_bonus adds to rarity roll (difficulty contribution)
|
||||||
|
- Generated equipment uses the ItemGenerator system
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
loot_type: Type of loot (static or procedural)
|
||||||
|
drop_chance: Probability of dropping (0.0 to 1.0)
|
||||||
|
quantity_min: Minimum quantity if dropped
|
||||||
|
quantity_max: Maximum quantity if dropped
|
||||||
|
item_id: Reference to item definition (for STATIC loot)
|
||||||
|
item_type: Type of equipment to generate (for PROCEDURAL loot)
|
||||||
|
rarity_bonus: Added to rarity roll (0.0 to 0.5, for PROCEDURAL)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Common fields
|
||||||
|
loot_type: LootType = LootType.STATIC
|
||||||
|
drop_chance: float = 0.1
|
||||||
|
quantity_min: int = 1
|
||||||
|
quantity_max: int = 1
|
||||||
|
|
||||||
|
# Static loot fields
|
||||||
|
item_id: Optional[str] = None
|
||||||
|
|
||||||
|
# Procedural loot fields
|
||||||
|
item_type: Optional[str] = None # "weapon" or "armor"
|
||||||
|
rarity_bonus: float = 0.0 # Added to rarity roll (0.0 to 0.5)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize loot entry to dictionary."""
|
||||||
|
data = {
|
||||||
|
"loot_type": self.loot_type.value,
|
||||||
|
"drop_chance": self.drop_chance,
|
||||||
|
"quantity_min": self.quantity_min,
|
||||||
|
"quantity_max": self.quantity_max,
|
||||||
|
}
|
||||||
|
# Only include relevant fields based on loot type
|
||||||
|
if self.item_id is not None:
|
||||||
|
data["item_id"] = self.item_id
|
||||||
|
if self.item_type is not None:
|
||||||
|
data["item_type"] = self.item_type
|
||||||
|
data["rarity_bonus"] = self.rarity_bonus
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> 'LootEntry':
|
||||||
|
"""
|
||||||
|
Deserialize loot entry from dictionary.
|
||||||
|
|
||||||
|
Backward compatible: entries without loot_type default to STATIC,
|
||||||
|
and item_id is required for STATIC entries (for backward compat).
|
||||||
|
"""
|
||||||
|
# Parse loot type with backward compatibility
|
||||||
|
loot_type_str = data.get("loot_type", "static")
|
||||||
|
loot_type = LootType(loot_type_str)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
loot_type=loot_type,
|
||||||
|
drop_chance=data.get("drop_chance", 0.1),
|
||||||
|
quantity_min=data.get("quantity_min", 1),
|
||||||
|
quantity_max=data.get("quantity_max", 1),
|
||||||
|
item_id=data.get("item_id"),
|
||||||
|
item_type=data.get("item_type"),
|
||||||
|
rarity_bonus=data.get("rarity_bonus", 0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EnemyTemplate:
|
||||||
|
"""
|
||||||
|
Template definition for an enemy type.
|
||||||
|
|
||||||
|
EnemyTemplates define the base characteristics of enemy types. When combat
|
||||||
|
starts, instances are created from templates with randomized variations.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
enemy_id: Unique identifier (e.g., "goblin", "dire_wolf")
|
||||||
|
name: Display name (e.g., "Goblin Scout")
|
||||||
|
description: Flavor text about the enemy
|
||||||
|
base_stats: Base stat block for this enemy
|
||||||
|
abilities: List of ability_ids this enemy can use
|
||||||
|
loot_table: Potential drops on defeat
|
||||||
|
experience_reward: Base XP granted on defeat
|
||||||
|
gold_reward_min: Minimum gold dropped
|
||||||
|
gold_reward_max: Maximum gold dropped
|
||||||
|
difficulty: Difficulty classification for encounter building
|
||||||
|
tags: Classification tags (e.g., ["humanoid", "goblinoid"])
|
||||||
|
location_tags: Location types where this enemy appears (e.g., ["forest", "dungeon"])
|
||||||
|
image_url: Optional image reference for UI
|
||||||
|
|
||||||
|
Combat-specific attributes:
|
||||||
|
base_damage: Base damage for basic attack (no weapon)
|
||||||
|
crit_chance: Critical hit chance (0.0 to 1.0)
|
||||||
|
flee_chance: Chance to successfully flee from this enemy
|
||||||
|
"""
|
||||||
|
|
||||||
|
enemy_id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
base_stats: Stats
|
||||||
|
abilities: List[str] = field(default_factory=list)
|
||||||
|
loot_table: List[LootEntry] = field(default_factory=list)
|
||||||
|
experience_reward: int = 10
|
||||||
|
gold_reward_min: int = 1
|
||||||
|
gold_reward_max: int = 5
|
||||||
|
difficulty: EnemyDifficulty = EnemyDifficulty.EASY
|
||||||
|
tags: List[str] = field(default_factory=list)
|
||||||
|
location_tags: List[str] = field(default_factory=list)
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
|
||||||
|
# Combat attributes
|
||||||
|
base_damage: int = 5
|
||||||
|
crit_chance: float = 0.05
|
||||||
|
flee_chance: float = 0.5
|
||||||
|
|
||||||
|
def get_gold_reward(self) -> int:
|
||||||
|
"""
|
||||||
|
Roll random gold reward within range.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Random gold amount between min and max
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
return random.randint(self.gold_reward_min, self.gold_reward_max)
|
||||||
|
|
||||||
|
def roll_loot(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Roll for loot drops based on loot table.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dropped items with quantities
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
drops = []
|
||||||
|
|
||||||
|
for entry in self.loot_table:
|
||||||
|
if random.random() < entry.drop_chance:
|
||||||
|
quantity = random.randint(entry.quantity_min, entry.quantity_max)
|
||||||
|
drops.append({
|
||||||
|
"item_id": entry.item_id,
|
||||||
|
"quantity": quantity,
|
||||||
|
})
|
||||||
|
|
||||||
|
return drops
|
||||||
|
|
||||||
|
def is_boss(self) -> bool:
|
||||||
|
"""Check if this enemy is a boss."""
|
||||||
|
return self.difficulty == EnemyDifficulty.BOSS
|
||||||
|
|
||||||
|
def has_tag(self, tag: str) -> bool:
|
||||||
|
"""Check if enemy has a specific tag."""
|
||||||
|
return tag.lower() in [t.lower() for t in self.tags]
|
||||||
|
|
||||||
|
def has_location_tag(self, location_type: str) -> bool:
|
||||||
|
"""Check if enemy can appear at a specific location type."""
|
||||||
|
return location_type.lower() in [t.lower() for t in self.location_tags]
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Serialize enemy template to dictionary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing all enemy data
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"enemy_id": self.enemy_id,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"base_stats": self.base_stats.to_dict(),
|
||||||
|
"abilities": self.abilities,
|
||||||
|
"loot_table": [entry.to_dict() for entry in self.loot_table],
|
||||||
|
"experience_reward": self.experience_reward,
|
||||||
|
"gold_reward_min": self.gold_reward_min,
|
||||||
|
"gold_reward_max": self.gold_reward_max,
|
||||||
|
"difficulty": self.difficulty.value,
|
||||||
|
"tags": self.tags,
|
||||||
|
"location_tags": self.location_tags,
|
||||||
|
"image_url": self.image_url,
|
||||||
|
"base_damage": self.base_damage,
|
||||||
|
"crit_chance": self.crit_chance,
|
||||||
|
"flee_chance": self.flee_chance,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> 'EnemyTemplate':
|
||||||
|
"""
|
||||||
|
Deserialize enemy template from dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary containing enemy data (from YAML or JSON)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EnemyTemplate instance
|
||||||
|
"""
|
||||||
|
# Parse base stats
|
||||||
|
stats_data = data.get("base_stats", {})
|
||||||
|
base_stats = Stats.from_dict(stats_data)
|
||||||
|
|
||||||
|
# Parse loot table
|
||||||
|
loot_table = [
|
||||||
|
LootEntry.from_dict(entry)
|
||||||
|
for entry in data.get("loot_table", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
# Parse difficulty
|
||||||
|
difficulty_value = data.get("difficulty", "easy")
|
||||||
|
if isinstance(difficulty_value, str):
|
||||||
|
difficulty = EnemyDifficulty(difficulty_value)
|
||||||
|
else:
|
||||||
|
difficulty = difficulty_value
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
enemy_id=data["enemy_id"],
|
||||||
|
name=data["name"],
|
||||||
|
description=data.get("description", ""),
|
||||||
|
base_stats=base_stats,
|
||||||
|
abilities=data.get("abilities", []),
|
||||||
|
loot_table=loot_table,
|
||||||
|
experience_reward=data.get("experience_reward", 10),
|
||||||
|
gold_reward_min=data.get("gold_reward_min", 1),
|
||||||
|
gold_reward_max=data.get("gold_reward_max", 5),
|
||||||
|
difficulty=difficulty,
|
||||||
|
tags=data.get("tags", []),
|
||||||
|
location_tags=data.get("location_tags", []),
|
||||||
|
image_url=data.get("image_url"),
|
||||||
|
base_damage=data.get("base_damage", 5),
|
||||||
|
crit_chance=data.get("crit_chance", 0.05),
|
||||||
|
flee_chance=data.get("flee_chance", 0.5),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of the enemy template."""
|
||||||
|
return (
|
||||||
|
f"EnemyTemplate({self.enemy_id}, {self.name}, "
|
||||||
|
f"difficulty={self.difficulty.value}, "
|
||||||
|
f"xp={self.experience_reward})"
|
||||||
|
)
|
||||||
@@ -29,6 +29,7 @@ class DamageType(Enum):
|
|||||||
HOLY = "holy" # Holy/divine damage
|
HOLY = "holy" # Holy/divine damage
|
||||||
SHADOW = "shadow" # Dark/shadow magic damage
|
SHADOW = "shadow" # Dark/shadow magic damage
|
||||||
POISON = "poison" # Poison damage (usually DoT)
|
POISON = "poison" # Poison damage (usually DoT)
|
||||||
|
ARCANE = "arcane" # Pure magical damage (staves, wands)
|
||||||
|
|
||||||
|
|
||||||
class ItemType(Enum):
|
class ItemType(Enum):
|
||||||
@@ -40,6 +41,31 @@ class ItemType(Enum):
|
|||||||
QUEST_ITEM = "quest_item" # Story-related, non-tradeable
|
QUEST_ITEM = "quest_item" # Story-related, non-tradeable
|
||||||
|
|
||||||
|
|
||||||
|
class ItemRarity(Enum):
|
||||||
|
"""Item rarity tiers affecting drop rates, value, and visual styling."""
|
||||||
|
|
||||||
|
COMMON = "common" # White/gray - basic items
|
||||||
|
UNCOMMON = "uncommon" # Green - slightly better
|
||||||
|
RARE = "rare" # Blue - noticeably better
|
||||||
|
EPIC = "epic" # Purple - powerful items
|
||||||
|
LEGENDARY = "legendary" # Orange/gold - best items
|
||||||
|
|
||||||
|
|
||||||
|
class AffixType(Enum):
|
||||||
|
"""Types of item affixes for procedural item generation."""
|
||||||
|
|
||||||
|
PREFIX = "prefix" # Appears before item name: "Flaming Dagger"
|
||||||
|
SUFFIX = "suffix" # Appears after item name: "Dagger of Strength"
|
||||||
|
|
||||||
|
|
||||||
|
class AffixTier(Enum):
|
||||||
|
"""Affix power tiers determining bonus magnitudes."""
|
||||||
|
|
||||||
|
MINOR = "minor" # Weaker bonuses, rolls on RARE items
|
||||||
|
MAJOR = "major" # Medium bonuses, rolls on EPIC items
|
||||||
|
LEGENDARY = "legendary" # Strongest bonuses, LEGENDARY only
|
||||||
|
|
||||||
|
|
||||||
class StatType(Enum):
|
class StatType(Enum):
|
||||||
"""Character attribute types."""
|
"""Character attribute types."""
|
||||||
|
|
||||||
@@ -49,6 +75,7 @@ class StatType(Enum):
|
|||||||
INTELLIGENCE = "intelligence" # Magical power
|
INTELLIGENCE = "intelligence" # Magical power
|
||||||
WISDOM = "wisdom" # Perception and insight
|
WISDOM = "wisdom" # Perception and insight
|
||||||
CHARISMA = "charisma" # Social influence
|
CHARISMA = "charisma" # Social influence
|
||||||
|
LUCK = "luck" # Fortune and fate
|
||||||
|
|
||||||
|
|
||||||
class AbilityType(Enum):
|
class AbilityType(Enum):
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ including weapons, armor, consumables, and quest items.
|
|||||||
from dataclasses import dataclass, field, asdict
|
from dataclasses import dataclass, field, asdict
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
from app.models.enums import ItemType, DamageType
|
from app.models.enums import ItemType, ItemRarity, DamageType
|
||||||
from app.models.effects import Effect
|
from app.models.effects import Effect
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ class Item:
|
|||||||
item_id: Unique identifier
|
item_id: Unique identifier
|
||||||
name: Display name
|
name: Display name
|
||||||
item_type: Category (weapon, armor, consumable, quest_item)
|
item_type: Category (weapon, armor, consumable, quest_item)
|
||||||
|
rarity: Rarity tier (common, uncommon, rare, epic, legendary)
|
||||||
description: Item lore and information
|
description: Item lore and information
|
||||||
value: Gold value for buying/selling
|
value: Gold value for buying/selling
|
||||||
is_tradeable: Whether item can be sold on marketplace
|
is_tradeable: Whether item can be sold on marketplace
|
||||||
@@ -32,7 +33,8 @@ class Item:
|
|||||||
effects_on_use: Effects applied when consumed (consumables only)
|
effects_on_use: Effects applied when consumed (consumables only)
|
||||||
|
|
||||||
Weapon-specific attributes:
|
Weapon-specific attributes:
|
||||||
damage: Base weapon damage
|
damage: Base weapon damage (physical/melee/ranged)
|
||||||
|
spell_power: Spell power for staves/wands (boosts spell damage)
|
||||||
damage_type: Type of damage (physical, fire, etc.)
|
damage_type: Type of damage (physical, fire, etc.)
|
||||||
crit_chance: Probability of critical hit (0.0 to 1.0)
|
crit_chance: Probability of critical hit (0.0 to 1.0)
|
||||||
crit_multiplier: Damage multiplier on critical hit
|
crit_multiplier: Damage multiplier on critical hit
|
||||||
@@ -49,7 +51,8 @@ class Item:
|
|||||||
item_id: str
|
item_id: str
|
||||||
name: str
|
name: str
|
||||||
item_type: ItemType
|
item_type: ItemType
|
||||||
description: str
|
rarity: ItemRarity = ItemRarity.COMMON
|
||||||
|
description: str = ""
|
||||||
value: int = 0
|
value: int = 0
|
||||||
is_tradeable: bool = True
|
is_tradeable: bool = True
|
||||||
|
|
||||||
@@ -60,11 +63,18 @@ class Item:
|
|||||||
effects_on_use: List[Effect] = field(default_factory=list)
|
effects_on_use: List[Effect] = field(default_factory=list)
|
||||||
|
|
||||||
# Weapon-specific
|
# Weapon-specific
|
||||||
damage: int = 0
|
damage: int = 0 # Physical damage for melee/ranged weapons
|
||||||
|
spell_power: int = 0 # Spell power for staves/wands (boosts spell damage)
|
||||||
damage_type: Optional[DamageType] = None
|
damage_type: Optional[DamageType] = None
|
||||||
crit_chance: float = 0.05 # 5% default critical hit chance
|
crit_chance: float = 0.05 # 5% default critical hit chance
|
||||||
crit_multiplier: float = 2.0 # 2x damage on critical hit
|
crit_multiplier: float = 2.0 # 2x damage on critical hit
|
||||||
|
|
||||||
|
# Elemental weapon properties (for split damage like Fire Sword)
|
||||||
|
# These enable weapons to deal both physical AND elemental damage
|
||||||
|
elemental_damage_type: Optional[DamageType] = None # Secondary damage type (fire, ice, etc.)
|
||||||
|
physical_ratio: float = 1.0 # Portion of damage that is physical (0.0-1.0)
|
||||||
|
elemental_ratio: float = 0.0 # Portion of damage that is elemental (0.0-1.0)
|
||||||
|
|
||||||
# Armor-specific
|
# Armor-specific
|
||||||
defense: int = 0
|
defense: int = 0
|
||||||
resistance: int = 0
|
resistance: int = 0
|
||||||
@@ -73,6 +83,24 @@ class Item:
|
|||||||
required_level: int = 1
|
required_level: int = 1
|
||||||
required_class: Optional[str] = None
|
required_class: Optional[str] = None
|
||||||
|
|
||||||
|
# Affix tracking (for procedurally generated items)
|
||||||
|
applied_affixes: List[str] = field(default_factory=list) # List of affix_ids
|
||||||
|
base_template_id: Optional[str] = None # ID of base item template used
|
||||||
|
generated_name: Optional[str] = None # Full generated name with affixes
|
||||||
|
is_generated: bool = False # True if created by item generator
|
||||||
|
|
||||||
|
def get_display_name(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the item's display name.
|
||||||
|
|
||||||
|
For generated items, returns the affix-enhanced name.
|
||||||
|
For static items, returns the base name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Display name string
|
||||||
|
"""
|
||||||
|
return self.generated_name or self.name
|
||||||
|
|
||||||
def is_weapon(self) -> bool:
|
def is_weapon(self) -> bool:
|
||||||
"""Check if this item is a weapon."""
|
"""Check if this item is a weapon."""
|
||||||
return self.item_type == ItemType.WEAPON
|
return self.item_type == ItemType.WEAPON
|
||||||
@@ -89,6 +117,39 @@ class Item:
|
|||||||
"""Check if this item is a quest item."""
|
"""Check if this item is a quest item."""
|
||||||
return self.item_type == ItemType.QUEST_ITEM
|
return self.item_type == ItemType.QUEST_ITEM
|
||||||
|
|
||||||
|
def is_elemental_weapon(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if this weapon deals elemental damage (split damage).
|
||||||
|
|
||||||
|
Elemental weapons deal both physical AND elemental damage,
|
||||||
|
calculated separately against DEF and RES.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
Fire Sword: 70% physical / 30% fire
|
||||||
|
Frost Blade: 60% physical / 40% ice
|
||||||
|
Lightning Spear: 50% physical / 50% lightning
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if weapon has elemental damage component
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
self.is_weapon() and
|
||||||
|
self.elemental_ratio > 0.0 and
|
||||||
|
self.elemental_damage_type is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_magical_weapon(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if this weapon is a spell-casting weapon (staff, wand, tome).
|
||||||
|
|
||||||
|
Magical weapons provide spell_power which boosts spell damage,
|
||||||
|
rather than physical damage for melee/ranged attacks.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if weapon has spell_power (staves, wands, etc.)
|
||||||
|
"""
|
||||||
|
return self.is_weapon() and self.spell_power > 0
|
||||||
|
|
||||||
def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool:
|
def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a character can equip this item.
|
Check if a character can equip this item.
|
||||||
@@ -131,9 +192,14 @@ class Item:
|
|||||||
"""
|
"""
|
||||||
data = asdict(self)
|
data = asdict(self)
|
||||||
data["item_type"] = self.item_type.value
|
data["item_type"] = self.item_type.value
|
||||||
|
data["rarity"] = self.rarity.value
|
||||||
if self.damage_type:
|
if self.damage_type:
|
||||||
data["damage_type"] = self.damage_type.value
|
data["damage_type"] = self.damage_type.value
|
||||||
|
if self.elemental_damage_type:
|
||||||
|
data["elemental_damage_type"] = self.elemental_damage_type.value
|
||||||
data["effects_on_use"] = [effect.to_dict() for effect in self.effects_on_use]
|
data["effects_on_use"] = [effect.to_dict() for effect in self.effects_on_use]
|
||||||
|
# Include display_name for convenience
|
||||||
|
data["display_name"] = self.get_display_name()
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -149,7 +215,13 @@ class Item:
|
|||||||
"""
|
"""
|
||||||
# Convert string values back to enums
|
# Convert string values back to enums
|
||||||
item_type = ItemType(data["item_type"])
|
item_type = ItemType(data["item_type"])
|
||||||
|
rarity = ItemRarity(data.get("rarity", "common"))
|
||||||
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
|
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
|
||||||
|
elemental_damage_type = (
|
||||||
|
DamageType(data["elemental_damage_type"])
|
||||||
|
if data.get("elemental_damage_type")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
# Deserialize effects
|
# Deserialize effects
|
||||||
effects = []
|
effects = []
|
||||||
@@ -160,7 +232,8 @@ class Item:
|
|||||||
item_id=data["item_id"],
|
item_id=data["item_id"],
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
item_type=item_type,
|
item_type=item_type,
|
||||||
description=data["description"],
|
rarity=rarity,
|
||||||
|
description=data.get("description", ""),
|
||||||
value=data.get("value", 0),
|
value=data.get("value", 0),
|
||||||
is_tradeable=data.get("is_tradeable", True),
|
is_tradeable=data.get("is_tradeable", True),
|
||||||
stat_bonuses=data.get("stat_bonuses", {}),
|
stat_bonuses=data.get("stat_bonuses", {}),
|
||||||
@@ -169,15 +242,29 @@ class Item:
|
|||||||
damage_type=damage_type,
|
damage_type=damage_type,
|
||||||
crit_chance=data.get("crit_chance", 0.05),
|
crit_chance=data.get("crit_chance", 0.05),
|
||||||
crit_multiplier=data.get("crit_multiplier", 2.0),
|
crit_multiplier=data.get("crit_multiplier", 2.0),
|
||||||
|
elemental_damage_type=elemental_damage_type,
|
||||||
|
physical_ratio=data.get("physical_ratio", 1.0),
|
||||||
|
elemental_ratio=data.get("elemental_ratio", 0.0),
|
||||||
defense=data.get("defense", 0),
|
defense=data.get("defense", 0),
|
||||||
resistance=data.get("resistance", 0),
|
resistance=data.get("resistance", 0),
|
||||||
required_level=data.get("required_level", 1),
|
required_level=data.get("required_level", 1),
|
||||||
required_class=data.get("required_class"),
|
required_class=data.get("required_class"),
|
||||||
|
# Affix tracking fields
|
||||||
|
applied_affixes=data.get("applied_affixes", []),
|
||||||
|
base_template_id=data.get("base_template_id"),
|
||||||
|
generated_name=data.get("generated_name"),
|
||||||
|
is_generated=data.get("is_generated", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""String representation of the item."""
|
"""String representation of the item."""
|
||||||
if self.is_weapon():
|
if self.is_weapon():
|
||||||
|
if self.is_elemental_weapon():
|
||||||
|
return (
|
||||||
|
f"Item({self.name}, elemental_weapon, dmg={self.damage}, "
|
||||||
|
f"phys={self.physical_ratio:.0%}/{self.elemental_damage_type.value}={self.elemental_ratio:.0%}, "
|
||||||
|
f"crit={self.crit_chance*100:.0f}%, value={self.value}g)"
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"Item({self.name}, weapon, dmg={self.damage}, "
|
f"Item({self.name}, weapon, dmg={self.damage}, "
|
||||||
f"crit={self.crit_chance*100:.0f}%, value={self.value}g)"
|
f"crit={self.crit_chance*100:.0f}%, value={self.value}g)"
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ class NPC:
|
|||||||
location_id: str
|
location_id: str
|
||||||
personality: NPCPersonality
|
personality: NPCPersonality
|
||||||
appearance: NPCAppearance
|
appearance: NPCAppearance
|
||||||
|
image_url: Optional[str] = None
|
||||||
knowledge: Optional[NPCKnowledge] = None
|
knowledge: Optional[NPCKnowledge] = None
|
||||||
relationships: List[NPCRelationship] = field(default_factory=list)
|
relationships: List[NPCRelationship] = field(default_factory=list)
|
||||||
inventory_for_sale: List[NPCInventoryItem] = field(default_factory=list)
|
inventory_for_sale: List[NPCInventoryItem] = field(default_factory=list)
|
||||||
@@ -316,6 +317,7 @@ class NPC:
|
|||||||
"name": self.name,
|
"name": self.name,
|
||||||
"role": self.role,
|
"role": self.role,
|
||||||
"location_id": self.location_id,
|
"location_id": self.location_id,
|
||||||
|
"image_url": self.image_url,
|
||||||
"personality": self.personality.to_dict(),
|
"personality": self.personality.to_dict(),
|
||||||
"appearance": self.appearance.to_dict(),
|
"appearance": self.appearance.to_dict(),
|
||||||
"knowledge": self.knowledge.to_dict() if self.knowledge else None,
|
"knowledge": self.knowledge.to_dict() if self.knowledge else None,
|
||||||
@@ -400,6 +402,7 @@ class NPC:
|
|||||||
name=data["name"],
|
name=data["name"],
|
||||||
role=data["role"],
|
role=data["role"],
|
||||||
location_id=data["location_id"],
|
location_id=data["location_id"],
|
||||||
|
image_url=data.get("image_url"),
|
||||||
personality=personality,
|
personality=personality,
|
||||||
appearance=appearance,
|
appearance=appearance,
|
||||||
knowledge=knowledge,
|
knowledge=knowledge,
|
||||||
|
|||||||
@@ -167,7 +167,8 @@ class GameSession:
|
|||||||
user_id: Owner of the session
|
user_id: Owner of the session
|
||||||
party_member_ids: Character IDs in this party (multiplayer only)
|
party_member_ids: Character IDs in this party (multiplayer only)
|
||||||
config: Session configuration settings
|
config: Session configuration settings
|
||||||
combat_encounter: Current combat (None if not in combat)
|
combat_encounter: Legacy inline combat data (None if not in combat)
|
||||||
|
active_combat_encounter_id: Reference to combat_encounters table (new system)
|
||||||
conversation_history: Turn-by-turn log of actions and DM responses
|
conversation_history: Turn-by-turn log of actions and DM responses
|
||||||
game_state: Current world/quest state
|
game_state: Current world/quest state
|
||||||
turn_order: Character turn order
|
turn_order: Character turn order
|
||||||
@@ -184,7 +185,8 @@ class GameSession:
|
|||||||
user_id: str = ""
|
user_id: str = ""
|
||||||
party_member_ids: List[str] = field(default_factory=list)
|
party_member_ids: List[str] = field(default_factory=list)
|
||||||
config: SessionConfig = field(default_factory=SessionConfig)
|
config: SessionConfig = field(default_factory=SessionConfig)
|
||||||
combat_encounter: Optional[CombatEncounter] = None
|
combat_encounter: Optional[CombatEncounter] = None # Legacy: inline combat data
|
||||||
|
active_combat_encounter_id: Optional[str] = None # New: reference to combat_encounters table
|
||||||
conversation_history: List[ConversationEntry] = field(default_factory=list)
|
conversation_history: List[ConversationEntry] = field(default_factory=list)
|
||||||
game_state: GameState = field(default_factory=GameState)
|
game_state: GameState = field(default_factory=GameState)
|
||||||
turn_order: List[str] = field(default_factory=list)
|
turn_order: List[str] = field(default_factory=list)
|
||||||
@@ -202,8 +204,13 @@ class GameSession:
|
|||||||
self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
def is_in_combat(self) -> bool:
|
def is_in_combat(self) -> bool:
|
||||||
"""Check if session is currently in combat."""
|
"""
|
||||||
return self.combat_encounter is not None
|
Check if session is currently in combat.
|
||||||
|
|
||||||
|
Checks both the new database reference and legacy inline storage
|
||||||
|
for backward compatibility.
|
||||||
|
"""
|
||||||
|
return self.active_combat_encounter_id is not None or self.combat_encounter is not None
|
||||||
|
|
||||||
def start_combat(self, encounter: CombatEncounter) -> None:
|
def start_combat(self, encounter: CombatEncounter) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -341,6 +348,7 @@ class GameSession:
|
|||||||
"party_member_ids": self.party_member_ids,
|
"party_member_ids": self.party_member_ids,
|
||||||
"config": self.config.to_dict(),
|
"config": self.config.to_dict(),
|
||||||
"combat_encounter": self.combat_encounter.to_dict() if self.combat_encounter else None,
|
"combat_encounter": self.combat_encounter.to_dict() if self.combat_encounter else None,
|
||||||
|
"active_combat_encounter_id": self.active_combat_encounter_id,
|
||||||
"conversation_history": [entry.to_dict() for entry in self.conversation_history],
|
"conversation_history": [entry.to_dict() for entry in self.conversation_history],
|
||||||
"game_state": self.game_state.to_dict(),
|
"game_state": self.game_state.to_dict(),
|
||||||
"turn_order": self.turn_order,
|
"turn_order": self.turn_order,
|
||||||
@@ -382,6 +390,7 @@ class GameSession:
|
|||||||
party_member_ids=data.get("party_member_ids", []),
|
party_member_ids=data.get("party_member_ids", []),
|
||||||
config=config,
|
config=config,
|
||||||
combat_encounter=combat_encounter,
|
combat_encounter=combat_encounter,
|
||||||
|
active_combat_encounter_id=data.get("active_combat_encounter_id"),
|
||||||
conversation_history=conversation_history,
|
conversation_history=conversation_history,
|
||||||
game_state=game_state,
|
game_state=game_state,
|
||||||
turn_order=data.get("turn_order", []),
|
turn_order=data.get("turn_order", []),
|
||||||
|
|||||||
@@ -21,12 +21,19 @@ class Stats:
|
|||||||
intelligence: Magical power, affects spell damage and MP
|
intelligence: Magical power, affects spell damage and MP
|
||||||
wisdom: Perception and insight, affects magical resistance
|
wisdom: Perception and insight, affects magical resistance
|
||||||
charisma: Social influence, affects NPC interactions
|
charisma: Social influence, affects NPC interactions
|
||||||
|
luck: Fortune and fate, affects critical hits, loot, and random outcomes
|
||||||
|
damage_bonus: Flat damage bonus from equipped weapons (default 0)
|
||||||
|
spell_power_bonus: Flat spell power bonus from staves/wands (default 0)
|
||||||
|
defense_bonus: Flat defense bonus from equipped armor (default 0)
|
||||||
|
resistance_bonus: Flat resistance bonus from equipped armor (default 0)
|
||||||
|
|
||||||
Computed Properties:
|
Computed Properties:
|
||||||
hit_points: Maximum HP = 10 + (constitution × 2)
|
hit_points: Maximum HP = 10 + (constitution × 2)
|
||||||
mana_points: Maximum MP = 10 + (intelligence × 2)
|
mana_points: Maximum MP = 10 + (intelligence × 2)
|
||||||
defense: Physical defense = constitution // 2
|
damage: Physical damage = int(strength × 0.75) + damage_bonus
|
||||||
resistance: Magical resistance = wisdom // 2
|
spell_power: Spell power = int(intelligence × 0.75) + spell_power_bonus
|
||||||
|
defense: Physical defense = (constitution // 2) + defense_bonus
|
||||||
|
resistance: Magical resistance = (wisdom // 2) + resistance_bonus
|
||||||
"""
|
"""
|
||||||
|
|
||||||
strength: int = 10
|
strength: int = 10
|
||||||
@@ -35,6 +42,13 @@ class Stats:
|
|||||||
intelligence: int = 10
|
intelligence: int = 10
|
||||||
wisdom: int = 10
|
wisdom: int = 10
|
||||||
charisma: int = 10
|
charisma: int = 10
|
||||||
|
luck: int = 8
|
||||||
|
|
||||||
|
# Equipment bonus fields (populated by get_effective_stats())
|
||||||
|
damage_bonus: int = 0 # From weapons (physical damage)
|
||||||
|
spell_power_bonus: int = 0 # From staves/wands (magical damage)
|
||||||
|
defense_bonus: int = 0 # From armor
|
||||||
|
resistance_bonus: int = 0 # From armor
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hit_points(self) -> int:
|
def hit_points(self) -> int:
|
||||||
@@ -60,29 +74,122 @@ class Stats:
|
|||||||
"""
|
"""
|
||||||
return 10 + (self.intelligence * 2)
|
return 10 + (self.intelligence * 2)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def damage(self) -> int:
|
||||||
|
"""
|
||||||
|
Calculate total physical damage from strength and equipment.
|
||||||
|
|
||||||
|
Formula: int(strength * 0.75) + damage_bonus
|
||||||
|
|
||||||
|
The damage_bonus comes from equipped weapons and is populated
|
||||||
|
by Character.get_effective_stats().
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total physical damage value
|
||||||
|
"""
|
||||||
|
return int(self.strength * 0.75) + self.damage_bonus
|
||||||
|
|
||||||
|
@property
|
||||||
|
def spell_power(self) -> int:
|
||||||
|
"""
|
||||||
|
Calculate spell power from intelligence and equipment.
|
||||||
|
|
||||||
|
Formula: int(intelligence * 0.75) + spell_power_bonus
|
||||||
|
|
||||||
|
The spell_power_bonus comes from equipped staves/wands and is
|
||||||
|
populated by Character.get_effective_stats().
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total spell power value
|
||||||
|
"""
|
||||||
|
return int(self.intelligence * 0.75) + self.spell_power_bonus
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def defense(self) -> int:
|
def defense(self) -> int:
|
||||||
"""
|
"""
|
||||||
Calculate physical defense from constitution.
|
Calculate physical defense from constitution and equipment.
|
||||||
|
|
||||||
Formula: constitution // 2
|
Formula: (constitution // 2) + defense_bonus
|
||||||
|
|
||||||
|
The defense_bonus comes from equipped armor and is populated
|
||||||
|
by Character.get_effective_stats().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Physical defense value (damage reduction)
|
Physical defense value (damage reduction)
|
||||||
"""
|
"""
|
||||||
return self.constitution // 2
|
return (self.constitution // 2) + self.defense_bonus
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def resistance(self) -> int:
|
def resistance(self) -> int:
|
||||||
"""
|
"""
|
||||||
Calculate magical resistance from wisdom.
|
Calculate magical resistance from wisdom and equipment.
|
||||||
|
|
||||||
Formula: wisdom // 2
|
Formula: (wisdom // 2) + resistance_bonus
|
||||||
|
|
||||||
|
The resistance_bonus comes from equipped armor and is populated
|
||||||
|
by Character.get_effective_stats().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Magical resistance value (spell damage reduction)
|
Magical resistance value (spell damage reduction)
|
||||||
"""
|
"""
|
||||||
return self.wisdom // 2
|
return (self.wisdom // 2) + self.resistance_bonus
|
||||||
|
|
||||||
|
@property
|
||||||
|
def crit_bonus(self) -> float:
|
||||||
|
"""
|
||||||
|
Calculate critical hit chance bonus from luck.
|
||||||
|
|
||||||
|
Formula: luck * 0.5% (0.005)
|
||||||
|
|
||||||
|
This bonus is added to the weapon's base crit chance.
|
||||||
|
The total crit chance is capped at 25% in the DamageCalculator.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Crit chance bonus as a decimal (e.g., 0.04 for LUK 8)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
LUK 8: 0.04 (4% bonus)
|
||||||
|
LUK 12: 0.06 (6% bonus)
|
||||||
|
"""
|
||||||
|
return self.luck * 0.005
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hit_bonus(self) -> float:
|
||||||
|
"""
|
||||||
|
Calculate hit chance bonus (miss reduction) from luck.
|
||||||
|
|
||||||
|
Formula: luck * 0.5% (0.005)
|
||||||
|
|
||||||
|
This reduces the base 10% miss chance. The minimum miss
|
||||||
|
chance is hard capped at 5% to prevent frustration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Miss reduction as a decimal (e.g., 0.04 for LUK 8)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
LUK 8: 0.04 (reduces miss from 10% to 6%)
|
||||||
|
LUK 12: 0.06 (reduces miss from 10% to 4%, capped at 5%)
|
||||||
|
"""
|
||||||
|
return self.luck * 0.005
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lucky_roll_chance(self) -> float:
|
||||||
|
"""
|
||||||
|
Calculate chance for a "lucky" high damage variance roll.
|
||||||
|
|
||||||
|
Formula: 5% + (luck * 0.25%)
|
||||||
|
|
||||||
|
When triggered, damage variance uses 100%-110% instead of 95%-105%.
|
||||||
|
This gives LUK characters more frequent high damage rolls.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lucky roll chance as a decimal
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
LUK 8: 0.07 (7% chance for lucky roll)
|
||||||
|
LUK 12: 0.08 (8% chance for lucky roll)
|
||||||
|
"""
|
||||||
|
return 0.05 + (self.luck * 0.0025)
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -111,6 +218,11 @@ class Stats:
|
|||||||
intelligence=data.get("intelligence", 10),
|
intelligence=data.get("intelligence", 10),
|
||||||
wisdom=data.get("wisdom", 10),
|
wisdom=data.get("wisdom", 10),
|
||||||
charisma=data.get("charisma", 10),
|
charisma=data.get("charisma", 10),
|
||||||
|
luck=data.get("luck", 8),
|
||||||
|
damage_bonus=data.get("damage_bonus", 0),
|
||||||
|
spell_power_bonus=data.get("spell_power_bonus", 0),
|
||||||
|
defense_bonus=data.get("defense_bonus", 0),
|
||||||
|
resistance_bonus=data.get("resistance_bonus", 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
def copy(self) -> 'Stats':
|
def copy(self) -> 'Stats':
|
||||||
@@ -127,6 +239,11 @@ class Stats:
|
|||||||
intelligence=self.intelligence,
|
intelligence=self.intelligence,
|
||||||
wisdom=self.wisdom,
|
wisdom=self.wisdom,
|
||||||
charisma=self.charisma,
|
charisma=self.charisma,
|
||||||
|
luck=self.luck,
|
||||||
|
damage_bonus=self.damage_bonus,
|
||||||
|
spell_power_bonus=self.spell_power_bonus,
|
||||||
|
defense_bonus=self.defense_bonus,
|
||||||
|
resistance_bonus=self.resistance_bonus,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
@@ -134,7 +251,9 @@ class Stats:
|
|||||||
return (
|
return (
|
||||||
f"Stats(STR={self.strength}, DEX={self.dexterity}, "
|
f"Stats(STR={self.strength}, DEX={self.dexterity}, "
|
||||||
f"CON={self.constitution}, INT={self.intelligence}, "
|
f"CON={self.constitution}, INT={self.intelligence}, "
|
||||||
f"WIS={self.wisdom}, CHA={self.charisma}, "
|
f"WIS={self.wisdom}, CHA={self.charisma}, LUK={self.luck}, "
|
||||||
f"HP={self.hit_points}, MP={self.mana_points}, "
|
f"HP={self.hit_points}, MP={self.mana_points}, "
|
||||||
f"DEF={self.defense}, RES={self.resistance})"
|
f"DMG={self.damage}, SP={self.spell_power}, "
|
||||||
|
f"DEF={self.defense}, RES={self.resistance}, "
|
||||||
|
f"CRIT_BONUS={self.crit_bonus:.1%}, HIT_BONUS={self.hit_bonus:.1%})"
|
||||||
)
|
)
|
||||||
|
|||||||
315
api/app/services/affix_loader.py
Normal file
315
api/app/services/affix_loader.py
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
"""
|
||||||
|
Affix Loader Service - YAML-based affix pool loading.
|
||||||
|
|
||||||
|
This service loads prefix and suffix affix definitions from YAML files,
|
||||||
|
providing a data-driven approach to item generation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
import random
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.models.affixes import Affix
|
||||||
|
from app.models.enums import AffixType, AffixTier
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
class AffixLoader:
|
||||||
|
"""
|
||||||
|
Loads and manages item affixes from YAML configuration files.
|
||||||
|
|
||||||
|
This allows game designers to define affixes without touching code.
|
||||||
|
Affixes are organized into prefixes.yaml and suffixes.yaml files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, data_dir: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Initialize the affix loader.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_dir: Path to directory containing affix YAML files
|
||||||
|
Defaults to /app/data/affixes/
|
||||||
|
"""
|
||||||
|
if data_dir is None:
|
||||||
|
# Default to app/data/affixes relative to this file
|
||||||
|
current_file = Path(__file__)
|
||||||
|
app_dir = current_file.parent.parent # Go up to /app
|
||||||
|
data_dir = str(app_dir / "data" / "affixes")
|
||||||
|
|
||||||
|
self.data_dir = Path(data_dir)
|
||||||
|
self._prefix_cache: Dict[str, Affix] = {}
|
||||||
|
self._suffix_cache: Dict[str, Affix] = {}
|
||||||
|
self._loaded = False
|
||||||
|
|
||||||
|
logger.info("AffixLoader initialized", data_dir=str(self.data_dir))
|
||||||
|
|
||||||
|
def _ensure_loaded(self) -> None:
|
||||||
|
"""Ensure affixes are loaded before any operation."""
|
||||||
|
if not self._loaded:
|
||||||
|
self.load_all()
|
||||||
|
|
||||||
|
def load_all(self) -> None:
|
||||||
|
"""Load all affixes from YAML files."""
|
||||||
|
if not self.data_dir.exists():
|
||||||
|
logger.warning("Affix data directory not found", path=str(self.data_dir))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load prefixes
|
||||||
|
prefixes_file = self.data_dir / "prefixes.yaml"
|
||||||
|
if prefixes_file.exists():
|
||||||
|
self._load_affixes_from_file(prefixes_file, self._prefix_cache)
|
||||||
|
|
||||||
|
# Load suffixes
|
||||||
|
suffixes_file = self.data_dir / "suffixes.yaml"
|
||||||
|
if suffixes_file.exists():
|
||||||
|
self._load_affixes_from_file(suffixes_file, self._suffix_cache)
|
||||||
|
|
||||||
|
self._loaded = True
|
||||||
|
logger.info(
|
||||||
|
"Affixes loaded",
|
||||||
|
prefix_count=len(self._prefix_cache),
|
||||||
|
suffix_count=len(self._suffix_cache)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_affixes_from_file(
|
||||||
|
self,
|
||||||
|
yaml_file: Path,
|
||||||
|
cache: Dict[str, Affix]
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Load affixes from a YAML file into the cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
yaml_file: Path to the YAML file
|
||||||
|
cache: Cache dictionary to populate
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(yaml_file, 'r') as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
# Get the top-level key (prefixes or suffixes)
|
||||||
|
affix_key = "prefixes" if "prefixes" in data else "suffixes"
|
||||||
|
affixes_data = data.get(affix_key, {})
|
||||||
|
|
||||||
|
for affix_id, affix_data in affixes_data.items():
|
||||||
|
# Ensure affix_id is set
|
||||||
|
affix_data["affix_id"] = affix_id
|
||||||
|
|
||||||
|
# Set defaults for missing optional fields
|
||||||
|
affix_data.setdefault("stat_bonuses", {})
|
||||||
|
affix_data.setdefault("defense_bonus", 0)
|
||||||
|
affix_data.setdefault("resistance_bonus", 0)
|
||||||
|
affix_data.setdefault("damage_bonus", 0)
|
||||||
|
affix_data.setdefault("elemental_ratio", 0.0)
|
||||||
|
affix_data.setdefault("crit_chance_bonus", 0.0)
|
||||||
|
affix_data.setdefault("crit_multiplier_bonus", 0.0)
|
||||||
|
affix_data.setdefault("allowed_item_types", [])
|
||||||
|
affix_data.setdefault("required_rarity", None)
|
||||||
|
|
||||||
|
affix = Affix.from_dict(affix_data)
|
||||||
|
cache[affix.affix_id] = affix
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Affixes loaded from file",
|
||||||
|
file=str(yaml_file),
|
||||||
|
count=len(affixes_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to load affix file",
|
||||||
|
file=str(yaml_file),
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_affix(self, affix_id: str) -> Optional[Affix]:
|
||||||
|
"""
|
||||||
|
Get a specific affix by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
affix_id: Unique affix identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Affix instance or None if not found
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
|
||||||
|
if affix_id in self._prefix_cache:
|
||||||
|
return self._prefix_cache[affix_id]
|
||||||
|
if affix_id in self._suffix_cache:
|
||||||
|
return self._suffix_cache[affix_id]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_eligible_prefixes(
|
||||||
|
self,
|
||||||
|
item_type: str,
|
||||||
|
rarity: str,
|
||||||
|
tier: Optional[AffixTier] = None
|
||||||
|
) -> List[Affix]:
|
||||||
|
"""
|
||||||
|
Get all prefixes eligible for an item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_type: Type of item ("weapon", "armor")
|
||||||
|
rarity: Item rarity ("rare", "epic", "legendary")
|
||||||
|
tier: Optional tier filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of eligible Affix instances
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
|
||||||
|
eligible = []
|
||||||
|
for affix in self._prefix_cache.values():
|
||||||
|
# Check if affix can apply to this item
|
||||||
|
if not affix.can_apply_to(item_type, rarity):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Apply tier filter if specified
|
||||||
|
if tier and affix.tier != tier:
|
||||||
|
continue
|
||||||
|
|
||||||
|
eligible.append(affix)
|
||||||
|
|
||||||
|
return eligible
|
||||||
|
|
||||||
|
def get_eligible_suffixes(
|
||||||
|
self,
|
||||||
|
item_type: str,
|
||||||
|
rarity: str,
|
||||||
|
tier: Optional[AffixTier] = None
|
||||||
|
) -> List[Affix]:
|
||||||
|
"""
|
||||||
|
Get all suffixes eligible for an item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_type: Type of item ("weapon", "armor")
|
||||||
|
rarity: Item rarity ("rare", "epic", "legendary")
|
||||||
|
tier: Optional tier filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of eligible Affix instances
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
|
||||||
|
eligible = []
|
||||||
|
for affix in self._suffix_cache.values():
|
||||||
|
# Check if affix can apply to this item
|
||||||
|
if not affix.can_apply_to(item_type, rarity):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Apply tier filter if specified
|
||||||
|
if tier and affix.tier != tier:
|
||||||
|
continue
|
||||||
|
|
||||||
|
eligible.append(affix)
|
||||||
|
|
||||||
|
return eligible
|
||||||
|
|
||||||
|
def get_random_prefix(
|
||||||
|
self,
|
||||||
|
item_type: str,
|
||||||
|
rarity: str,
|
||||||
|
tier: Optional[AffixTier] = None,
|
||||||
|
exclude_ids: Optional[List[str]] = None
|
||||||
|
) -> Optional[Affix]:
|
||||||
|
"""
|
||||||
|
Get a random eligible prefix.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_type: Type of item ("weapon", "armor")
|
||||||
|
rarity: Item rarity
|
||||||
|
tier: Optional tier filter
|
||||||
|
exclude_ids: Affix IDs to exclude (for avoiding duplicates)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Random eligible Affix or None if none available
|
||||||
|
"""
|
||||||
|
eligible = self.get_eligible_prefixes(item_type, rarity, tier)
|
||||||
|
|
||||||
|
# Filter out excluded IDs
|
||||||
|
if exclude_ids:
|
||||||
|
eligible = [a for a in eligible if a.affix_id not in exclude_ids]
|
||||||
|
|
||||||
|
if not eligible:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return random.choice(eligible)
|
||||||
|
|
||||||
|
def get_random_suffix(
|
||||||
|
self,
|
||||||
|
item_type: str,
|
||||||
|
rarity: str,
|
||||||
|
tier: Optional[AffixTier] = None,
|
||||||
|
exclude_ids: Optional[List[str]] = None
|
||||||
|
) -> Optional[Affix]:
|
||||||
|
"""
|
||||||
|
Get a random eligible suffix.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_type: Type of item ("weapon", "armor")
|
||||||
|
rarity: Item rarity
|
||||||
|
tier: Optional tier filter
|
||||||
|
exclude_ids: Affix IDs to exclude (for avoiding duplicates)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Random eligible Affix or None if none available
|
||||||
|
"""
|
||||||
|
eligible = self.get_eligible_suffixes(item_type, rarity, tier)
|
||||||
|
|
||||||
|
# Filter out excluded IDs
|
||||||
|
if exclude_ids:
|
||||||
|
eligible = [a for a in eligible if a.affix_id not in exclude_ids]
|
||||||
|
|
||||||
|
if not eligible:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return random.choice(eligible)
|
||||||
|
|
||||||
|
def get_all_prefixes(self) -> Dict[str, Affix]:
|
||||||
|
"""
|
||||||
|
Get all cached prefixes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of prefix affixes
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
return self._prefix_cache.copy()
|
||||||
|
|
||||||
|
def get_all_suffixes(self) -> Dict[str, Affix]:
|
||||||
|
"""
|
||||||
|
Get all cached suffixes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of suffix affixes
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
return self._suffix_cache.copy()
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Clear the affix cache, forcing reload on next access."""
|
||||||
|
self._prefix_cache.clear()
|
||||||
|
self._suffix_cache.clear()
|
||||||
|
self._loaded = False
|
||||||
|
logger.debug("Affix cache cleared")
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance for convenience
|
||||||
|
_loader_instance: Optional[AffixLoader] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_affix_loader() -> AffixLoader:
|
||||||
|
"""
|
||||||
|
Get the global AffixLoader instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Singleton AffixLoader instance
|
||||||
|
"""
|
||||||
|
global _loader_instance
|
||||||
|
if _loader_instance is None:
|
||||||
|
_loader_instance = AffixLoader()
|
||||||
|
return _loader_instance
|
||||||
274
api/app/services/base_item_loader.py
Normal file
274
api/app/services/base_item_loader.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"""
|
||||||
|
Base Item Loader Service - YAML-based base item template loading.
|
||||||
|
|
||||||
|
This service loads base item templates (weapons, armor) from YAML files,
|
||||||
|
providing the foundation for procedural item generation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
import random
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.models.affixes import BaseItemTemplate
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
# Rarity order for comparison
|
||||||
|
RARITY_ORDER = {
|
||||||
|
"common": 0,
|
||||||
|
"uncommon": 1,
|
||||||
|
"rare": 2,
|
||||||
|
"epic": 3,
|
||||||
|
"legendary": 4
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BaseItemLoader:
|
||||||
|
"""
|
||||||
|
Loads and manages base item templates from YAML configuration files.
|
||||||
|
|
||||||
|
This allows game designers to define base items without touching code.
|
||||||
|
Templates are organized into weapons.yaml and armor.yaml files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, data_dir: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Initialize the base item loader.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_dir: Path to directory containing base item YAML files
|
||||||
|
Defaults to /app/data/base_items/
|
||||||
|
"""
|
||||||
|
if data_dir is None:
|
||||||
|
# Default to app/data/base_items relative to this file
|
||||||
|
current_file = Path(__file__)
|
||||||
|
app_dir = current_file.parent.parent # Go up to /app
|
||||||
|
data_dir = str(app_dir / "data" / "base_items")
|
||||||
|
|
||||||
|
self.data_dir = Path(data_dir)
|
||||||
|
self._weapon_cache: Dict[str, BaseItemTemplate] = {}
|
||||||
|
self._armor_cache: Dict[str, BaseItemTemplate] = {}
|
||||||
|
self._loaded = False
|
||||||
|
|
||||||
|
logger.info("BaseItemLoader initialized", data_dir=str(self.data_dir))
|
||||||
|
|
||||||
|
def _ensure_loaded(self) -> None:
|
||||||
|
"""Ensure templates are loaded before any operation."""
|
||||||
|
if not self._loaded:
|
||||||
|
self.load_all()
|
||||||
|
|
||||||
|
def load_all(self) -> None:
|
||||||
|
"""Load all base item templates from YAML files."""
|
||||||
|
if not self.data_dir.exists():
|
||||||
|
logger.warning("Base item data directory not found", path=str(self.data_dir))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load weapons
|
||||||
|
weapons_file = self.data_dir / "weapons.yaml"
|
||||||
|
if weapons_file.exists():
|
||||||
|
self._load_templates_from_file(weapons_file, "weapons", self._weapon_cache)
|
||||||
|
|
||||||
|
# Load armor
|
||||||
|
armor_file = self.data_dir / "armor.yaml"
|
||||||
|
if armor_file.exists():
|
||||||
|
self._load_templates_from_file(armor_file, "armor", self._armor_cache)
|
||||||
|
|
||||||
|
self._loaded = True
|
||||||
|
logger.info(
|
||||||
|
"Base item templates loaded",
|
||||||
|
weapon_count=len(self._weapon_cache),
|
||||||
|
armor_count=len(self._armor_cache)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_templates_from_file(
|
||||||
|
self,
|
||||||
|
yaml_file: Path,
|
||||||
|
key: str,
|
||||||
|
cache: Dict[str, BaseItemTemplate]
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Load templates from a YAML file into the cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
yaml_file: Path to the YAML file
|
||||||
|
key: Top-level key in YAML (e.g., "weapons", "armor")
|
||||||
|
cache: Cache dictionary to populate
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(yaml_file, 'r') as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
templates_data = data.get(key, {})
|
||||||
|
|
||||||
|
for template_id, template_data in templates_data.items():
|
||||||
|
# Ensure template_id is set
|
||||||
|
template_data["template_id"] = template_id
|
||||||
|
|
||||||
|
# Set defaults for missing optional fields
|
||||||
|
template_data.setdefault("description", "")
|
||||||
|
template_data.setdefault("base_damage", 0)
|
||||||
|
template_data.setdefault("base_spell_power", 0)
|
||||||
|
template_data.setdefault("base_defense", 0)
|
||||||
|
template_data.setdefault("base_resistance", 0)
|
||||||
|
template_data.setdefault("base_value", 10)
|
||||||
|
template_data.setdefault("damage_type", "physical")
|
||||||
|
template_data.setdefault("crit_chance", 0.05)
|
||||||
|
template_data.setdefault("crit_multiplier", 2.0)
|
||||||
|
template_data.setdefault("required_level", 1)
|
||||||
|
template_data.setdefault("drop_weight", 1.0)
|
||||||
|
template_data.setdefault("min_rarity", "common")
|
||||||
|
|
||||||
|
template = BaseItemTemplate.from_dict(template_data)
|
||||||
|
cache[template.template_id] = template
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Templates loaded from file",
|
||||||
|
file=str(yaml_file),
|
||||||
|
count=len(templates_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to load base item file",
|
||||||
|
file=str(yaml_file),
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_template(self, template_id: str) -> Optional[BaseItemTemplate]:
|
||||||
|
"""
|
||||||
|
Get a specific template by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_id: Unique template identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BaseItemTemplate instance or None if not found
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
|
||||||
|
if template_id in self._weapon_cache:
|
||||||
|
return self._weapon_cache[template_id]
|
||||||
|
if template_id in self._armor_cache:
|
||||||
|
return self._armor_cache[template_id]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_eligible_templates(
|
||||||
|
self,
|
||||||
|
item_type: str,
|
||||||
|
rarity: str,
|
||||||
|
character_level: int = 1
|
||||||
|
) -> List[BaseItemTemplate]:
|
||||||
|
"""
|
||||||
|
Get all templates eligible for generation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_type: Type of item ("weapon", "armor")
|
||||||
|
rarity: Target rarity
|
||||||
|
character_level: Player level for eligibility
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of eligible BaseItemTemplate instances
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
|
||||||
|
# Select the appropriate cache
|
||||||
|
if item_type == "weapon":
|
||||||
|
cache = self._weapon_cache
|
||||||
|
elif item_type == "armor":
|
||||||
|
cache = self._armor_cache
|
||||||
|
else:
|
||||||
|
logger.warning("Unknown item type", item_type=item_type)
|
||||||
|
return []
|
||||||
|
|
||||||
|
eligible = []
|
||||||
|
for template in cache.values():
|
||||||
|
# Check level requirement
|
||||||
|
if not template.can_drop_for_level(character_level):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check rarity requirement
|
||||||
|
if not template.can_generate_at_rarity(rarity):
|
||||||
|
continue
|
||||||
|
|
||||||
|
eligible.append(template)
|
||||||
|
|
||||||
|
return eligible
|
||||||
|
|
||||||
|
def get_random_template(
|
||||||
|
self,
|
||||||
|
item_type: str,
|
||||||
|
rarity: str,
|
||||||
|
character_level: int = 1
|
||||||
|
) -> Optional[BaseItemTemplate]:
|
||||||
|
"""
|
||||||
|
Get a random eligible template, weighted by drop_weight.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_type: Type of item ("weapon", "armor")
|
||||||
|
rarity: Target rarity
|
||||||
|
character_level: Player level for eligibility
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Random eligible BaseItemTemplate or None if none available
|
||||||
|
"""
|
||||||
|
eligible = self.get_eligible_templates(item_type, rarity, character_level)
|
||||||
|
|
||||||
|
if not eligible:
|
||||||
|
logger.warning(
|
||||||
|
"No templates match criteria",
|
||||||
|
item_type=item_type,
|
||||||
|
rarity=rarity,
|
||||||
|
level=character_level
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Weighted random selection based on drop_weight
|
||||||
|
weights = [t.drop_weight for t in eligible]
|
||||||
|
return random.choices(eligible, weights=weights, k=1)[0]
|
||||||
|
|
||||||
|
def get_all_weapons(self) -> Dict[str, BaseItemTemplate]:
|
||||||
|
"""
|
||||||
|
Get all cached weapon templates.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of weapon templates
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
return self._weapon_cache.copy()
|
||||||
|
|
||||||
|
def get_all_armor(self) -> Dict[str, BaseItemTemplate]:
|
||||||
|
"""
|
||||||
|
Get all cached armor templates.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of armor templates
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
return self._armor_cache.copy()
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Clear the template cache, forcing reload on next access."""
|
||||||
|
self._weapon_cache.clear()
|
||||||
|
self._armor_cache.clear()
|
||||||
|
self._loaded = False
|
||||||
|
logger.debug("Base item template cache cleared")
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance for convenience
|
||||||
|
_loader_instance: Optional[BaseItemLoader] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_base_item_loader() -> BaseItemLoader:
|
||||||
|
"""
|
||||||
|
Get the global BaseItemLoader instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Singleton BaseItemLoader instance
|
||||||
|
"""
|
||||||
|
global _loader_instance
|
||||||
|
if _loader_instance is None:
|
||||||
|
_loader_instance = BaseItemLoader()
|
||||||
|
return _loader_instance
|
||||||
@@ -21,6 +21,7 @@ from app.services.database_service import get_database_service
|
|||||||
from app.services.appwrite_service import AppwriteService
|
from app.services.appwrite_service import AppwriteService
|
||||||
from app.services.class_loader import get_class_loader
|
from app.services.class_loader import get_class_loader
|
||||||
from app.services.origin_service import get_origin_service
|
from app.services.origin_service import get_origin_service
|
||||||
|
from app.services.static_item_loader import get_static_item_loader
|
||||||
from app.utils.logging import get_logger
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
@@ -173,6 +174,23 @@ class CharacterService:
|
|||||||
current_location=starting_location_id # Set starting location
|
current_location=starting_location_id # Set starting location
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add starting equipment to inventory
|
||||||
|
if player_class.starting_equipment:
|
||||||
|
item_loader = get_static_item_loader()
|
||||||
|
for item_id in player_class.starting_equipment:
|
||||||
|
item = item_loader.get_item(item_id)
|
||||||
|
if item:
|
||||||
|
character.add_item(item)
|
||||||
|
logger.debug("Added starting equipment",
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id,
|
||||||
|
item_name=item.name)
|
||||||
|
else:
|
||||||
|
logger.warning("Starting equipment item not found",
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id,
|
||||||
|
class_id=class_id)
|
||||||
|
|
||||||
# Serialize character to JSON
|
# Serialize character to JSON
|
||||||
character_dict = character.to_dict()
|
character_dict = character.to_dict()
|
||||||
character_json = json.dumps(character_dict)
|
character_json = json.dumps(character_dict)
|
||||||
@@ -334,7 +352,10 @@ class CharacterService:
|
|||||||
|
|
||||||
def delete_character(self, character_id: str, user_id: str) -> bool:
|
def delete_character(self, character_id: str, user_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Delete a character (soft delete by marking inactive).
|
Permanently delete a character from the database.
|
||||||
|
|
||||||
|
Also cleans up any game sessions associated with the character
|
||||||
|
to prevent orphaned sessions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
character_id: Character ID
|
character_id: Character ID
|
||||||
@@ -354,11 +375,20 @@ class CharacterService:
|
|||||||
if not character:
|
if not character:
|
||||||
raise CharacterNotFound(f"Character not found: {character_id}")
|
raise CharacterNotFound(f"Character not found: {character_id}")
|
||||||
|
|
||||||
# Soft delete by marking inactive
|
# Clean up associated sessions before deleting the character
|
||||||
self.db.update_document(
|
# Local import to avoid circular dependency (session_service imports character_service)
|
||||||
|
from app.services.session_service import get_session_service
|
||||||
|
session_service = get_session_service()
|
||||||
|
deleted_sessions = session_service.delete_sessions_by_character(character_id)
|
||||||
|
if deleted_sessions > 0:
|
||||||
|
logger.info("Cleaned up sessions for deleted character",
|
||||||
|
character_id=character_id,
|
||||||
|
sessions_deleted=deleted_sessions)
|
||||||
|
|
||||||
|
# Hard delete - permanently remove from database
|
||||||
|
self.db.delete_document(
|
||||||
collection_id=self.collection_id,
|
collection_id=self.collection_id,
|
||||||
document_id=character_id,
|
document_id=character_id
|
||||||
data={'is_active': False}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Character deleted successfully", character_id=character_id)
|
logger.info("Character deleted successfully", character_id=character_id)
|
||||||
@@ -982,7 +1012,14 @@ class CharacterService:
|
|||||||
limit: int = 5
|
limit: int = 5
|
||||||
) -> List[Dict[str, str]]:
|
) -> List[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Get recent dialogue history with an NPC.
|
Get recent dialogue history with an NPC from recent_messages cache.
|
||||||
|
|
||||||
|
This method reads from character.npc_interactions[npc_id].recent_messages
|
||||||
|
which contains the last 3 messages for quick AI context. For full conversation
|
||||||
|
history, use ChatMessageService.get_conversation_history().
|
||||||
|
|
||||||
|
Backward Compatibility: Falls back to dialogue_history if recent_messages
|
||||||
|
doesn't exist (for characters created before chat_messages system).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
character_id: Character ID
|
character_id: Character ID
|
||||||
@@ -991,16 +1028,46 @@ class CharacterService:
|
|||||||
limit: Maximum number of recent exchanges to return (default 5)
|
limit: Maximum number of recent exchanges to return (default 5)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of dialogue exchanges [{player_line: str, npc_response: str}, ...]
|
List of dialogue exchanges [{player_message: str, npc_response: str, timestamp: str}, ...]
|
||||||
|
OR legacy format [{player_line: str, npc_response: str}, ...]
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
character = self.get_character(character_id, user_id)
|
character = self.get_character(character_id, user_id)
|
||||||
|
|
||||||
interaction = character.npc_interactions.get(npc_id, {})
|
interaction = character.npc_interactions.get(npc_id, {})
|
||||||
dialogue_history = interaction.get("dialogue_history", [])
|
|
||||||
|
|
||||||
# Return most recent exchanges (up to limit)
|
# NEW: Try recent_messages first (last 3 messages cache)
|
||||||
return dialogue_history[-limit:] if dialogue_history else []
|
recent_messages = interaction.get("recent_messages")
|
||||||
|
if recent_messages is not None:
|
||||||
|
# Return most recent exchanges (up to limit, but recent_messages is already capped at 3)
|
||||||
|
return recent_messages[-limit:] if recent_messages else []
|
||||||
|
|
||||||
|
# DEPRECATED: Fall back to dialogue_history for backward compatibility
|
||||||
|
# This field will be removed after full migration to chat_messages system
|
||||||
|
dialogue_history = interaction.get("dialogue_history", [])
|
||||||
|
if dialogue_history:
|
||||||
|
logger.debug("Using deprecated dialogue_history field",
|
||||||
|
character_id=character_id,
|
||||||
|
npc_id=npc_id)
|
||||||
|
# Convert old format to new format if needed
|
||||||
|
# Old format: {player_line, npc_response}
|
||||||
|
# New format: {player_message, npc_response, timestamp}
|
||||||
|
converted = []
|
||||||
|
for entry in dialogue_history[-limit:]:
|
||||||
|
if "player_message" in entry:
|
||||||
|
# Already new format
|
||||||
|
converted.append(entry)
|
||||||
|
else:
|
||||||
|
# Old format, convert
|
||||||
|
converted.append({
|
||||||
|
"player_message": entry.get("player_line", ""),
|
||||||
|
"npc_response": entry.get("npc_response", ""),
|
||||||
|
"timestamp": "" # No timestamp available in old format
|
||||||
|
})
|
||||||
|
return converted
|
||||||
|
|
||||||
|
# No dialogue history at all
|
||||||
|
return []
|
||||||
|
|
||||||
except CharacterNotFound:
|
except CharacterNotFound:
|
||||||
raise
|
raise
|
||||||
@@ -1025,9 +1092,9 @@ class CharacterService:
|
|||||||
character_json = json.dumps(character_dict)
|
character_json = json.dumps(character_dict)
|
||||||
|
|
||||||
# Update in database
|
# Update in database
|
||||||
self.db.update_document(
|
self.db.update_row(
|
||||||
collection_id=self.collection_id,
|
table_id=self.collection_id,
|
||||||
document_id=character.character_id,
|
row_id=character.character_id,
|
||||||
data={'characterData': character_json}
|
data={'characterData': character_json}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
617
api/app/services/chat_message_service.py
Normal file
617
api/app/services/chat_message_service.py
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
"""
|
||||||
|
Chat Message Service.
|
||||||
|
|
||||||
|
This service handles all business logic for player-NPC conversation history,
|
||||||
|
including saving messages, retrieving conversations, searching, and managing
|
||||||
|
the recent_messages cache in character documents.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from appwrite.query import Query
|
||||||
|
from appwrite.exception import AppwriteException
|
||||||
|
from appwrite.id import ID
|
||||||
|
|
||||||
|
from app.models.chat_message import ChatMessage, MessageContext, ConversationSummary
|
||||||
|
from app.services.database_service import get_database_service
|
||||||
|
from app.services.character_service import CharacterService
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
# Custom Exceptions
|
||||||
|
class ChatMessageNotFound(Exception):
|
||||||
|
"""Raised when a chat message is not found."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessagePermissionDenied(Exception):
|
||||||
|
"""Raised when user doesn't have permission to access/modify a chat message."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageService:
|
||||||
|
"""
|
||||||
|
Service for managing player-NPC chat messages.
|
||||||
|
|
||||||
|
This service provides:
|
||||||
|
- Saving dialogue exchanges to chat_messages collection
|
||||||
|
- Updating recent_messages cache in character documents
|
||||||
|
- Retrieving conversation history with pagination
|
||||||
|
- Searching messages by text, NPC, context, date range
|
||||||
|
- Getting conversation summaries for UI
|
||||||
|
- Soft deleting messages for privacy/moderation
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the chat message service with database and character service."""
|
||||||
|
self.db = get_database_service()
|
||||||
|
self.character_service = CharacterService()
|
||||||
|
logger.info("ChatMessageService initialized")
|
||||||
|
|
||||||
|
def save_dialogue_exchange(
|
||||||
|
self,
|
||||||
|
character_id: str,
|
||||||
|
user_id: str,
|
||||||
|
npc_id: str,
|
||||||
|
player_message: str,
|
||||||
|
npc_response: str,
|
||||||
|
context: MessageContext = MessageContext.DIALOGUE,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
location_id: Optional[str] = None
|
||||||
|
) -> ChatMessage:
|
||||||
|
"""
|
||||||
|
Save a dialogue exchange to chat_messages collection and update character's recent_messages cache.
|
||||||
|
|
||||||
|
This method:
|
||||||
|
1. Creates a ChatMessage document in the chat_messages collection
|
||||||
|
2. Updates character.npc_interactions[npc_id].recent_messages with the last 3 messages
|
||||||
|
3. Updates character.npc_interactions[npc_id].total_messages counter
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_id: Player's character ID
|
||||||
|
user_id: User ID (for ownership validation)
|
||||||
|
npc_id: NPC identifier
|
||||||
|
player_message: What the player said
|
||||||
|
npc_response: NPC's reply
|
||||||
|
context: Type of interaction (default: DIALOGUE)
|
||||||
|
metadata: Optional extensible metadata (quest_id, faction_id, etc.)
|
||||||
|
session_id: Optional game session reference
|
||||||
|
location_id: Optional location where conversation happened
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Saved ChatMessage instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ChatMessagePermissionDenied: If user doesn't own the character
|
||||||
|
AppwriteException: If database operation fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate ownership
|
||||||
|
self._validate_ownership(character_id, user_id)
|
||||||
|
|
||||||
|
# Create chat message
|
||||||
|
chat_message = ChatMessage.create(
|
||||||
|
character_id=character_id,
|
||||||
|
npc_id=npc_id,
|
||||||
|
player_message=player_message,
|
||||||
|
npc_response=npc_response,
|
||||||
|
context=context,
|
||||||
|
session_id=session_id,
|
||||||
|
location_id=location_id,
|
||||||
|
metadata=metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
message_data = chat_message.to_dict()
|
||||||
|
# Convert metadata dict to JSON string for storage (Appwrite requires string type)
|
||||||
|
# Always convert, even if empty dict (empty dict {} is falsy in Python!)
|
||||||
|
message_data['metadata'] = json.dumps(message_data.get('metadata') or {})
|
||||||
|
|
||||||
|
self.db.create_row(
|
||||||
|
table_id='chat_messages',
|
||||||
|
data=message_data,
|
||||||
|
row_id=chat_message.message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Chat message saved",
|
||||||
|
message_id=chat_message.message_id,
|
||||||
|
character_id=character_id,
|
||||||
|
npc_id=npc_id,
|
||||||
|
context=context.value)
|
||||||
|
|
||||||
|
# Update character's recent_messages cache
|
||||||
|
self._update_recent_messages_preview(character_id, user_id, npc_id, chat_message)
|
||||||
|
|
||||||
|
return chat_message
|
||||||
|
|
||||||
|
except ChatMessagePermissionDenied:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to save dialogue exchange",
|
||||||
|
character_id=character_id,
|
||||||
|
npc_id=npc_id,
|
||||||
|
error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_conversation_history(
|
||||||
|
self,
|
||||||
|
character_id: str,
|
||||||
|
user_id: str,
|
||||||
|
npc_id: str,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0
|
||||||
|
) -> List[ChatMessage]:
|
||||||
|
"""
|
||||||
|
Get paginated conversation history between character and specific NPC.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_id: Player's character ID
|
||||||
|
user_id: User ID (for ownership validation)
|
||||||
|
npc_id: NPC identifier
|
||||||
|
limit: Maximum messages to return (default 50, max 100)
|
||||||
|
offset: Number of messages to skip for pagination
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ChatMessage instances ordered by timestamp DESC (most recent first)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ChatMessagePermissionDenied: If user doesn't own the character
|
||||||
|
AppwriteException: If database query fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate ownership
|
||||||
|
self._validate_ownership(character_id, user_id)
|
||||||
|
|
||||||
|
# Clamp limit to max 100
|
||||||
|
limit = min(limit, 100)
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
queries = [
|
||||||
|
Query.equal('character_id', character_id),
|
||||||
|
Query.equal('npc_id', npc_id),
|
||||||
|
Query.equal('is_deleted', False),
|
||||||
|
Query.order_desc('timestamp'),
|
||||||
|
Query.limit(limit),
|
||||||
|
Query.offset(offset)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fetch messages
|
||||||
|
rows = self.db.list_rows(
|
||||||
|
table_id='chat_messages',
|
||||||
|
queries=queries
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to ChatMessage objects
|
||||||
|
messages = []
|
||||||
|
for row in rows:
|
||||||
|
message_data = row.data
|
||||||
|
# Parse JSON metadata if present
|
||||||
|
if message_data.get('metadata') and isinstance(message_data['metadata'], str):
|
||||||
|
message_data['metadata'] = json.loads(message_data['metadata'])
|
||||||
|
messages.append(ChatMessage.from_dict(message_data))
|
||||||
|
|
||||||
|
logger.info("Retrieved conversation history",
|
||||||
|
character_id=character_id,
|
||||||
|
npc_id=npc_id,
|
||||||
|
count=len(messages),
|
||||||
|
limit=limit,
|
||||||
|
offset=offset)
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
except ChatMessagePermissionDenied:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get conversation history",
|
||||||
|
character_id=character_id,
|
||||||
|
npc_id=npc_id,
|
||||||
|
error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
def search_messages(
|
||||||
|
self,
|
||||||
|
character_id: str,
|
||||||
|
user_id: str,
|
||||||
|
search_text: str,
|
||||||
|
npc_id: Optional[str] = None,
|
||||||
|
context: Optional[MessageContext] = None,
|
||||||
|
date_from: Optional[str] = None,
|
||||||
|
date_to: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0
|
||||||
|
) -> List[ChatMessage]:
|
||||||
|
"""
|
||||||
|
Search messages by text with optional filters.
|
||||||
|
|
||||||
|
Note: Appwrite's full-text search may be limited. This performs basic filtering.
|
||||||
|
For production use, consider implementing a dedicated search service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_id: Player's character ID
|
||||||
|
user_id: User ID (for ownership validation)
|
||||||
|
search_text: Text to search for in player_message and npc_response
|
||||||
|
npc_id: Optional filter by specific NPC
|
||||||
|
context: Optional filter by message context type
|
||||||
|
date_from: Optional start date (ISO format)
|
||||||
|
date_to: Optional end date (ISO format)
|
||||||
|
limit: Maximum messages to return (default 50)
|
||||||
|
offset: Number of messages to skip for pagination
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching ChatMessage instances
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ChatMessagePermissionDenied: If user doesn't own the character
|
||||||
|
AppwriteException: If database query fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate ownership
|
||||||
|
self._validate_ownership(character_id, user_id)
|
||||||
|
|
||||||
|
# Build base query
|
||||||
|
queries = [
|
||||||
|
Query.equal('character_id', character_id),
|
||||||
|
Query.equal('is_deleted', False)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add optional filters
|
||||||
|
if npc_id:
|
||||||
|
queries.append(Query.equal('npc_id', npc_id))
|
||||||
|
|
||||||
|
if context:
|
||||||
|
queries.append(Query.equal('context', context.value))
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
queries.append(Query.greater_than_equal('timestamp', date_from))
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
queries.append(Query.less_than_equal('timestamp', date_to))
|
||||||
|
|
||||||
|
# Add search (Appwrite uses Query.search() if available)
|
||||||
|
if search_text:
|
||||||
|
try:
|
||||||
|
queries.append(Query.search('player_message', search_text))
|
||||||
|
except:
|
||||||
|
# Fallback: will filter in Python if search not supported
|
||||||
|
pass
|
||||||
|
|
||||||
|
queries.extend([
|
||||||
|
Query.order_desc('timestamp'),
|
||||||
|
Query.limit(min(limit, 100)),
|
||||||
|
Query.offset(offset)
|
||||||
|
])
|
||||||
|
|
||||||
|
# Fetch messages
|
||||||
|
rows = self.db.list_rows(
|
||||||
|
table_id='chat_messages',
|
||||||
|
queries=queries
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to ChatMessage objects and apply text filter if needed
|
||||||
|
messages = []
|
||||||
|
for row in rows:
|
||||||
|
message_data = row.data
|
||||||
|
# Parse JSON metadata if present
|
||||||
|
if message_data.get('metadata') and isinstance(message_data['metadata'], str):
|
||||||
|
message_data['metadata'] = json.loads(message_data['metadata'])
|
||||||
|
|
||||||
|
# Filter by search text in Python if not handled by database
|
||||||
|
if search_text:
|
||||||
|
player_msg = message_data.get('player_message', '').lower()
|
||||||
|
npc_msg = message_data.get('npc_response', '').lower()
|
||||||
|
if search_text.lower() not in player_msg and search_text.lower() not in npc_msg:
|
||||||
|
continue
|
||||||
|
|
||||||
|
messages.append(ChatMessage.from_dict(message_data))
|
||||||
|
|
||||||
|
logger.info("Search completed",
|
||||||
|
character_id=character_id,
|
||||||
|
search_text=search_text,
|
||||||
|
npc_id=npc_id,
|
||||||
|
context=context.value if context else None,
|
||||||
|
results=len(messages))
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
except ChatMessagePermissionDenied:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to search messages",
|
||||||
|
character_id=character_id,
|
||||||
|
search_text=search_text,
|
||||||
|
error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_all_conversations_summary(
|
||||||
|
self,
|
||||||
|
character_id: str,
|
||||||
|
user_id: str
|
||||||
|
) -> List[ConversationSummary]:
|
||||||
|
"""
|
||||||
|
Get summary of all NPC conversations for a character.
|
||||||
|
|
||||||
|
Returns list of NPCs the character has talked to, with message counts,
|
||||||
|
last message timestamp, and preview of most recent exchange.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_id: Player's character ID
|
||||||
|
user_id: User ID (for ownership validation)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ConversationSummary instances
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ChatMessagePermissionDenied: If user doesn't own the character
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate ownership
|
||||||
|
self._validate_ownership(character_id, user_id)
|
||||||
|
|
||||||
|
# Get character to access npc_interactions
|
||||||
|
character = self.character_service.get_character(character_id, user_id)
|
||||||
|
|
||||||
|
# Build summaries from character's npc_interactions
|
||||||
|
summaries = []
|
||||||
|
for npc_id, interaction in character.npc_interactions.items():
|
||||||
|
# Get NPC name (if available from NPC data, otherwise use npc_id)
|
||||||
|
# TODO: When NPC service is available, fetch NPC name
|
||||||
|
npc_name = npc_id.replace('_', ' ').title()
|
||||||
|
|
||||||
|
# Get last message timestamp and preview from recent_messages
|
||||||
|
recent_messages = interaction.get('recent_messages', [])
|
||||||
|
last_timestamp = interaction.get('last_interaction', '')
|
||||||
|
recent_preview = ''
|
||||||
|
|
||||||
|
if recent_messages:
|
||||||
|
last_msg = recent_messages[-1]
|
||||||
|
last_timestamp = last_msg.get('timestamp', last_timestamp)
|
||||||
|
recent_preview = last_msg.get('npc_response', '')[:100] # First 100 chars
|
||||||
|
|
||||||
|
# Get total message count
|
||||||
|
total_messages = interaction.get('total_messages', interaction.get('interaction_count', 0))
|
||||||
|
|
||||||
|
summary = ConversationSummary(
|
||||||
|
npc_id=npc_id,
|
||||||
|
npc_name=npc_name,
|
||||||
|
last_message_timestamp=last_timestamp,
|
||||||
|
message_count=total_messages,
|
||||||
|
recent_preview=recent_preview
|
||||||
|
)
|
||||||
|
summaries.append(summary)
|
||||||
|
|
||||||
|
# Sort by most recent first
|
||||||
|
summaries.sort(key=lambda s: s.last_message_timestamp, reverse=True)
|
||||||
|
|
||||||
|
logger.info("Retrieved conversation summaries",
|
||||||
|
character_id=character_id,
|
||||||
|
conversation_count=len(summaries))
|
||||||
|
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
except ChatMessagePermissionDenied:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get conversation summaries",
|
||||||
|
character_id=character_id,
|
||||||
|
error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
def soft_delete_message(
|
||||||
|
self,
|
||||||
|
message_id: str,
|
||||||
|
character_id: str,
|
||||||
|
user_id: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Soft delete a message by setting is_deleted=True.
|
||||||
|
|
||||||
|
Used for privacy/moderation. Message remains in database but filtered from queries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: Message ID to delete
|
||||||
|
character_id: Character ID (for ownership validation)
|
||||||
|
user_id: User ID (for ownership validation)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ChatMessageNotFound: If message not found
|
||||||
|
ChatMessagePermissionDenied: If user doesn't own the character/message
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate ownership
|
||||||
|
self._validate_ownership(character_id, user_id)
|
||||||
|
|
||||||
|
# Fetch message to verify it belongs to this character
|
||||||
|
message_row = self.db.get_row(table_id='chat_messages', row_id=message_id)
|
||||||
|
|
||||||
|
if not message_row:
|
||||||
|
raise ChatMessageNotFound(f"Message {message_id} not found")
|
||||||
|
|
||||||
|
if message_row.data.get('character_id') != character_id:
|
||||||
|
raise ChatMessagePermissionDenied("Message does not belong to this character")
|
||||||
|
|
||||||
|
# Update message to set is_deleted=True
|
||||||
|
self.db.update_row(
|
||||||
|
table_id='chat_messages',
|
||||||
|
row_id=message_id,
|
||||||
|
data={'is_deleted': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Message soft deleted",
|
||||||
|
message_id=message_id,
|
||||||
|
character_id=character_id)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except (ChatMessageNotFound, ChatMessagePermissionDenied):
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to soft delete message",
|
||||||
|
message_id=message_id,
|
||||||
|
error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
def delete_messages_by_session(self, session_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Permanently delete all chat messages associated with a session.
|
||||||
|
|
||||||
|
Used when a session is deleted to clean up associated messages.
|
||||||
|
This is a hard delete - messages are removed from the database entirely.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID whose messages should be deleted
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of messages deleted
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This method does not validate ownership because it's called from
|
||||||
|
session_service after ownership has already been validated.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Query all messages with this session_id
|
||||||
|
messages = self.db.list_rows(
|
||||||
|
table_id='chat_messages',
|
||||||
|
queries=[Query.equal('session_id', session_id)],
|
||||||
|
limit=1000 # Handle up to 1000 messages per session
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted_count = 0
|
||||||
|
for message in messages:
|
||||||
|
try:
|
||||||
|
self.db.delete_document(
|
||||||
|
collection_id='chat_messages',
|
||||||
|
document_id=message.id
|
||||||
|
)
|
||||||
|
deleted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to delete individual message",
|
||||||
|
message_id=message.id,
|
||||||
|
session_id=session_id,
|
||||||
|
error=str(e))
|
||||||
|
# Continue deleting other messages
|
||||||
|
|
||||||
|
logger.info("Deleted messages for session",
|
||||||
|
session_id=session_id,
|
||||||
|
deleted_count=deleted_count)
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete messages by session",
|
||||||
|
session_id=session_id,
|
||||||
|
error=str(e))
|
||||||
|
# Don't raise - session deletion should still proceed
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Helper Methods
|
||||||
|
|
||||||
|
def _update_recent_messages_preview(
|
||||||
|
self,
|
||||||
|
character_id: str,
|
||||||
|
user_id: str,
|
||||||
|
npc_id: str,
|
||||||
|
new_message: ChatMessage
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Update the recent_messages cache in character.npc_interactions[npc_id].
|
||||||
|
|
||||||
|
Maintains the last 3 messages for quick AI context retrieval without querying
|
||||||
|
the chat_messages collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_id: Character ID
|
||||||
|
user_id: User ID
|
||||||
|
npc_id: NPC ID
|
||||||
|
new_message: New message to add to preview
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get character
|
||||||
|
character = self.character_service.get_character(character_id, user_id)
|
||||||
|
|
||||||
|
# Get or create NPC interaction
|
||||||
|
if npc_id not in character.npc_interactions:
|
||||||
|
character.npc_interactions[npc_id] = {
|
||||||
|
'npc_id': npc_id,
|
||||||
|
'first_met': new_message.timestamp,
|
||||||
|
'last_interaction': new_message.timestamp,
|
||||||
|
'interaction_count': 0,
|
||||||
|
'revealed_secrets': [],
|
||||||
|
'relationship_level': 50,
|
||||||
|
'custom_flags': {},
|
||||||
|
'recent_messages': [],
|
||||||
|
'total_messages': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
interaction = character.npc_interactions[npc_id]
|
||||||
|
|
||||||
|
# Add new message to recent_messages
|
||||||
|
recent_messages = interaction.get('recent_messages', [])
|
||||||
|
recent_messages.append(new_message.to_preview())
|
||||||
|
|
||||||
|
# Keep only last 3 messages
|
||||||
|
interaction['recent_messages'] = recent_messages[-3:]
|
||||||
|
|
||||||
|
# Update total message count
|
||||||
|
interaction['total_messages'] = interaction.get('total_messages', 0) + 1
|
||||||
|
|
||||||
|
# Update last_interaction timestamp
|
||||||
|
interaction['last_interaction'] = new_message.timestamp
|
||||||
|
|
||||||
|
# Save character
|
||||||
|
self.character_service.update_character(character_id, user_id, character)
|
||||||
|
|
||||||
|
logger.debug("Updated recent_messages preview",
|
||||||
|
character_id=character_id,
|
||||||
|
npc_id=npc_id,
|
||||||
|
recent_count=len(interaction['recent_messages']))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to update recent_messages preview",
|
||||||
|
character_id=character_id,
|
||||||
|
npc_id=npc_id,
|
||||||
|
error=str(e))
|
||||||
|
# Don't raise - this is a cache update, not critical
|
||||||
|
|
||||||
|
def _validate_ownership(self, character_id: str, user_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Validate that the user owns the character.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_id: Character ID
|
||||||
|
user_id: User ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ChatMessagePermissionDenied: If user doesn't own the character
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.character_service.get_character(character_id, user_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Ownership validation failed",
|
||||||
|
character_id=character_id,
|
||||||
|
user_id=user_id)
|
||||||
|
raise ChatMessagePermissionDenied(f"User {user_id} does not own character {character_id}")
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance for convenience
|
||||||
|
_service_instance: Optional[ChatMessageService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_chat_message_service() -> ChatMessageService:
|
||||||
|
"""
|
||||||
|
Get the global ChatMessageService instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Singleton ChatMessageService instance
|
||||||
|
"""
|
||||||
|
global _service_instance
|
||||||
|
if _service_instance is None:
|
||||||
|
_service_instance = ChatMessageService()
|
||||||
|
return _service_instance
|
||||||
359
api/app/services/combat_loot_service.py
Normal file
359
api/app/services/combat_loot_service.py
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
"""
|
||||||
|
Combat Loot Service - Orchestrates loot generation from combat encounters.
|
||||||
|
|
||||||
|
This service bridges the EnemyTemplate loot tables with both the StaticItemLoader
|
||||||
|
(for consumables and materials) and ItemGenerator (for procedural equipment).
|
||||||
|
|
||||||
|
The service calculates effective rarity based on:
|
||||||
|
- Party average level
|
||||||
|
- Enemy difficulty tier
|
||||||
|
- Character luck stat
|
||||||
|
- Optional loot bonus modifiers (from abilities, buffs, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from app.models.enemy import EnemyTemplate, LootEntry, LootType, EnemyDifficulty
|
||||||
|
from app.models.items import Item
|
||||||
|
from app.services.item_generator import get_item_generator, ItemGenerator
|
||||||
|
from app.services.static_item_loader import get_static_item_loader, StaticItemLoader
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
# Difficulty tier rarity bonuses (converted to effective luck points)
|
||||||
|
# Higher difficulty enemies have better chances of dropping rare items
|
||||||
|
DIFFICULTY_RARITY_BONUS = {
|
||||||
|
EnemyDifficulty.EASY: 0.0,
|
||||||
|
EnemyDifficulty.MEDIUM: 0.05,
|
||||||
|
EnemyDifficulty.HARD: 0.15,
|
||||||
|
EnemyDifficulty.BOSS: 0.30,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Multiplier for converting rarity bonus to effective luck points
|
||||||
|
# Each 0.05 bonus translates to +1 effective luck
|
||||||
|
LUCK_CONVERSION_FACTOR = 20
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LootContext:
|
||||||
|
"""
|
||||||
|
Context for loot generation calculations.
|
||||||
|
|
||||||
|
Provides all the factors that influence loot quality and rarity.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
party_average_level: Average level of player characters in the encounter
|
||||||
|
enemy_difficulty: Difficulty tier of the enemy being looted
|
||||||
|
luck_stat: Party's luck stat (typically average or leader's luck)
|
||||||
|
loot_bonus: Additional bonus from abilities, buffs, or modifiers (0.0 to 1.0)
|
||||||
|
"""
|
||||||
|
party_average_level: int = 1
|
||||||
|
enemy_difficulty: EnemyDifficulty = EnemyDifficulty.EASY
|
||||||
|
luck_stat: int = 8
|
||||||
|
loot_bonus: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class CombatLootService:
|
||||||
|
"""
|
||||||
|
Service for generating combat loot drops.
|
||||||
|
|
||||||
|
Supports two types of loot:
|
||||||
|
- STATIC: Predefined items loaded from YAML (consumables, materials)
|
||||||
|
- PROCEDURAL: Generated equipment with affixes (weapons, armor)
|
||||||
|
|
||||||
|
The service handles:
|
||||||
|
- Rolling for drops based on drop_chance
|
||||||
|
- Loading static items via StaticItemLoader
|
||||||
|
- Generating procedural items via ItemGenerator
|
||||||
|
- Calculating effective rarity based on context
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
item_generator: Optional[ItemGenerator] = None,
|
||||||
|
static_loader: Optional[StaticItemLoader] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the combat loot service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_generator: ItemGenerator instance (uses global singleton if None)
|
||||||
|
static_loader: StaticItemLoader instance (uses global singleton if None)
|
||||||
|
"""
|
||||||
|
self.item_generator = item_generator or get_item_generator()
|
||||||
|
self.static_loader = static_loader or get_static_item_loader()
|
||||||
|
logger.info("CombatLootService initialized")
|
||||||
|
|
||||||
|
def generate_loot_from_enemy(
|
||||||
|
self,
|
||||||
|
enemy: EnemyTemplate,
|
||||||
|
context: LootContext
|
||||||
|
) -> List[Item]:
|
||||||
|
"""
|
||||||
|
Generate all loot drops from a defeated enemy.
|
||||||
|
|
||||||
|
Iterates through the enemy's loot table, rolling for each entry
|
||||||
|
and generating appropriate items based on loot type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enemy: The defeated enemy template
|
||||||
|
context: Loot generation context (party level, luck, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Item objects to add to player inventory
|
||||||
|
"""
|
||||||
|
items = []
|
||||||
|
|
||||||
|
for entry in enemy.loot_table:
|
||||||
|
# Roll for drop chance
|
||||||
|
if random.random() >= entry.drop_chance:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine quantity
|
||||||
|
quantity = random.randint(entry.quantity_min, entry.quantity_max)
|
||||||
|
|
||||||
|
if entry.loot_type == LootType.STATIC:
|
||||||
|
# Static item: load from predefined templates
|
||||||
|
static_items = self._generate_static_items(entry, quantity)
|
||||||
|
items.extend(static_items)
|
||||||
|
|
||||||
|
elif entry.loot_type == LootType.PROCEDURAL:
|
||||||
|
# Procedural equipment: generate with ItemGenerator
|
||||||
|
procedural_items = self._generate_procedural_items(
|
||||||
|
entry, quantity, context
|
||||||
|
)
|
||||||
|
items.extend(procedural_items)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Loot generated from enemy",
|
||||||
|
enemy_id=enemy.enemy_id,
|
||||||
|
enemy_difficulty=enemy.difficulty.value,
|
||||||
|
item_count=len(items),
|
||||||
|
party_level=context.party_average_level,
|
||||||
|
luck=context.luck_stat
|
||||||
|
)
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
def _generate_static_items(
|
||||||
|
self,
|
||||||
|
entry: LootEntry,
|
||||||
|
quantity: int
|
||||||
|
) -> List[Item]:
|
||||||
|
"""
|
||||||
|
Generate static items from a loot entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entry: The loot table entry
|
||||||
|
quantity: Number of items to generate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Item instances
|
||||||
|
"""
|
||||||
|
items = []
|
||||||
|
|
||||||
|
if not entry.item_id:
|
||||||
|
logger.warning(
|
||||||
|
"Static loot entry missing item_id",
|
||||||
|
entry=entry.to_dict()
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
for _ in range(quantity):
|
||||||
|
item = self.static_loader.get_item(entry.item_id)
|
||||||
|
if item:
|
||||||
|
items.append(item)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to load static item",
|
||||||
|
item_id=entry.item_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
def _generate_procedural_items(
|
||||||
|
self,
|
||||||
|
entry: LootEntry,
|
||||||
|
quantity: int,
|
||||||
|
context: LootContext
|
||||||
|
) -> List[Item]:
|
||||||
|
"""
|
||||||
|
Generate procedural items from a loot entry.
|
||||||
|
|
||||||
|
Calculates effective luck based on:
|
||||||
|
- Base luck stat
|
||||||
|
- Entry-specific rarity bonus
|
||||||
|
- Difficulty bonus
|
||||||
|
- Loot bonus from abilities/buffs
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entry: The loot table entry
|
||||||
|
quantity: Number of items to generate
|
||||||
|
context: Loot generation context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of generated Item instances
|
||||||
|
"""
|
||||||
|
items = []
|
||||||
|
|
||||||
|
if not entry.item_type:
|
||||||
|
logger.warning(
|
||||||
|
"Procedural loot entry missing item_type",
|
||||||
|
entry=entry.to_dict()
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
# Calculate effective luck for rarity roll
|
||||||
|
effective_luck = self._calculate_effective_luck(entry, context)
|
||||||
|
|
||||||
|
for _ in range(quantity):
|
||||||
|
item = self.item_generator.generate_loot_drop(
|
||||||
|
character_level=context.party_average_level,
|
||||||
|
luck_stat=effective_luck,
|
||||||
|
item_type=entry.item_type
|
||||||
|
)
|
||||||
|
if item:
|
||||||
|
items.append(item)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to generate procedural item",
|
||||||
|
item_type=entry.item_type,
|
||||||
|
level=context.party_average_level
|
||||||
|
)
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
def _calculate_effective_luck(
|
||||||
|
self,
|
||||||
|
entry: LootEntry,
|
||||||
|
context: LootContext
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Calculate effective luck for rarity rolling.
|
||||||
|
|
||||||
|
Combines multiple factors:
|
||||||
|
- Base luck stat from party
|
||||||
|
- Entry-specific rarity bonus (defined per loot entry)
|
||||||
|
- Difficulty bonus (based on enemy tier)
|
||||||
|
- Loot bonus (from abilities, buffs, etc.)
|
||||||
|
|
||||||
|
The formula:
|
||||||
|
effective_luck = base_luck + (entry_bonus + difficulty_bonus + loot_bonus) * FACTOR
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entry: The loot table entry
|
||||||
|
context: Loot generation context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Effective luck stat for rarity calculations
|
||||||
|
"""
|
||||||
|
# Get difficulty bonus
|
||||||
|
difficulty_bonus = DIFFICULTY_RARITY_BONUS.get(
|
||||||
|
context.enemy_difficulty, 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sum all bonuses
|
||||||
|
total_bonus = (
|
||||||
|
entry.rarity_bonus +
|
||||||
|
difficulty_bonus +
|
||||||
|
context.loot_bonus
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert bonus to effective luck points
|
||||||
|
bonus_luck = int(total_bonus * LUCK_CONVERSION_FACTOR)
|
||||||
|
|
||||||
|
effective_luck = context.luck_stat + bonus_luck
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Effective luck calculated",
|
||||||
|
base_luck=context.luck_stat,
|
||||||
|
entry_bonus=entry.rarity_bonus,
|
||||||
|
difficulty_bonus=difficulty_bonus,
|
||||||
|
loot_bonus=context.loot_bonus,
|
||||||
|
total_bonus=total_bonus,
|
||||||
|
effective_luck=effective_luck
|
||||||
|
)
|
||||||
|
|
||||||
|
return effective_luck
|
||||||
|
|
||||||
|
def generate_boss_loot(
|
||||||
|
self,
|
||||||
|
enemy: EnemyTemplate,
|
||||||
|
context: LootContext,
|
||||||
|
guaranteed_drops: int = 1
|
||||||
|
) -> List[Item]:
|
||||||
|
"""
|
||||||
|
Generate loot from a boss enemy with guaranteed drops.
|
||||||
|
|
||||||
|
Boss enemies are guaranteed to drop at least one piece of equipment
|
||||||
|
in addition to their normal loot table rolls.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enemy: The boss enemy template
|
||||||
|
context: Loot generation context
|
||||||
|
guaranteed_drops: Number of guaranteed equipment drops
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Item objects including guaranteed drops
|
||||||
|
"""
|
||||||
|
# Generate normal loot first
|
||||||
|
items = self.generate_loot_from_enemy(enemy, context)
|
||||||
|
|
||||||
|
# Add guaranteed procedural drops for bosses
|
||||||
|
if enemy.is_boss():
|
||||||
|
context_for_boss = LootContext(
|
||||||
|
party_average_level=context.party_average_level,
|
||||||
|
enemy_difficulty=EnemyDifficulty.BOSS,
|
||||||
|
luck_stat=context.luck_stat,
|
||||||
|
loot_bonus=context.loot_bonus + 0.1 # Extra bonus for bosses
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(guaranteed_drops):
|
||||||
|
# Alternate between weapon and armor
|
||||||
|
item_type = random.choice(["weapon", "armor"])
|
||||||
|
effective_luck = self._calculate_effective_luck(
|
||||||
|
LootEntry(
|
||||||
|
loot_type=LootType.PROCEDURAL,
|
||||||
|
item_type=item_type,
|
||||||
|
rarity_bonus=0.15 # Boss-tier bonus
|
||||||
|
),
|
||||||
|
context_for_boss
|
||||||
|
)
|
||||||
|
|
||||||
|
item = self.item_generator.generate_loot_drop(
|
||||||
|
character_level=context.party_average_level,
|
||||||
|
luck_stat=effective_luck,
|
||||||
|
item_type=item_type
|
||||||
|
)
|
||||||
|
if item:
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Boss loot generated",
|
||||||
|
enemy_id=enemy.enemy_id,
|
||||||
|
guaranteed_drops=guaranteed_drops,
|
||||||
|
total_items=len(items)
|
||||||
|
)
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
# Global singleton
|
||||||
|
_service_instance: Optional[CombatLootService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_combat_loot_service() -> CombatLootService:
|
||||||
|
"""
|
||||||
|
Get the global CombatLootService instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Singleton CombatLootService instance
|
||||||
|
"""
|
||||||
|
global _service_instance
|
||||||
|
if _service_instance is None:
|
||||||
|
_service_instance = CombatLootService()
|
||||||
|
return _service_instance
|
||||||
578
api/app/services/combat_repository.py
Normal file
578
api/app/services/combat_repository.py
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
"""
|
||||||
|
Combat Repository - Database operations for combat encounters.
|
||||||
|
|
||||||
|
This service handles all CRUD operations for combat data stored in
|
||||||
|
dedicated database tables (combat_encounters, combat_rounds).
|
||||||
|
|
||||||
|
Separates combat persistence from the CombatService which handles
|
||||||
|
business logic and game mechanics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from appwrite.query import Query
|
||||||
|
|
||||||
|
from app.models.combat import CombatEncounter, Combatant
|
||||||
|
from app.models.enums import CombatStatus
|
||||||
|
from app.services.database_service import get_database_service, DatabaseService
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Exceptions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class CombatEncounterNotFound(Exception):
|
||||||
|
"""Raised when combat encounter is not found in database."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CombatRoundNotFound(Exception):
|
||||||
|
"""Raised when combat round is not found in database."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Combat Repository
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class CombatRepository:
|
||||||
|
"""
|
||||||
|
Repository for combat encounter database operations.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Creating and reading combat encounters
|
||||||
|
- Updating combat state during actions
|
||||||
|
- Saving per-round history for logging and replay
|
||||||
|
- Time-based cleanup of old combat data
|
||||||
|
|
||||||
|
Tables:
|
||||||
|
- combat_encounters: Main encounter state and metadata
|
||||||
|
- combat_rounds: Per-round action history
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Table IDs
|
||||||
|
ENCOUNTERS_TABLE = "combat_encounters"
|
||||||
|
ROUNDS_TABLE = "combat_rounds"
|
||||||
|
|
||||||
|
# Default retention period for cleanup (days)
|
||||||
|
DEFAULT_RETENTION_DAYS = 7
|
||||||
|
|
||||||
|
def __init__(self, db: Optional[DatabaseService] = None):
|
||||||
|
"""
|
||||||
|
Initialize the combat repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Optional DatabaseService instance (for testing/injection)
|
||||||
|
"""
|
||||||
|
self.db = db or get_database_service()
|
||||||
|
logger.info("CombatRepository initialized")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Encounter CRUD Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def create_encounter(
|
||||||
|
self,
|
||||||
|
encounter: CombatEncounter,
|
||||||
|
session_id: str,
|
||||||
|
user_id: str
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Create a new combat encounter record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encounter: CombatEncounter instance to persist
|
||||||
|
session_id: Game session ID this encounter belongs to
|
||||||
|
user_id: Owner user ID for authorization
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
encounter_id of created record
|
||||||
|
"""
|
||||||
|
created_at = self._get_timestamp()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'sessionId': session_id,
|
||||||
|
'userId': user_id,
|
||||||
|
'status': encounter.status.value,
|
||||||
|
'roundNumber': encounter.round_number,
|
||||||
|
'currentTurnIndex': encounter.current_turn_index,
|
||||||
|
'turnOrder': json.dumps(encounter.turn_order),
|
||||||
|
'combatantsData': json.dumps([c.to_dict() for c in encounter.combatants]),
|
||||||
|
'combatLog': json.dumps(encounter.combat_log),
|
||||||
|
'created_at': created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.db.create_row(
|
||||||
|
table_id=self.ENCOUNTERS_TABLE,
|
||||||
|
data=data,
|
||||||
|
row_id=encounter.encounter_id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Combat encounter created",
|
||||||
|
encounter_id=encounter.encounter_id,
|
||||||
|
session_id=session_id,
|
||||||
|
combatant_count=len(encounter.combatants))
|
||||||
|
|
||||||
|
return encounter.encounter_id
|
||||||
|
|
||||||
|
def get_encounter(self, encounter_id: str) -> Optional[CombatEncounter]:
|
||||||
|
"""
|
||||||
|
Get a combat encounter by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encounter_id: Encounter ID to fetch
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CombatEncounter or None if not found
|
||||||
|
"""
|
||||||
|
logger.info("Fetching encounter from database",
|
||||||
|
encounter_id=encounter_id)
|
||||||
|
|
||||||
|
row = self.db.get_row(self.ENCOUNTERS_TABLE, encounter_id)
|
||||||
|
if not row:
|
||||||
|
logger.warning("Encounter not found", encounter_id=encounter_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info("Raw database row data",
|
||||||
|
encounter_id=encounter_id,
|
||||||
|
currentTurnIndex=row.data.get('currentTurnIndex'),
|
||||||
|
roundNumber=row.data.get('roundNumber'))
|
||||||
|
|
||||||
|
encounter = self._row_to_encounter(row.data, encounter_id)
|
||||||
|
|
||||||
|
logger.info("Encounter object created",
|
||||||
|
encounter_id=encounter_id,
|
||||||
|
current_turn_index=encounter.current_turn_index,
|
||||||
|
turn_order=encounter.turn_order)
|
||||||
|
|
||||||
|
return encounter
|
||||||
|
|
||||||
|
def get_encounter_by_session(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
active_only: bool = True
|
||||||
|
) -> Optional[CombatEncounter]:
|
||||||
|
"""
|
||||||
|
Get combat encounter for a session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Game session ID
|
||||||
|
active_only: If True, only return active encounters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CombatEncounter or None if not found
|
||||||
|
"""
|
||||||
|
queries = [Query.equal('sessionId', session_id)]
|
||||||
|
if active_only:
|
||||||
|
queries.append(Query.equal('status', CombatStatus.ACTIVE.value))
|
||||||
|
|
||||||
|
rows = self.db.list_rows(
|
||||||
|
table_id=self.ENCOUNTERS_TABLE,
|
||||||
|
queries=queries,
|
||||||
|
limit=1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
|
||||||
|
row = rows[0]
|
||||||
|
return self._row_to_encounter(row.data, row.id)
|
||||||
|
|
||||||
|
def get_user_active_encounters(self, user_id: str) -> List[CombatEncounter]:
|
||||||
|
"""
|
||||||
|
Get all active encounters for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to query
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of active CombatEncounter instances
|
||||||
|
"""
|
||||||
|
rows = self.db.list_rows(
|
||||||
|
table_id=self.ENCOUNTERS_TABLE,
|
||||||
|
queries=[
|
||||||
|
Query.equal('userId', user_id),
|
||||||
|
Query.equal('status', CombatStatus.ACTIVE.value)
|
||||||
|
],
|
||||||
|
limit=25
|
||||||
|
)
|
||||||
|
|
||||||
|
return [self._row_to_encounter(row.data, row.id) for row in rows]
|
||||||
|
|
||||||
|
def update_encounter(self, encounter: CombatEncounter) -> None:
|
||||||
|
"""
|
||||||
|
Update an existing combat encounter.
|
||||||
|
|
||||||
|
Call this after each action to persist the updated state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encounter: CombatEncounter with updated state
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
'status': encounter.status.value,
|
||||||
|
'roundNumber': encounter.round_number,
|
||||||
|
'currentTurnIndex': encounter.current_turn_index,
|
||||||
|
'turnOrder': json.dumps(encounter.turn_order),
|
||||||
|
'combatantsData': json.dumps([c.to_dict() for c in encounter.combatants]),
|
||||||
|
'combatLog': json.dumps(encounter.combat_log),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Saving encounter to database",
|
||||||
|
encounter_id=encounter.encounter_id,
|
||||||
|
current_turn_index=encounter.current_turn_index,
|
||||||
|
combat_log_entries=len(encounter.combat_log))
|
||||||
|
|
||||||
|
self.db.update_row(
|
||||||
|
table_id=self.ENCOUNTERS_TABLE,
|
||||||
|
row_id=encounter.encounter_id,
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Encounter saved successfully",
|
||||||
|
encounter_id=encounter.encounter_id)
|
||||||
|
|
||||||
|
def end_encounter(
|
||||||
|
self,
|
||||||
|
encounter_id: str,
|
||||||
|
status: CombatStatus
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Mark an encounter as ended.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encounter_id: Encounter ID to end
|
||||||
|
status: Final status (VICTORY, DEFEAT, FLED)
|
||||||
|
"""
|
||||||
|
ended_at = self._get_timestamp()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'status': status.value,
|
||||||
|
'ended_at': ended_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.db.update_row(
|
||||||
|
table_id=self.ENCOUNTERS_TABLE,
|
||||||
|
row_id=encounter_id,
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Combat encounter ended",
|
||||||
|
encounter_id=encounter_id,
|
||||||
|
status=status.value)
|
||||||
|
|
||||||
|
def delete_encounter(self, encounter_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete an encounter and all its rounds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encounter_id: Encounter ID to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted successfully
|
||||||
|
"""
|
||||||
|
# Delete rounds first
|
||||||
|
self._delete_rounds_for_encounter(encounter_id)
|
||||||
|
|
||||||
|
# Delete encounter
|
||||||
|
result = self.db.delete_row(self.ENCOUNTERS_TABLE, encounter_id)
|
||||||
|
|
||||||
|
logger.info("Combat encounter deleted", encounter_id=encounter_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Round Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def save_round(
|
||||||
|
self,
|
||||||
|
encounter_id: str,
|
||||||
|
session_id: str,
|
||||||
|
round_number: int,
|
||||||
|
actions: List[Dict[str, Any]],
|
||||||
|
states_start: List[Combatant],
|
||||||
|
states_end: List[Combatant]
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Save a completed round's data for history/replay.
|
||||||
|
|
||||||
|
Call this at the end of each round (after all combatants have acted).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encounter_id: Parent encounter ID
|
||||||
|
session_id: Game session ID (denormalized for queries)
|
||||||
|
round_number: Round number (1-indexed)
|
||||||
|
actions: List of all actions taken this round
|
||||||
|
states_start: Combatant states at round start
|
||||||
|
states_end: Combatant states at round end
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
round_id of created record
|
||||||
|
"""
|
||||||
|
round_id = f"rnd_{uuid4().hex[:12]}"
|
||||||
|
created_at = self._get_timestamp()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'encounterId': encounter_id,
|
||||||
|
'sessionId': session_id,
|
||||||
|
'roundNumber': round_number,
|
||||||
|
'actionsData': json.dumps(actions),
|
||||||
|
'combatantStatesStart': json.dumps([c.to_dict() for c in states_start]),
|
||||||
|
'combatantStatesEnd': json.dumps([c.to_dict() for c in states_end]),
|
||||||
|
'created_at': created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.db.create_row(
|
||||||
|
table_id=self.ROUNDS_TABLE,
|
||||||
|
data=data,
|
||||||
|
row_id=round_id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug("Combat round saved",
|
||||||
|
round_id=round_id,
|
||||||
|
encounter_id=encounter_id,
|
||||||
|
round_number=round_number,
|
||||||
|
action_count=len(actions))
|
||||||
|
|
||||||
|
return round_id
|
||||||
|
|
||||||
|
def get_encounter_rounds(
|
||||||
|
self,
|
||||||
|
encounter_id: str,
|
||||||
|
limit: int = 100
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all rounds for an encounter, ordered by round number.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encounter_id: Encounter ID to fetch rounds for
|
||||||
|
limit: Maximum number of rounds to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of round data dictionaries
|
||||||
|
"""
|
||||||
|
rows = self.db.list_rows(
|
||||||
|
table_id=self.ROUNDS_TABLE,
|
||||||
|
queries=[Query.equal('encounterId', encounter_id)],
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
rounds = []
|
||||||
|
for row in rows:
|
||||||
|
rounds.append({
|
||||||
|
'round_id': row.id,
|
||||||
|
'round_number': row.data.get('roundNumber'),
|
||||||
|
'actions': json.loads(row.data.get('actionsData', '[]')),
|
||||||
|
'states_start': json.loads(row.data.get('combatantStatesStart', '[]')),
|
||||||
|
'states_end': json.loads(row.data.get('combatantStatesEnd', '[]')),
|
||||||
|
'created_at': row.data.get('created_at'),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by round number
|
||||||
|
return sorted(rounds, key=lambda r: r['round_number'])
|
||||||
|
|
||||||
|
def get_session_combat_history(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
limit: int = 50
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get combat history for a session.
|
||||||
|
|
||||||
|
Returns summary of all encounters for the session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Game session ID
|
||||||
|
limit: Maximum encounters to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of encounter summaries
|
||||||
|
"""
|
||||||
|
rows = self.db.list_rows(
|
||||||
|
table_id=self.ENCOUNTERS_TABLE,
|
||||||
|
queries=[Query.equal('sessionId', session_id)],
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
history = []
|
||||||
|
for row in rows:
|
||||||
|
history.append({
|
||||||
|
'encounter_id': row.id,
|
||||||
|
'status': row.data.get('status'),
|
||||||
|
'round_count': row.data.get('roundNumber', 1),
|
||||||
|
'created_at': row.data.get('created_at'),
|
||||||
|
'ended_at': row.data.get('ended_at'),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by created_at descending (newest first)
|
||||||
|
return sorted(history, key=lambda h: h['created_at'] or '', reverse=True)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Cleanup Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def delete_encounters_by_session(self, session_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Delete all encounters for a session.
|
||||||
|
|
||||||
|
Call this when a session is deleted.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID to clean up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of encounters deleted
|
||||||
|
"""
|
||||||
|
rows = self.db.list_rows(
|
||||||
|
table_id=self.ENCOUNTERS_TABLE,
|
||||||
|
queries=[Query.equal('sessionId', session_id)],
|
||||||
|
limit=100
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
for row in rows:
|
||||||
|
# Delete rounds first
|
||||||
|
self._delete_rounds_for_encounter(row.id)
|
||||||
|
# Delete encounter
|
||||||
|
self.db.delete_row(self.ENCOUNTERS_TABLE, row.id)
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
if deleted > 0:
|
||||||
|
logger.info("Deleted encounters for session",
|
||||||
|
session_id=session_id,
|
||||||
|
deleted_count=deleted)
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def delete_old_encounters(
|
||||||
|
self,
|
||||||
|
older_than_days: int = DEFAULT_RETENTION_DAYS
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Delete ended encounters older than specified days.
|
||||||
|
|
||||||
|
This is the main cleanup method for time-based retention.
|
||||||
|
Should be scheduled to run periodically (daily recommended).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
older_than_days: Delete encounters ended more than this many days ago
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of encounters deleted
|
||||||
|
"""
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
|
||||||
|
cutoff_str = cutoff.isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
# Find old ended encounters
|
||||||
|
# Note: We only delete ended encounters, not active ones
|
||||||
|
rows = self.db.list_rows(
|
||||||
|
table_id=self.ENCOUNTERS_TABLE,
|
||||||
|
queries=[
|
||||||
|
Query.notEqual('status', CombatStatus.ACTIVE.value),
|
||||||
|
Query.lessThan('created_at', cutoff_str)
|
||||||
|
],
|
||||||
|
limit=100
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
for row in rows:
|
||||||
|
self._delete_rounds_for_encounter(row.id)
|
||||||
|
self.db.delete_row(self.ENCOUNTERS_TABLE, row.id)
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
if deleted > 0:
|
||||||
|
logger.info("Deleted old combat encounters",
|
||||||
|
deleted_count=deleted,
|
||||||
|
older_than_days=older_than_days)
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Helper Methods
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _delete_rounds_for_encounter(self, encounter_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Delete all rounds for an encounter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encounter_id: Encounter ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of rounds deleted
|
||||||
|
"""
|
||||||
|
rows = self.db.list_rows(
|
||||||
|
table_id=self.ROUNDS_TABLE,
|
||||||
|
queries=[Query.equal('encounterId', encounter_id)],
|
||||||
|
limit=100
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
self.db.delete_row(self.ROUNDS_TABLE, row.id)
|
||||||
|
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
def _row_to_encounter(
|
||||||
|
self,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
encounter_id: str
|
||||||
|
) -> CombatEncounter:
|
||||||
|
"""
|
||||||
|
Convert database row data to CombatEncounter object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Row data dictionary
|
||||||
|
encounter_id: Encounter ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Deserialized CombatEncounter
|
||||||
|
"""
|
||||||
|
# Parse JSON fields
|
||||||
|
combatants_data = json.loads(data.get('combatantsData', '[]'))
|
||||||
|
combatants = [Combatant.from_dict(c) for c in combatants_data]
|
||||||
|
|
||||||
|
turn_order = json.loads(data.get('turnOrder', '[]'))
|
||||||
|
combat_log = json.loads(data.get('combatLog', '[]'))
|
||||||
|
|
||||||
|
# Parse status enum
|
||||||
|
status_str = data.get('status', 'active')
|
||||||
|
status = CombatStatus(status_str)
|
||||||
|
|
||||||
|
return CombatEncounter(
|
||||||
|
encounter_id=encounter_id,
|
||||||
|
combatants=combatants,
|
||||||
|
turn_order=turn_order,
|
||||||
|
current_turn_index=data.get('currentTurnIndex', 0),
|
||||||
|
round_number=data.get('roundNumber', 1),
|
||||||
|
combat_log=combat_log,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_timestamp(self) -> str:
|
||||||
|
"""Get current UTC timestamp in ISO format."""
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Global Instance
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
_repository_instance: Optional[CombatRepository] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_combat_repository() -> CombatRepository:
|
||||||
|
"""
|
||||||
|
Get the global CombatRepository instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Singleton CombatRepository instance
|
||||||
|
"""
|
||||||
|
global _repository_instance
|
||||||
|
if _repository_instance is None:
|
||||||
|
_repository_instance = CombatRepository()
|
||||||
|
return _repository_instance
|
||||||
1486
api/app/services/combat_service.py
Normal file
1486
api/app/services/combat_service.py
Normal file
File diff suppressed because it is too large
Load Diff
590
api/app/services/damage_calculator.py
Normal file
590
api/app/services/damage_calculator.py
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
"""
|
||||||
|
Damage Calculator Service
|
||||||
|
|
||||||
|
A comprehensive, formula-driven damage calculation system for Code of Conquest.
|
||||||
|
Handles physical, magical, and elemental damage with LUK stat integration
|
||||||
|
for variance, critical hits, and accuracy.
|
||||||
|
|
||||||
|
Formulas:
|
||||||
|
Physical: (effective_stats.damage + ability_power) * Variance * Crit_Mult - DEF
|
||||||
|
where effective_stats.damage = int(STR * 0.75) + damage_bonus (from weapon)
|
||||||
|
Magical: (effective_stats.spell_power + ability_power) * Variance * Crit_Mult - RES
|
||||||
|
where effective_stats.spell_power = int(INT * 0.75) + spell_power_bonus (from staff/wand)
|
||||||
|
Elemental: Split between physical and magical components using ratios
|
||||||
|
|
||||||
|
LUK Integration:
|
||||||
|
- Miss reduction: 10% base - (LUK * 0.5%), hard cap at 5% miss
|
||||||
|
- Crit bonus: Base 5% + (LUK * 0.5%), max 25%
|
||||||
|
- Lucky variance: 5% + (LUK * 0.25%) chance for higher damage roll
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
|
||||||
|
from app.models.stats import Stats
|
||||||
|
from app.models.enums import DamageType
|
||||||
|
|
||||||
|
|
||||||
|
class CombatConstants:
|
||||||
|
"""
|
||||||
|
Combat system tuning constants.
|
||||||
|
|
||||||
|
These values control the balance of combat mechanics and can be
|
||||||
|
adjusted for game balance without modifying formula logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Stat Scaling
|
||||||
|
# How much primary stats (STR/INT) contribute to damage
|
||||||
|
# 0.75 means STR 14 adds +10.5 damage
|
||||||
|
STAT_SCALING_FACTOR: float = 0.75
|
||||||
|
|
||||||
|
# Hit/Miss System
|
||||||
|
BASE_MISS_CHANCE: float = 0.10 # 10% base miss rate
|
||||||
|
LUK_MISS_REDUCTION: float = 0.005 # 0.5% per LUK point
|
||||||
|
DEX_EVASION_BONUS: float = 0.0025 # 0.25% per DEX above 10
|
||||||
|
MIN_MISS_CHANCE: float = 0.05 # Hard cap: 5% minimum miss
|
||||||
|
|
||||||
|
# Critical Hits
|
||||||
|
DEFAULT_CRIT_CHANCE: float = 0.05 # 5% base crit
|
||||||
|
LUK_CRIT_BONUS: float = 0.005 # 0.5% per LUK point
|
||||||
|
MAX_CRIT_CHANCE: float = 0.25 # 25% cap (before skills)
|
||||||
|
DEFAULT_CRIT_MULTIPLIER: float = 2.0
|
||||||
|
|
||||||
|
# Damage Variance
|
||||||
|
BASE_VARIANCE_MIN: float = 0.95 # Minimum variance roll
|
||||||
|
BASE_VARIANCE_MAX: float = 1.05 # Maximum variance roll
|
||||||
|
LUCKY_VARIANCE_MIN: float = 1.00 # Lucky roll minimum
|
||||||
|
LUCKY_VARIANCE_MAX: float = 1.10 # Lucky roll maximum (10% bonus)
|
||||||
|
BASE_LUCKY_CHANCE: float = 0.05 # 5% base lucky roll chance
|
||||||
|
LUK_LUCKY_BONUS: float = 0.0025 # 0.25% per LUK point
|
||||||
|
|
||||||
|
# Defense Mitigation
|
||||||
|
# Ensures high-DEF targets still take meaningful damage
|
||||||
|
MIN_DAMAGE_RATIO: float = 0.20 # 20% of raw always goes through
|
||||||
|
MIN_DAMAGE: int = 1 # Absolute minimum damage
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DamageResult:
|
||||||
|
"""
|
||||||
|
Result of a damage calculation.
|
||||||
|
|
||||||
|
Contains the calculated damage values, whether the attack was a crit or miss,
|
||||||
|
and a human-readable message for the combat log.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
total_damage: Final damage after all calculations
|
||||||
|
physical_damage: Physical component (for split damage)
|
||||||
|
elemental_damage: Elemental component (for split damage)
|
||||||
|
damage_type: Primary damage type (physical, fire, etc.)
|
||||||
|
is_critical: Whether the attack was a critical hit
|
||||||
|
is_miss: Whether the attack missed entirely
|
||||||
|
variance_roll: The variance multiplier that was applied
|
||||||
|
raw_damage: Damage before defense mitigation
|
||||||
|
message: Human-readable description for combat log
|
||||||
|
"""
|
||||||
|
|
||||||
|
total_damage: int = 0
|
||||||
|
physical_damage: int = 0
|
||||||
|
elemental_damage: int = 0
|
||||||
|
damage_type: DamageType = DamageType.PHYSICAL
|
||||||
|
elemental_type: Optional[DamageType] = None
|
||||||
|
is_critical: bool = False
|
||||||
|
is_miss: bool = False
|
||||||
|
variance_roll: float = 1.0
|
||||||
|
raw_damage: int = 0
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize damage result to dictionary."""
|
||||||
|
return {
|
||||||
|
"total_damage": self.total_damage,
|
||||||
|
"physical_damage": self.physical_damage,
|
||||||
|
"elemental_damage": self.elemental_damage,
|
||||||
|
"damage_type": self.damage_type.value if self.damage_type else "physical",
|
||||||
|
"elemental_type": self.elemental_type.value if self.elemental_type else None,
|
||||||
|
"is_critical": self.is_critical,
|
||||||
|
"is_miss": self.is_miss,
|
||||||
|
"variance_roll": round(self.variance_roll, 3),
|
||||||
|
"raw_damage": self.raw_damage,
|
||||||
|
"message": self.message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DamageCalculator:
|
||||||
|
"""
|
||||||
|
Formula-driven damage calculator for combat.
|
||||||
|
|
||||||
|
This class provides static methods for calculating all types of damage
|
||||||
|
in the combat system, including hit/miss chances, critical hits,
|
||||||
|
damage variance, and defense mitigation.
|
||||||
|
|
||||||
|
All formulas integrate the LUK stat for meaningful randomness while
|
||||||
|
maintaining a hard cap on miss chance to prevent frustration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_hit_chance(
|
||||||
|
attacker_luck: int,
|
||||||
|
defender_dexterity: int,
|
||||||
|
skill_bonus: float = 0.0
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calculate hit probability for an attack.
|
||||||
|
|
||||||
|
Formula:
|
||||||
|
miss_chance = max(0.05, 0.10 - (LUK * 0.005) + ((DEX - 10) * 0.0025))
|
||||||
|
hit_chance = 1.0 - miss_chance
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attacker_luck: Attacker's LUK stat
|
||||||
|
defender_dexterity: Defender's DEX stat
|
||||||
|
skill_bonus: Additional hit chance from skills (0.0 to 1.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hit probability as a float between 0.0 and 1.0
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
LUK 8, DEX 10: miss = 10% - 4% + 0% = 6%
|
||||||
|
LUK 12, DEX 10: miss = 10% - 6% + 0% = 4% -> capped at 5%
|
||||||
|
LUK 8, DEX 15: miss = 10% - 4% + 1.25% = 7.25%
|
||||||
|
"""
|
||||||
|
# Base miss rate
|
||||||
|
base_miss = CombatConstants.BASE_MISS_CHANCE
|
||||||
|
|
||||||
|
# LUK reduces miss chance
|
||||||
|
luk_reduction = attacker_luck * CombatConstants.LUK_MISS_REDUCTION
|
||||||
|
|
||||||
|
# High DEX increases evasion (only DEX above 10 counts)
|
||||||
|
dex_above_base = max(0, defender_dexterity - 10)
|
||||||
|
dex_evasion = dex_above_base * CombatConstants.DEX_EVASION_BONUS
|
||||||
|
|
||||||
|
# Calculate final miss chance with hard cap
|
||||||
|
miss_chance = base_miss - luk_reduction + dex_evasion - skill_bonus
|
||||||
|
miss_chance = max(CombatConstants.MIN_MISS_CHANCE, miss_chance)
|
||||||
|
|
||||||
|
return 1.0 - miss_chance
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_crit_chance(
|
||||||
|
attacker_luck: int,
|
||||||
|
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
|
||||||
|
skill_bonus: float = 0.0
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calculate critical hit probability.
|
||||||
|
|
||||||
|
Formula:
|
||||||
|
crit_chance = min(0.25, weapon_crit + (LUK * 0.005) + skill_bonus)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attacker_luck: Attacker's LUK stat
|
||||||
|
weapon_crit_chance: Base crit chance from weapon (default 5%)
|
||||||
|
skill_bonus: Additional crit chance from skills
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Crit probability as a float (capped at 25%)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
LUK 8, weapon 5%: crit = 5% + 4% = 9%
|
||||||
|
LUK 12, weapon 5%: crit = 5% + 6% = 11%
|
||||||
|
LUK 12, weapon 10%: crit = 10% + 6% = 16%
|
||||||
|
"""
|
||||||
|
# LUK bonus to crit
|
||||||
|
luk_bonus = attacker_luck * CombatConstants.LUK_CRIT_BONUS
|
||||||
|
|
||||||
|
# Total crit chance with cap
|
||||||
|
total_crit = weapon_crit_chance + luk_bonus + skill_bonus
|
||||||
|
|
||||||
|
return min(CombatConstants.MAX_CRIT_CHANCE, total_crit)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_variance(attacker_luck: int) -> float:
|
||||||
|
"""
|
||||||
|
Calculate damage variance multiplier with LUK bonus.
|
||||||
|
|
||||||
|
Hybrid variance system:
|
||||||
|
- Base roll: 95% to 105% of damage
|
||||||
|
- LUK grants chance for "lucky roll": 100% to 110% instead
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attacker_luck: Attacker's LUK stat
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Variance multiplier (typically 0.95 to 1.10)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
LUK 8: 7% chance for lucky roll (100-110%)
|
||||||
|
LUK 12: 8% chance for lucky roll
|
||||||
|
"""
|
||||||
|
# Calculate lucky roll chance
|
||||||
|
lucky_chance = (
|
||||||
|
CombatConstants.BASE_LUCKY_CHANCE +
|
||||||
|
(attacker_luck * CombatConstants.LUK_LUCKY_BONUS)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Roll for lucky variance
|
||||||
|
if random.random() < lucky_chance:
|
||||||
|
# Lucky roll: higher damage range
|
||||||
|
return random.uniform(
|
||||||
|
CombatConstants.LUCKY_VARIANCE_MIN,
|
||||||
|
CombatConstants.LUCKY_VARIANCE_MAX
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Normal roll
|
||||||
|
return random.uniform(
|
||||||
|
CombatConstants.BASE_VARIANCE_MIN,
|
||||||
|
CombatConstants.BASE_VARIANCE_MAX
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def apply_defense(
|
||||||
|
raw_damage: int,
|
||||||
|
defense: int,
|
||||||
|
min_damage_ratio: float = CombatConstants.MIN_DAMAGE_RATIO
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Apply defense mitigation with minimum damage guarantee.
|
||||||
|
|
||||||
|
Ensures at least 20% of raw damage always goes through,
|
||||||
|
preventing high-DEF tanks from becoming unkillable.
|
||||||
|
Absolute minimum is always 1 damage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_damage: Damage before defense
|
||||||
|
defense: Target's defense value
|
||||||
|
min_damage_ratio: Minimum % of raw damage that goes through
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Final damage after mitigation (minimum 1)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
raw=20, def=5: 20 - 5 = 15 damage
|
||||||
|
raw=20, def=18: max(4, 2) = 4 damage (20% minimum)
|
||||||
|
raw=10, def=100: max(2, -90) = 2 damage (20% minimum)
|
||||||
|
"""
|
||||||
|
# Calculate mitigated damage
|
||||||
|
mitigated = raw_damage - defense
|
||||||
|
|
||||||
|
# Minimum damage is 20% of raw, or 1, whichever is higher
|
||||||
|
min_damage = max(CombatConstants.MIN_DAMAGE, int(raw_damage * min_damage_ratio))
|
||||||
|
|
||||||
|
return max(min_damage, mitigated)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def calculate_physical_damage(
|
||||||
|
cls,
|
||||||
|
attacker_stats: Stats,
|
||||||
|
defender_stats: Stats,
|
||||||
|
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
|
||||||
|
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
|
||||||
|
ability_base_power: int = 0,
|
||||||
|
skill_hit_bonus: float = 0.0,
|
||||||
|
skill_crit_bonus: float = 0.0,
|
||||||
|
) -> DamageResult:
|
||||||
|
"""
|
||||||
|
Calculate physical damage for a melee/ranged attack.
|
||||||
|
|
||||||
|
Formula:
|
||||||
|
Base = attacker_stats.damage + ability_base_power
|
||||||
|
where attacker_stats.damage = int(STR * 0.75) + damage_bonus
|
||||||
|
Damage = Base * Variance * Crit_Mult - DEF
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attacker_stats: Attacker's Stats (includes weapon damage via damage property)
|
||||||
|
defender_stats: Defender's Stats (DEX, CON used)
|
||||||
|
weapon_crit_chance: Crit chance from weapon (default 5%)
|
||||||
|
weapon_crit_multiplier: Crit damage multiplier (default 2.0x)
|
||||||
|
ability_base_power: Additional base power from ability
|
||||||
|
skill_hit_bonus: Hit chance bonus from skills
|
||||||
|
skill_crit_bonus: Crit chance bonus from skills
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DamageResult with calculated damage and metadata
|
||||||
|
"""
|
||||||
|
result = DamageResult(damage_type=DamageType.PHYSICAL)
|
||||||
|
|
||||||
|
# Step 1: Check for miss
|
||||||
|
hit_chance = cls.calculate_hit_chance(
|
||||||
|
attacker_stats.luck,
|
||||||
|
defender_stats.dexterity,
|
||||||
|
skill_hit_bonus
|
||||||
|
)
|
||||||
|
|
||||||
|
if random.random() > hit_chance:
|
||||||
|
result.is_miss = True
|
||||||
|
result.message = "Attack missed!"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Step 2: Calculate base damage
|
||||||
|
# attacker_stats.damage already includes: int(STR * 0.75) + damage_bonus (weapon)
|
||||||
|
base_damage = attacker_stats.damage + ability_base_power
|
||||||
|
|
||||||
|
# Step 3: Apply variance
|
||||||
|
variance = cls.calculate_variance(attacker_stats.luck)
|
||||||
|
result.variance_roll = variance
|
||||||
|
damage = base_damage * variance
|
||||||
|
|
||||||
|
# Step 4: Check for critical hit
|
||||||
|
crit_chance = cls.calculate_crit_chance(
|
||||||
|
attacker_stats.luck,
|
||||||
|
weapon_crit_chance,
|
||||||
|
skill_crit_bonus
|
||||||
|
)
|
||||||
|
|
||||||
|
if random.random() < crit_chance:
|
||||||
|
result.is_critical = True
|
||||||
|
damage *= weapon_crit_multiplier
|
||||||
|
|
||||||
|
# Store raw damage before defense
|
||||||
|
result.raw_damage = int(damage)
|
||||||
|
|
||||||
|
# Step 5: Apply defense mitigation
|
||||||
|
final_damage = cls.apply_defense(int(damage), defender_stats.defense)
|
||||||
|
|
||||||
|
result.total_damage = final_damage
|
||||||
|
result.physical_damage = final_damage
|
||||||
|
|
||||||
|
# Build message
|
||||||
|
crit_text = " CRITICAL HIT!" if result.is_critical else ""
|
||||||
|
result.message = f"Dealt {final_damage} physical damage.{crit_text}"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def calculate_magical_damage(
|
||||||
|
cls,
|
||||||
|
attacker_stats: Stats,
|
||||||
|
defender_stats: Stats,
|
||||||
|
ability_base_power: int,
|
||||||
|
damage_type: DamageType = DamageType.FIRE,
|
||||||
|
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
|
||||||
|
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
|
||||||
|
skill_hit_bonus: float = 0.0,
|
||||||
|
skill_crit_bonus: float = 0.0,
|
||||||
|
) -> DamageResult:
|
||||||
|
"""
|
||||||
|
Calculate magical damage for a spell.
|
||||||
|
|
||||||
|
Spells CAN critically hit (same formula as physical).
|
||||||
|
LUK benefits all classes equally.
|
||||||
|
|
||||||
|
Formula:
|
||||||
|
Base = attacker_stats.spell_power + ability_base_power
|
||||||
|
where attacker_stats.spell_power = int(INT * 0.75) + spell_power_bonus
|
||||||
|
Damage = Base * Variance * Crit_Mult - RES
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attacker_stats: Attacker's Stats (includes staff/wand spell_power via spell_power property)
|
||||||
|
defender_stats: Defender's Stats (DEX, WIS used)
|
||||||
|
ability_base_power: Base power of the spell
|
||||||
|
damage_type: Type of magical damage (fire, ice, etc.)
|
||||||
|
weapon_crit_chance: Crit chance (from focus/staff)
|
||||||
|
weapon_crit_multiplier: Crit damage multiplier
|
||||||
|
skill_hit_bonus: Hit chance bonus from skills
|
||||||
|
skill_crit_bonus: Crit chance bonus from skills
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DamageResult with calculated damage and metadata
|
||||||
|
"""
|
||||||
|
result = DamageResult(damage_type=damage_type)
|
||||||
|
|
||||||
|
# Step 1: Check for miss (spells can miss too)
|
||||||
|
hit_chance = cls.calculate_hit_chance(
|
||||||
|
attacker_stats.luck,
|
||||||
|
defender_stats.dexterity,
|
||||||
|
skill_hit_bonus
|
||||||
|
)
|
||||||
|
|
||||||
|
if random.random() > hit_chance:
|
||||||
|
result.is_miss = True
|
||||||
|
result.message = "Spell missed!"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Step 2: Calculate base damage
|
||||||
|
# attacker_stats.spell_power already includes: int(INT * 0.75) + spell_power_bonus (staff/wand)
|
||||||
|
base_damage = attacker_stats.spell_power + ability_base_power
|
||||||
|
|
||||||
|
# Step 3: Apply variance
|
||||||
|
variance = cls.calculate_variance(attacker_stats.luck)
|
||||||
|
result.variance_roll = variance
|
||||||
|
damage = base_damage * variance
|
||||||
|
|
||||||
|
# Step 4: Check for critical hit (spells CAN crit)
|
||||||
|
crit_chance = cls.calculate_crit_chance(
|
||||||
|
attacker_stats.luck,
|
||||||
|
weapon_crit_chance,
|
||||||
|
skill_crit_bonus
|
||||||
|
)
|
||||||
|
|
||||||
|
if random.random() < crit_chance:
|
||||||
|
result.is_critical = True
|
||||||
|
damage *= weapon_crit_multiplier
|
||||||
|
|
||||||
|
# Store raw damage before resistance
|
||||||
|
result.raw_damage = int(damage)
|
||||||
|
|
||||||
|
# Step 5: Apply resistance mitigation
|
||||||
|
final_damage = cls.apply_defense(int(damage), defender_stats.resistance)
|
||||||
|
|
||||||
|
result.total_damage = final_damage
|
||||||
|
result.elemental_damage = final_damage
|
||||||
|
|
||||||
|
# Build message
|
||||||
|
crit_text = " CRITICAL HIT!" if result.is_critical else ""
|
||||||
|
result.message = f"Dealt {final_damage} {damage_type.value} damage.{crit_text}"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def calculate_elemental_weapon_damage(
|
||||||
|
cls,
|
||||||
|
attacker_stats: Stats,
|
||||||
|
defender_stats: Stats,
|
||||||
|
weapon_crit_chance: float,
|
||||||
|
weapon_crit_multiplier: float,
|
||||||
|
physical_ratio: float,
|
||||||
|
elemental_ratio: float,
|
||||||
|
elemental_type: DamageType,
|
||||||
|
ability_base_power: int = 0,
|
||||||
|
skill_hit_bonus: float = 0.0,
|
||||||
|
skill_crit_bonus: float = 0.0,
|
||||||
|
) -> DamageResult:
|
||||||
|
"""
|
||||||
|
Calculate split damage for elemental weapons (e.g., Fire Sword).
|
||||||
|
|
||||||
|
Elemental weapons deal both physical AND elemental damage,
|
||||||
|
calculated separately against DEF and RES respectively.
|
||||||
|
|
||||||
|
Formula:
|
||||||
|
Physical = (attacker_stats.damage + ability_power) * PHYS_RATIO - DEF
|
||||||
|
Elemental = (attacker_stats.spell_power + ability_power) * ELEM_RATIO - RES
|
||||||
|
Total = Physical + Elemental
|
||||||
|
|
||||||
|
Recommended Split Ratios:
|
||||||
|
- Pure Physical: 100% / 0%
|
||||||
|
- Fire Sword: 70% / 30%
|
||||||
|
- Frost Blade: 60% / 40%
|
||||||
|
- Lightning Spear: 50% / 50%
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attacker_stats: Attacker's Stats (damage and spell_power include equipment)
|
||||||
|
defender_stats: Defender's Stats
|
||||||
|
weapon_crit_chance: Crit chance from weapon
|
||||||
|
weapon_crit_multiplier: Crit damage multiplier
|
||||||
|
physical_ratio: Portion of damage that is physical (0.0-1.0)
|
||||||
|
elemental_ratio: Portion of damage that is elemental (0.0-1.0)
|
||||||
|
elemental_type: Type of elemental damage
|
||||||
|
ability_base_power: Additional base power from ability
|
||||||
|
skill_hit_bonus: Hit chance bonus from skills
|
||||||
|
skill_crit_bonus: Crit chance bonus from skills
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DamageResult with split physical/elemental damage
|
||||||
|
"""
|
||||||
|
result = DamageResult(
|
||||||
|
damage_type=DamageType.PHYSICAL,
|
||||||
|
elemental_type=elemental_type
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 1: Check for miss (single roll for entire attack)
|
||||||
|
hit_chance = cls.calculate_hit_chance(
|
||||||
|
attacker_stats.luck,
|
||||||
|
defender_stats.dexterity,
|
||||||
|
skill_hit_bonus
|
||||||
|
)
|
||||||
|
|
||||||
|
if random.random() > hit_chance:
|
||||||
|
result.is_miss = True
|
||||||
|
result.message = "Attack missed!"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Step 2: Check for critical (single roll applies to both components)
|
||||||
|
variance = cls.calculate_variance(attacker_stats.luck)
|
||||||
|
result.variance_roll = variance
|
||||||
|
|
||||||
|
crit_chance = cls.calculate_crit_chance(
|
||||||
|
attacker_stats.luck,
|
||||||
|
weapon_crit_chance,
|
||||||
|
skill_crit_bonus
|
||||||
|
)
|
||||||
|
is_crit = random.random() < crit_chance
|
||||||
|
result.is_critical = is_crit
|
||||||
|
crit_mult = weapon_crit_multiplier if is_crit else 1.0
|
||||||
|
|
||||||
|
# Step 3: Calculate physical component
|
||||||
|
# attacker_stats.damage includes: int(STR * 0.75) + damage_bonus (weapon)
|
||||||
|
phys_base = (attacker_stats.damage + ability_base_power) * physical_ratio
|
||||||
|
phys_damage = phys_base * variance * crit_mult
|
||||||
|
phys_final = cls.apply_defense(int(phys_damage), defender_stats.defense)
|
||||||
|
|
||||||
|
# Step 4: Calculate elemental component
|
||||||
|
# attacker_stats.spell_power includes: int(INT * 0.75) + spell_power_bonus (staff/wand)
|
||||||
|
elem_base = (attacker_stats.spell_power + ability_base_power) * elemental_ratio
|
||||||
|
elem_damage = elem_base * variance * crit_mult
|
||||||
|
elem_final = cls.apply_defense(int(elem_damage), defender_stats.resistance)
|
||||||
|
|
||||||
|
# Step 5: Combine results
|
||||||
|
result.physical_damage = phys_final
|
||||||
|
result.elemental_damage = elem_final
|
||||||
|
result.total_damage = phys_final + elem_final
|
||||||
|
result.raw_damage = int(phys_damage + elem_damage)
|
||||||
|
|
||||||
|
# Build message
|
||||||
|
crit_text = " CRITICAL HIT!" if is_crit else ""
|
||||||
|
result.message = (
|
||||||
|
f"Dealt {result.total_damage} damage "
|
||||||
|
f"({phys_final} physical + {elem_final} {elemental_type.value}).{crit_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def calculate_aoe_damage(
|
||||||
|
cls,
|
||||||
|
attacker_stats: Stats,
|
||||||
|
defender_stats_list: List[Stats],
|
||||||
|
ability_base_power: int,
|
||||||
|
damage_type: DamageType = DamageType.FIRE,
|
||||||
|
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
|
||||||
|
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
|
||||||
|
skill_hit_bonus: float = 0.0,
|
||||||
|
skill_crit_bonus: float = 0.0,
|
||||||
|
) -> List[DamageResult]:
|
||||||
|
"""
|
||||||
|
Calculate AoE spell damage against multiple targets.
|
||||||
|
|
||||||
|
AoE spells deal FULL damage to all targets (balanced by higher mana costs).
|
||||||
|
Each target has independent hit/crit rolls but shares the base calculation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attacker_stats: Attacker's Stats
|
||||||
|
defender_stats_list: List of defender Stats (one per target)
|
||||||
|
ability_base_power: Base power of the AoE spell
|
||||||
|
damage_type: Type of magical damage
|
||||||
|
weapon_crit_chance: Crit chance from focus/staff
|
||||||
|
weapon_crit_multiplier: Crit damage multiplier
|
||||||
|
skill_hit_bonus: Hit chance bonus from skills
|
||||||
|
skill_crit_bonus: Crit chance bonus from skills
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of DamageResult, one per target
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Each target gets independent damage calculation
|
||||||
|
for defender_stats in defender_stats_list:
|
||||||
|
result = cls.calculate_magical_damage(
|
||||||
|
attacker_stats=attacker_stats,
|
||||||
|
defender_stats=defender_stats,
|
||||||
|
ability_base_power=ability_base_power,
|
||||||
|
damage_type=damage_type,
|
||||||
|
weapon_crit_chance=weapon_crit_chance,
|
||||||
|
weapon_crit_multiplier=weapon_crit_multiplier,
|
||||||
|
skill_hit_bonus=skill_hit_bonus,
|
||||||
|
skill_crit_bonus=skill_crit_bonus,
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
return results
|
||||||
@@ -97,6 +97,33 @@ class DatabaseInitService:
|
|||||||
logger.error("Failed to initialize ai_usage_logs table", error=str(e))
|
logger.error("Failed to initialize ai_usage_logs table", error=str(e))
|
||||||
results['ai_usage_logs'] = False
|
results['ai_usage_logs'] = False
|
||||||
|
|
||||||
|
# Initialize chat_messages table
|
||||||
|
try:
|
||||||
|
self.init_chat_messages_table()
|
||||||
|
results['chat_messages'] = True
|
||||||
|
logger.info("Chat messages table initialized successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to initialize chat_messages table", error=str(e))
|
||||||
|
results['chat_messages'] = False
|
||||||
|
|
||||||
|
# Initialize combat_encounters table
|
||||||
|
try:
|
||||||
|
self.init_combat_encounters_table()
|
||||||
|
results['combat_encounters'] = True
|
||||||
|
logger.info("Combat encounters table initialized successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to initialize combat_encounters table", error=str(e))
|
||||||
|
results['combat_encounters'] = False
|
||||||
|
|
||||||
|
# Initialize combat_rounds table
|
||||||
|
try:
|
||||||
|
self.init_combat_rounds_table()
|
||||||
|
results['combat_rounds'] = True
|
||||||
|
logger.info("Combat rounds table initialized successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to initialize combat_rounds table", error=str(e))
|
||||||
|
results['combat_rounds'] = False
|
||||||
|
|
||||||
success_count = sum(1 for v in results.values() if v)
|
success_count = sum(1 for v in results.values() if v)
|
||||||
total_count = len(results)
|
total_count = len(results)
|
||||||
|
|
||||||
@@ -536,6 +563,527 @@ class DatabaseInitService:
|
|||||||
code=e.code)
|
code=e.code)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def init_chat_messages_table(self) -> bool:
|
||||||
|
"""
|
||||||
|
Initialize the chat_messages table for storing player-NPC conversation history.
|
||||||
|
|
||||||
|
Table schema:
|
||||||
|
- message_id (string, required): Unique message identifier (UUID)
|
||||||
|
- character_id (string, required): Player's character ID
|
||||||
|
- npc_id (string, required): NPC identifier
|
||||||
|
- player_message (string, required): What the player said
|
||||||
|
- npc_response (string, required): NPC's reply
|
||||||
|
- timestamp (string, required): ISO timestamp when message was created
|
||||||
|
- session_id (string, optional): Game session reference
|
||||||
|
- location_id (string, optional): Where conversation happened
|
||||||
|
- context (string, required): Message context type (dialogue, quest, shop, etc.)
|
||||||
|
- metadata (string, optional): JSON metadata for quest_id, faction_id, etc.
|
||||||
|
- is_deleted (boolean, default=False): Soft delete flag
|
||||||
|
|
||||||
|
Indexes:
|
||||||
|
- character_id + npc_id + timestamp: Primary query pattern (conversation history)
|
||||||
|
- character_id + timestamp: All character messages
|
||||||
|
- session_id + timestamp: Session-based queries
|
||||||
|
- context: Filter by interaction type
|
||||||
|
- timestamp: Date range queries
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AppwriteException: If table creation fails
|
||||||
|
"""
|
||||||
|
table_id = 'chat_messages'
|
||||||
|
|
||||||
|
logger.info("Initializing chat_messages table", table_id=table_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if table already exists
|
||||||
|
try:
|
||||||
|
self.tables_db.get_table(
|
||||||
|
database_id=self.database_id,
|
||||||
|
table_id=table_id
|
||||||
|
)
|
||||||
|
logger.info("Chat messages table already exists", table_id=table_id)
|
||||||
|
return True
|
||||||
|
except AppwriteException as e:
|
||||||
|
if e.code != 404:
|
||||||
|
raise
|
||||||
|
logger.info("Chat messages table does not exist, creating...")
|
||||||
|
|
||||||
|
# Create table
|
||||||
|
logger.info("Creating chat_messages table")
|
||||||
|
table = self.tables_db.create_table(
|
||||||
|
database_id=self.database_id,
|
||||||
|
table_id=table_id,
|
||||||
|
name='Chat Messages'
|
||||||
|
)
|
||||||
|
logger.info("Chat messages table created", table_id=table['$id'])
|
||||||
|
|
||||||
|
# Create columns
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='message_id',
|
||||||
|
column_type='string',
|
||||||
|
size=36, # UUID length
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='character_id',
|
||||||
|
column_type='string',
|
||||||
|
size=100,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='npc_id',
|
||||||
|
column_type='string',
|
||||||
|
size=100,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='player_message',
|
||||||
|
column_type='string',
|
||||||
|
size=2000, # Player input limit
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='npc_response',
|
||||||
|
column_type='string',
|
||||||
|
size=5000, # AI-generated response
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='timestamp',
|
||||||
|
column_type='string',
|
||||||
|
size=50, # ISO timestamp format
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='session_id',
|
||||||
|
column_type='string',
|
||||||
|
size=100,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='location_id',
|
||||||
|
column_type='string',
|
||||||
|
size=100,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='context',
|
||||||
|
column_type='string',
|
||||||
|
size=50, # MessageContext enum values
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='metadata',
|
||||||
|
column_type='string',
|
||||||
|
size=1000, # JSON metadata
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='is_deleted',
|
||||||
|
column_type='boolean',
|
||||||
|
required=False,
|
||||||
|
default=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for columns to fully propagate
|
||||||
|
logger.info("Waiting for columns to propagate before creating indexes...")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Create indexes for efficient querying
|
||||||
|
# Most common query: get conversation between character and specific NPC
|
||||||
|
self._create_index(
|
||||||
|
table_id=table_id,
|
||||||
|
index_id='idx_character_npc_time',
|
||||||
|
index_type='key',
|
||||||
|
attributes=['character_id', 'npc_id', 'timestamp']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all messages for a character (across all NPCs)
|
||||||
|
self._create_index(
|
||||||
|
table_id=table_id,
|
||||||
|
index_id='idx_character_time',
|
||||||
|
index_type='key',
|
||||||
|
attributes=['character_id', 'timestamp']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Session-based queries
|
||||||
|
self._create_index(
|
||||||
|
table_id=table_id,
|
||||||
|
index_id='idx_session_time',
|
||||||
|
index_type='key',
|
||||||
|
attributes=['session_id', 'timestamp']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by context (quest, shop, lore, etc.)
|
||||||
|
self._create_index(
|
||||||
|
table_id=table_id,
|
||||||
|
index_id='idx_context',
|
||||||
|
index_type='key',
|
||||||
|
attributes=['context']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Date range queries
|
||||||
|
self._create_index(
|
||||||
|
table_id=table_id,
|
||||||
|
index_id='idx_timestamp',
|
||||||
|
index_type='key',
|
||||||
|
attributes=['timestamp']
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Chat messages table initialized successfully", table_id=table_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except AppwriteException as e:
|
||||||
|
logger.error("Failed to initialize chat_messages table",
|
||||||
|
table_id=table_id,
|
||||||
|
error=str(e),
|
||||||
|
code=e.code)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def init_combat_encounters_table(self) -> bool:
|
||||||
|
"""
|
||||||
|
Initialize the combat_encounters table for storing combat encounter state.
|
||||||
|
|
||||||
|
Table schema:
|
||||||
|
- sessionId (string, required): Game session ID (FK to game_sessions)
|
||||||
|
- userId (string, required): Owner user ID for authorization
|
||||||
|
- status (string, required): Combat status (active, victory, defeat, fled)
|
||||||
|
- roundNumber (integer, required): Current round number
|
||||||
|
- currentTurnIndex (integer, required): Index in turn_order for current turn
|
||||||
|
- turnOrder (string, required): JSON array of combatant IDs in initiative order
|
||||||
|
- combatantsData (string, required): JSON array of Combatant objects (full state)
|
||||||
|
- combatLog (string, optional): JSON array of all combat log entries
|
||||||
|
- created_at (string, required): ISO timestamp of combat start
|
||||||
|
- ended_at (string, optional): ISO timestamp when combat ended
|
||||||
|
|
||||||
|
Indexes:
|
||||||
|
- idx_sessionId: Session-based lookups
|
||||||
|
- idx_userId_status: User's active combats query
|
||||||
|
- idx_status_created_at: Time-based cleanup queries
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AppwriteException: If table creation fails
|
||||||
|
"""
|
||||||
|
table_id = 'combat_encounters'
|
||||||
|
|
||||||
|
logger.info("Initializing combat_encounters table", table_id=table_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if table already exists
|
||||||
|
try:
|
||||||
|
self.tables_db.get_table(
|
||||||
|
database_id=self.database_id,
|
||||||
|
table_id=table_id
|
||||||
|
)
|
||||||
|
logger.info("Combat encounters table already exists", table_id=table_id)
|
||||||
|
return True
|
||||||
|
except AppwriteException as e:
|
||||||
|
if e.code != 404:
|
||||||
|
raise
|
||||||
|
logger.info("Combat encounters table does not exist, creating...")
|
||||||
|
|
||||||
|
# Create table
|
||||||
|
logger.info("Creating combat_encounters table")
|
||||||
|
table = self.tables_db.create_table(
|
||||||
|
database_id=self.database_id,
|
||||||
|
table_id=table_id,
|
||||||
|
name='Combat Encounters'
|
||||||
|
)
|
||||||
|
logger.info("Combat encounters table created", table_id=table['$id'])
|
||||||
|
|
||||||
|
# Create columns
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='sessionId',
|
||||||
|
column_type='string',
|
||||||
|
size=255,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='userId',
|
||||||
|
column_type='string',
|
||||||
|
size=255,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='status',
|
||||||
|
column_type='string',
|
||||||
|
size=20,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='roundNumber',
|
||||||
|
column_type='integer',
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='currentTurnIndex',
|
||||||
|
column_type='integer',
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='turnOrder',
|
||||||
|
column_type='string',
|
||||||
|
size=2000, # JSON array of combatant IDs
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='combatantsData',
|
||||||
|
column_type='string',
|
||||||
|
size=65535, # Large text field for JSON combatant array
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='combatLog',
|
||||||
|
column_type='string',
|
||||||
|
size=65535, # Large text field for combat log
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='created_at',
|
||||||
|
column_type='string',
|
||||||
|
size=50, # ISO timestamp format
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='ended_at',
|
||||||
|
column_type='string',
|
||||||
|
size=50, # ISO timestamp format
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for columns to fully propagate
|
||||||
|
logger.info("Waiting for columns to propagate before creating indexes...")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Create indexes
|
||||||
|
self._create_index(
|
||||||
|
table_id=table_id,
|
||||||
|
index_id='idx_sessionId',
|
||||||
|
index_type='key',
|
||||||
|
attributes=['sessionId']
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_index(
|
||||||
|
table_id=table_id,
|
||||||
|
index_id='idx_userId_status',
|
||||||
|
index_type='key',
|
||||||
|
attributes=['userId', 'status']
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_index(
|
||||||
|
table_id=table_id,
|
||||||
|
index_id='idx_status_created_at',
|
||||||
|
index_type='key',
|
||||||
|
attributes=['status', 'created_at']
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Combat encounters table initialized successfully", table_id=table_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except AppwriteException as e:
|
||||||
|
logger.error("Failed to initialize combat_encounters table",
|
||||||
|
table_id=table_id,
|
||||||
|
error=str(e),
|
||||||
|
code=e.code)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def init_combat_rounds_table(self) -> bool:
|
||||||
|
"""
|
||||||
|
Initialize the combat_rounds table for storing per-round action history.
|
||||||
|
|
||||||
|
Table schema:
|
||||||
|
- encounterId (string, required): FK to combat_encounters
|
||||||
|
- sessionId (string, required): Denormalized for efficient queries
|
||||||
|
- roundNumber (integer, required): Round number (1-indexed)
|
||||||
|
- actionsData (string, required): JSON array of all actions in this round
|
||||||
|
- combatantStatesStart (string, required): JSON snapshot of combatant states at round start
|
||||||
|
- combatantStatesEnd (string, required): JSON snapshot of combatant states at round end
|
||||||
|
- created_at (string, required): ISO timestamp when round completed
|
||||||
|
|
||||||
|
Indexes:
|
||||||
|
- idx_encounterId: Encounter-based lookups
|
||||||
|
- idx_encounterId_roundNumber: Ordered retrieval of rounds
|
||||||
|
- idx_sessionId: Session-based queries
|
||||||
|
- idx_created_at: Time-based cleanup
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AppwriteException: If table creation fails
|
||||||
|
"""
|
||||||
|
table_id = 'combat_rounds'
|
||||||
|
|
||||||
|
logger.info("Initializing combat_rounds table", table_id=table_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if table already exists
|
||||||
|
try:
|
||||||
|
self.tables_db.get_table(
|
||||||
|
database_id=self.database_id,
|
||||||
|
table_id=table_id
|
||||||
|
)
|
||||||
|
logger.info("Combat rounds table already exists", table_id=table_id)
|
||||||
|
return True
|
||||||
|
except AppwriteException as e:
|
||||||
|
if e.code != 404:
|
||||||
|
raise
|
||||||
|
logger.info("Combat rounds table does not exist, creating...")
|
||||||
|
|
||||||
|
# Create table
|
||||||
|
logger.info("Creating combat_rounds table")
|
||||||
|
table = self.tables_db.create_table(
|
||||||
|
database_id=self.database_id,
|
||||||
|
table_id=table_id,
|
||||||
|
name='Combat Rounds'
|
||||||
|
)
|
||||||
|
logger.info("Combat rounds table created", table_id=table['$id'])
|
||||||
|
|
||||||
|
# Create columns
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='encounterId',
|
||||||
|
column_type='string',
|
||||||
|
size=36, # UUID format: enc_xxxxxxxxxxxx
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='sessionId',
|
||||||
|
column_type='string',
|
||||||
|
size=255,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='roundNumber',
|
||||||
|
column_type='integer',
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='actionsData',
|
||||||
|
column_type='string',
|
||||||
|
size=65535, # JSON array of action objects
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='combatantStatesStart',
|
||||||
|
column_type='string',
|
||||||
|
size=65535, # JSON snapshot of combatant states
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='combatantStatesEnd',
|
||||||
|
column_type='string',
|
||||||
|
size=65535, # JSON snapshot of combatant states
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_column(
|
||||||
|
table_id=table_id,
|
||||||
|
column_id='created_at',
|
||||||
|
column_type='string',
|
||||||
|
size=50, # ISO timestamp format
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for columns to fully propagate
|
||||||
|
logger.info("Waiting for columns to propagate before creating indexes...")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Create indexes
|
||||||
|
self._create_index(
|
||||||
|
table_id=table_id,
|
||||||
|
index_id='idx_encounterId',
|
||||||
|
index_type='key',
|
||||||
|
attributes=['encounterId']
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_index(
|
||||||
|
table_id=table_id,
|
||||||
|
index_id='idx_encounterId_roundNumber',
|
||||||
|
index_type='key',
|
||||||
|
attributes=['encounterId', 'roundNumber']
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_index(
|
||||||
|
table_id=table_id,
|
||||||
|
index_id='idx_sessionId',
|
||||||
|
index_type='key',
|
||||||
|
attributes=['sessionId']
|
||||||
|
)
|
||||||
|
|
||||||
|
self._create_index(
|
||||||
|
table_id=table_id,
|
||||||
|
index_id='idx_created_at',
|
||||||
|
index_type='key',
|
||||||
|
attributes=['created_at']
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Combat rounds table initialized successfully", table_id=table_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except AppwriteException as e:
|
||||||
|
logger.error("Failed to initialize combat_rounds table",
|
||||||
|
table_id=table_id,
|
||||||
|
error=str(e),
|
||||||
|
code=e.code)
|
||||||
|
raise
|
||||||
|
|
||||||
def _create_column(
|
def _create_column(
|
||||||
self,
|
self,
|
||||||
table_id: str,
|
table_id: str,
|
||||||
|
|||||||
308
api/app/services/encounter_generator.py
Normal file
308
api/app/services/encounter_generator.py
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
"""
|
||||||
|
Encounter Generator Service - Generate random combat encounters.
|
||||||
|
|
||||||
|
This service generates location-appropriate, level-scaled encounter groups
|
||||||
|
for the "Search for Monsters" feature. Players can select from generated
|
||||||
|
encounter options to initiate combat.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from app.models.enemy import EnemyTemplate, EnemyDifficulty
|
||||||
|
from app.services.enemy_loader import get_enemy_loader
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EncounterGroup:
|
||||||
|
"""
|
||||||
|
A generated encounter option for the player to choose.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
group_id: Unique identifier for this encounter option
|
||||||
|
enemies: List of enemy_ids that will spawn
|
||||||
|
enemy_names: Display names for the UI
|
||||||
|
display_name: Formatted display string (e.g., "3 Goblin Scouts")
|
||||||
|
challenge: Difficulty label ("Easy", "Medium", "Hard", "Boss")
|
||||||
|
total_xp: Total XP reward (not displayed to player, used internally)
|
||||||
|
"""
|
||||||
|
group_id: str
|
||||||
|
enemies: List[str] # List of enemy_ids
|
||||||
|
enemy_names: List[str] # Display names
|
||||||
|
display_name: str # Formatted for display
|
||||||
|
challenge: str # "Easy", "Medium", "Hard", "Boss"
|
||||||
|
total_xp: int # Internal tracking, not displayed
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize encounter group for API response."""
|
||||||
|
return {
|
||||||
|
"group_id": self.group_id,
|
||||||
|
"enemies": self.enemies,
|
||||||
|
"enemy_names": self.enemy_names,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"challenge": self.challenge,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EncounterGenerator:
|
||||||
|
"""
|
||||||
|
Generates random encounter groups for a given location and character level.
|
||||||
|
|
||||||
|
Encounter difficulty is determined by:
|
||||||
|
- Character level (higher level = more enemies, harder varieties)
|
||||||
|
- Location type (different monsters in different areas)
|
||||||
|
- Random variance (some encounters harder/easier than average)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the encounter generator."""
|
||||||
|
self.enemy_loader = get_enemy_loader()
|
||||||
|
|
||||||
|
def generate_encounters(
|
||||||
|
self,
|
||||||
|
location_type: str,
|
||||||
|
character_level: int,
|
||||||
|
num_encounters: int = 4
|
||||||
|
) -> List[EncounterGroup]:
|
||||||
|
"""
|
||||||
|
Generate multiple encounter options for the player to choose from.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location_type: Type of location (e.g., "forest", "town", "dungeon")
|
||||||
|
character_level: Current character level (1-20+)
|
||||||
|
num_encounters: Number of encounter options to generate (default 4)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of EncounterGroup options, each with different difficulty
|
||||||
|
"""
|
||||||
|
# Get enemies available at this location
|
||||||
|
available_enemies = self.enemy_loader.get_enemies_by_location(location_type)
|
||||||
|
|
||||||
|
if not available_enemies:
|
||||||
|
logger.warning(
|
||||||
|
"No enemies found for location",
|
||||||
|
location_type=location_type
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Generate a mix of difficulties
|
||||||
|
# Always try to include: 1 Easy, 1-2 Medium, 0-1 Hard
|
||||||
|
encounters = []
|
||||||
|
difficulty_mix = self._get_difficulty_mix(character_level, num_encounters)
|
||||||
|
|
||||||
|
for target_difficulty in difficulty_mix:
|
||||||
|
encounter = self._generate_single_encounter(
|
||||||
|
available_enemies=available_enemies,
|
||||||
|
character_level=character_level,
|
||||||
|
target_difficulty=target_difficulty
|
||||||
|
)
|
||||||
|
if encounter:
|
||||||
|
encounters.append(encounter)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Generated encounters",
|
||||||
|
location_type=location_type,
|
||||||
|
character_level=character_level,
|
||||||
|
num_encounters=len(encounters)
|
||||||
|
)
|
||||||
|
|
||||||
|
return encounters
|
||||||
|
|
||||||
|
def _get_difficulty_mix(
|
||||||
|
self,
|
||||||
|
character_level: int,
|
||||||
|
num_encounters: int
|
||||||
|
) -> List[str]:
|
||||||
|
"""
|
||||||
|
Determine the mix of encounter difficulties to generate.
|
||||||
|
|
||||||
|
Lower-level characters see more easy encounters.
|
||||||
|
Higher-level characters see more hard encounters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_level: Character's current level
|
||||||
|
num_encounters: Total encounters to generate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of target difficulty strings
|
||||||
|
"""
|
||||||
|
if character_level <= 2:
|
||||||
|
# Very low level: mostly easy
|
||||||
|
mix = ["Easy", "Easy", "Medium", "Easy"]
|
||||||
|
elif character_level <= 5:
|
||||||
|
# Low level: easy and medium
|
||||||
|
mix = ["Easy", "Medium", "Medium", "Hard"]
|
||||||
|
elif character_level <= 10:
|
||||||
|
# Mid level: balanced
|
||||||
|
mix = ["Easy", "Medium", "Hard", "Hard"]
|
||||||
|
else:
|
||||||
|
# High level: harder encounters
|
||||||
|
mix = ["Medium", "Hard", "Hard", "Boss"]
|
||||||
|
|
||||||
|
return mix[:num_encounters]
|
||||||
|
|
||||||
|
def _generate_single_encounter(
|
||||||
|
self,
|
||||||
|
available_enemies: List[EnemyTemplate],
|
||||||
|
character_level: int,
|
||||||
|
target_difficulty: str
|
||||||
|
) -> Optional[EncounterGroup]:
|
||||||
|
"""
|
||||||
|
Generate a single encounter group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
available_enemies: Pool of enemies to choose from
|
||||||
|
character_level: Character's level for scaling
|
||||||
|
target_difficulty: Target difficulty ("Easy", "Medium", "Hard", "Boss")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EncounterGroup or None if generation fails
|
||||||
|
"""
|
||||||
|
# Map target difficulty to enemy difficulty levels
|
||||||
|
difficulty_mapping = {
|
||||||
|
"Easy": [EnemyDifficulty.EASY],
|
||||||
|
"Medium": [EnemyDifficulty.EASY, EnemyDifficulty.MEDIUM],
|
||||||
|
"Hard": [EnemyDifficulty.MEDIUM, EnemyDifficulty.HARD],
|
||||||
|
"Boss": [EnemyDifficulty.HARD, EnemyDifficulty.BOSS],
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed_difficulties = difficulty_mapping.get(target_difficulty, [EnemyDifficulty.EASY])
|
||||||
|
|
||||||
|
# Filter enemies by difficulty
|
||||||
|
candidates = [
|
||||||
|
e for e in available_enemies
|
||||||
|
if e.difficulty in allowed_difficulties
|
||||||
|
]
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
# Fall back to any available enemy
|
||||||
|
candidates = available_enemies
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Determine enemy count based on difficulty and level
|
||||||
|
enemy_count = self._calculate_enemy_count(
|
||||||
|
target_difficulty=target_difficulty,
|
||||||
|
character_level=character_level
|
||||||
|
)
|
||||||
|
|
||||||
|
# Select enemies (allowing duplicates for packs)
|
||||||
|
selected_enemies = random.choices(candidates, k=enemy_count)
|
||||||
|
|
||||||
|
# Build encounter group
|
||||||
|
enemy_ids = [e.enemy_id for e in selected_enemies]
|
||||||
|
enemy_names = [e.name for e in selected_enemies]
|
||||||
|
total_xp = sum(e.experience_reward for e in selected_enemies)
|
||||||
|
|
||||||
|
# Create display name (e.g., "3 Goblin Scouts" or "2 Goblins, 1 Goblin Shaman")
|
||||||
|
display_name = self._format_display_name(enemy_names)
|
||||||
|
|
||||||
|
return EncounterGroup(
|
||||||
|
group_id=f"enc_{uuid.uuid4().hex[:8]}",
|
||||||
|
enemies=enemy_ids,
|
||||||
|
enemy_names=enemy_names,
|
||||||
|
display_name=display_name,
|
||||||
|
challenge=target_difficulty,
|
||||||
|
total_xp=total_xp
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calculate_enemy_count(
|
||||||
|
self,
|
||||||
|
target_difficulty: str,
|
||||||
|
character_level: int
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Calculate how many enemies should be in the encounter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_difficulty: Target difficulty level
|
||||||
|
character_level: Character's level
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of enemies to include
|
||||||
|
"""
|
||||||
|
# Base counts by difficulty
|
||||||
|
base_counts = {
|
||||||
|
"Easy": (1, 2), # 1-2 enemies
|
||||||
|
"Medium": (2, 3), # 2-3 enemies
|
||||||
|
"Hard": (2, 4), # 2-4 enemies
|
||||||
|
"Boss": (1, 3), # 1 boss + 0-2 adds
|
||||||
|
}
|
||||||
|
|
||||||
|
min_count, max_count = base_counts.get(target_difficulty, (1, 2))
|
||||||
|
|
||||||
|
# Scale slightly with level (higher level = can handle more)
|
||||||
|
level_bonus = min(character_level // 5, 2) # +1 enemy every 5 levels, max +2
|
||||||
|
max_count = min(max_count + level_bonus, 6) # Cap at 6 enemies
|
||||||
|
|
||||||
|
return random.randint(min_count, max_count)
|
||||||
|
|
||||||
|
def _format_display_name(self, enemy_names: List[str]) -> str:
|
||||||
|
"""
|
||||||
|
Format enemy names for display.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
["Goblin Scout"] -> "Goblin Scout"
|
||||||
|
["Goblin Scout", "Goblin Scout", "Goblin Scout"] -> "3 Goblin Scouts"
|
||||||
|
["Goblin Scout", "Goblin Shaman"] -> "Goblin Scout, Goblin Shaman"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enemy_names: List of enemy display names
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted display string
|
||||||
|
"""
|
||||||
|
if len(enemy_names) == 1:
|
||||||
|
return enemy_names[0]
|
||||||
|
|
||||||
|
# Count occurrences
|
||||||
|
counts = Counter(enemy_names)
|
||||||
|
|
||||||
|
if len(counts) == 1:
|
||||||
|
# All same enemy type
|
||||||
|
name = list(counts.keys())[0]
|
||||||
|
count = list(counts.values())[0]
|
||||||
|
# Simple pluralization
|
||||||
|
if count > 1:
|
||||||
|
if name.endswith('f'):
|
||||||
|
# wolf -> wolves
|
||||||
|
plural_name = name[:-1] + "ves"
|
||||||
|
elif name.endswith('s') or name.endswith('x') or name.endswith('ch'):
|
||||||
|
plural_name = name + "es"
|
||||||
|
else:
|
||||||
|
plural_name = name + "s"
|
||||||
|
return f"{count} {plural_name}"
|
||||||
|
return name
|
||||||
|
else:
|
||||||
|
# Mixed enemy types - list them
|
||||||
|
parts = []
|
||||||
|
for name, count in counts.items():
|
||||||
|
if count > 1:
|
||||||
|
parts.append(f"{count}x {name}")
|
||||||
|
else:
|
||||||
|
parts.append(name)
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
_generator_instance: Optional[EncounterGenerator] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_encounter_generator() -> EncounterGenerator:
|
||||||
|
"""
|
||||||
|
Get the global EncounterGenerator instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Singleton EncounterGenerator instance
|
||||||
|
"""
|
||||||
|
global _generator_instance
|
||||||
|
if _generator_instance is None:
|
||||||
|
_generator_instance = EncounterGenerator()
|
||||||
|
return _generator_instance
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user