feat/phase4-combat-foundation #8
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,3 +35,4 @@ Thumbs.db
|
|||||||
logs/
|
logs/
|
||||||
app/logs/
|
app/logs/
|
||||||
*.log
|
*.log
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
@@ -169,8 +169,17 @@ def register_blueprints(app: Flask) -> None:
|
|||||||
app.register_blueprint(chat_bp)
|
app.register_blueprint(chat_bp)
|
||||||
logger.info("Chat API blueprint registered")
|
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')
|
||||||
|
|||||||
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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
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
|
||||||
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
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -92,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)
|
||||||
|
|
||||||
@@ -100,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():
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1074,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}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
@@ -106,6 +106,24 @@ class DatabaseInitService:
|
|||||||
logger.error("Failed to initialize chat_messages table", error=str(e))
|
logger.error("Failed to initialize chat_messages table", error=str(e))
|
||||||
results['chat_messages'] = False
|
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)
|
||||||
|
|
||||||
@@ -746,6 +764,326 @@ class DatabaseInitService:
|
|||||||
code=e.code)
|
code=e.code)
|
||||||
raise
|
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
|
||||||
300
api/app/services/enemy_loader.py
Normal file
300
api/app/services/enemy_loader.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""
|
||||||
|
Enemy Loader Service - YAML-based enemy template loading.
|
||||||
|
|
||||||
|
This service loads enemy definitions from YAML files, providing a data-driven
|
||||||
|
approach to defining monsters and enemies for combat encounters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.models.enemy import EnemyTemplate, EnemyDifficulty
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
class EnemyLoader:
|
||||||
|
"""
|
||||||
|
Loads enemy templates from YAML configuration files.
|
||||||
|
|
||||||
|
This allows game designers to define enemies without touching code.
|
||||||
|
Enemy files are organized by difficulty in subdirectories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, data_dir: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Initialize the enemy loader.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_dir: Path to directory containing enemy YAML files
|
||||||
|
Defaults to /app/data/enemies/
|
||||||
|
"""
|
||||||
|
if data_dir is None:
|
||||||
|
# Default to app/data/enemies relative to this file
|
||||||
|
current_file = Path(__file__)
|
||||||
|
app_dir = current_file.parent.parent # Go up to /app
|
||||||
|
data_dir = str(app_dir / "data" / "enemies")
|
||||||
|
|
||||||
|
self.data_dir = Path(data_dir)
|
||||||
|
self._enemy_cache: Dict[str, EnemyTemplate] = {}
|
||||||
|
self._loaded = False
|
||||||
|
|
||||||
|
logger.info("EnemyLoader initialized", data_dir=str(self.data_dir))
|
||||||
|
|
||||||
|
def load_enemy(self, enemy_id: str) -> Optional[EnemyTemplate]:
|
||||||
|
"""
|
||||||
|
Load a single enemy template by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enemy_id: Unique enemy identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EnemyTemplate instance or None if not found
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
if enemy_id in self._enemy_cache:
|
||||||
|
return self._enemy_cache[enemy_id]
|
||||||
|
|
||||||
|
# If not cached, try loading all enemies first
|
||||||
|
if not self._loaded:
|
||||||
|
self.load_all_enemies()
|
||||||
|
if enemy_id in self._enemy_cache:
|
||||||
|
return self._enemy_cache[enemy_id]
|
||||||
|
|
||||||
|
# Try loading from specific YAML file
|
||||||
|
yaml_file = self.data_dir / f"{enemy_id}.yaml"
|
||||||
|
if yaml_file.exists():
|
||||||
|
return self._load_from_file(yaml_file)
|
||||||
|
|
||||||
|
# Search in subdirectories
|
||||||
|
for subdir in self.data_dir.iterdir():
|
||||||
|
if subdir.is_dir():
|
||||||
|
yaml_file = subdir / f"{enemy_id}.yaml"
|
||||||
|
if yaml_file.exists():
|
||||||
|
return self._load_from_file(yaml_file)
|
||||||
|
|
||||||
|
logger.warning("Enemy not found", enemy_id=enemy_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _load_from_file(self, yaml_file: Path) -> Optional[EnemyTemplate]:
|
||||||
|
"""
|
||||||
|
Load an enemy template from a specific YAML file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
yaml_file: Path to the YAML file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EnemyTemplate instance or None on error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(yaml_file, 'r') as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
enemy = EnemyTemplate.from_dict(data)
|
||||||
|
self._enemy_cache[enemy.enemy_id] = enemy
|
||||||
|
|
||||||
|
logger.debug("Enemy loaded", enemy_id=enemy.enemy_id, file=str(yaml_file))
|
||||||
|
return enemy
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to load enemy file",
|
||||||
|
file=str(yaml_file),
|
||||||
|
error=str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def load_all_enemies(self) -> Dict[str, EnemyTemplate]:
|
||||||
|
"""
|
||||||
|
Load all enemy templates from the data directory.
|
||||||
|
|
||||||
|
Searches both the root directory and subdirectories for YAML files.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping enemy_id to EnemyTemplate instance
|
||||||
|
"""
|
||||||
|
if not self.data_dir.exists():
|
||||||
|
logger.warning("Enemy data directory not found", path=str(self.data_dir))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
enemies = {}
|
||||||
|
|
||||||
|
# Load from root directory
|
||||||
|
for yaml_file in self.data_dir.glob("*.yaml"):
|
||||||
|
enemy = self._load_from_file(yaml_file)
|
||||||
|
if enemy:
|
||||||
|
enemies[enemy.enemy_id] = enemy
|
||||||
|
|
||||||
|
# Load from subdirectories (organized by difficulty)
|
||||||
|
for subdir in self.data_dir.iterdir():
|
||||||
|
if subdir.is_dir():
|
||||||
|
for yaml_file in subdir.glob("*.yaml"):
|
||||||
|
enemy = self._load_from_file(yaml_file)
|
||||||
|
if enemy:
|
||||||
|
enemies[enemy.enemy_id] = enemy
|
||||||
|
|
||||||
|
self._loaded = True
|
||||||
|
logger.info("All enemies loaded", count=len(enemies))
|
||||||
|
|
||||||
|
return enemies
|
||||||
|
|
||||||
|
def get_enemies_by_difficulty(
|
||||||
|
self,
|
||||||
|
difficulty: EnemyDifficulty
|
||||||
|
) -> List[EnemyTemplate]:
|
||||||
|
"""
|
||||||
|
Get all enemies matching a difficulty level.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
difficulty: Difficulty level to filter by
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of EnemyTemplate instances
|
||||||
|
"""
|
||||||
|
if not self._loaded:
|
||||||
|
self.load_all_enemies()
|
||||||
|
|
||||||
|
return [
|
||||||
|
enemy for enemy in self._enemy_cache.values()
|
||||||
|
if enemy.difficulty == difficulty
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_enemies_by_tag(self, tag: str) -> List[EnemyTemplate]:
|
||||||
|
"""
|
||||||
|
Get all enemies with a specific tag.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tag: Tag to filter by (e.g., "undead", "beast", "humanoid")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of EnemyTemplate instances with that tag
|
||||||
|
"""
|
||||||
|
if not self._loaded:
|
||||||
|
self.load_all_enemies()
|
||||||
|
|
||||||
|
return [
|
||||||
|
enemy for enemy in self._enemy_cache.values()
|
||||||
|
if enemy.has_tag(tag)
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_enemies_by_location(
|
||||||
|
self,
|
||||||
|
location_type: str,
|
||||||
|
difficulty: Optional[EnemyDifficulty] = None
|
||||||
|
) -> List[EnemyTemplate]:
|
||||||
|
"""
|
||||||
|
Get all enemies that can appear at a specific location type.
|
||||||
|
|
||||||
|
This is used by the encounter generator to find location-appropriate
|
||||||
|
enemies for random encounters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location_type: Location type to filter by (e.g., "forest", "dungeon",
|
||||||
|
"town", "wilderness", "crypt", "ruins", "road")
|
||||||
|
difficulty: Optional difficulty filter to narrow results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of EnemyTemplate instances that can appear at the location
|
||||||
|
"""
|
||||||
|
if not self._loaded:
|
||||||
|
self.load_all_enemies()
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
enemy for enemy in self._enemy_cache.values()
|
||||||
|
if enemy.has_location_tag(location_type)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Apply difficulty filter if specified
|
||||||
|
if difficulty is not None:
|
||||||
|
candidates = [e for e in candidates if e.difficulty == difficulty]
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Enemies found for location",
|
||||||
|
location_type=location_type,
|
||||||
|
difficulty=difficulty.value if difficulty else None,
|
||||||
|
count=len(candidates)
|
||||||
|
)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def get_random_enemies(
|
||||||
|
self,
|
||||||
|
count: int = 1,
|
||||||
|
difficulty: Optional[EnemyDifficulty] = None,
|
||||||
|
tag: Optional[str] = None,
|
||||||
|
exclude_bosses: bool = True
|
||||||
|
) -> List[EnemyTemplate]:
|
||||||
|
"""
|
||||||
|
Get random enemies for encounter generation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count: Number of enemies to select
|
||||||
|
difficulty: Optional difficulty filter
|
||||||
|
tag: Optional tag filter
|
||||||
|
exclude_bosses: Whether to exclude boss enemies
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of randomly selected EnemyTemplate instances
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
|
||||||
|
if not self._loaded:
|
||||||
|
self.load_all_enemies()
|
||||||
|
|
||||||
|
# Build candidate list
|
||||||
|
candidates = list(self._enemy_cache.values())
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if difficulty:
|
||||||
|
candidates = [e for e in candidates if e.difficulty == difficulty]
|
||||||
|
if tag:
|
||||||
|
candidates = [e for e in candidates if e.has_tag(tag)]
|
||||||
|
if exclude_bosses:
|
||||||
|
candidates = [e for e in candidates if not e.is_boss()]
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
logger.warning("No enemies match filters",
|
||||||
|
difficulty=difficulty.value if difficulty else None,
|
||||||
|
tag=tag)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Select random enemies (with replacement if needed)
|
||||||
|
if len(candidates) >= count:
|
||||||
|
return random.sample(candidates, count)
|
||||||
|
else:
|
||||||
|
# Not enough unique enemies, allow duplicates
|
||||||
|
return random.choices(candidates, k=count)
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Clear the enemy cache, forcing reload on next access."""
|
||||||
|
self._enemy_cache.clear()
|
||||||
|
self._loaded = False
|
||||||
|
logger.debug("Enemy cache cleared")
|
||||||
|
|
||||||
|
def get_all_cached(self) -> Dict[str, EnemyTemplate]:
|
||||||
|
"""
|
||||||
|
Get all cached enemies.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of cached enemy templates
|
||||||
|
"""
|
||||||
|
if not self._loaded:
|
||||||
|
self.load_all_enemies()
|
||||||
|
return self._enemy_cache.copy()
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance for convenience
|
||||||
|
_loader_instance: Optional[EnemyLoader] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_enemy_loader() -> EnemyLoader:
|
||||||
|
"""
|
||||||
|
Get the global EnemyLoader instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Singleton EnemyLoader instance
|
||||||
|
"""
|
||||||
|
global _loader_instance
|
||||||
|
if _loader_instance is None:
|
||||||
|
_loader_instance = EnemyLoader()
|
||||||
|
return _loader_instance
|
||||||
867
api/app/services/inventory_service.py
Normal file
867
api/app/services/inventory_service.py
Normal file
@@ -0,0 +1,867 @@
|
|||||||
|
"""
|
||||||
|
Inventory Service - Manages character inventory, equipment, and consumable usage.
|
||||||
|
|
||||||
|
This service provides an orchestration layer on top of the Character model's
|
||||||
|
inventory methods, adding:
|
||||||
|
- Input validation and error handling
|
||||||
|
- Equipment slot validation (weapon vs armor slots)
|
||||||
|
- Level and class requirement checks
|
||||||
|
- Consumable effect application
|
||||||
|
- Integration with CharacterService for persistence
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from app.services.inventory_service import get_inventory_service
|
||||||
|
|
||||||
|
inventory_service = get_inventory_service()
|
||||||
|
inventory_service.equip_item(character, item, "weapon", user_id)
|
||||||
|
inventory_service.use_consumable(character, "health_potion_small", user_id)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional, Dict, Any, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.models.character import Character
|
||||||
|
from app.models.items import Item
|
||||||
|
from app.models.effects import Effect
|
||||||
|
from app.models.enums import ItemType, EffectType
|
||||||
|
from app.services.character_service import get_character_service, CharacterService
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Custom Exceptions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class InventoryError(Exception):
|
||||||
|
"""Base exception for inventory operations."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ItemNotFoundError(InventoryError):
|
||||||
|
"""Raised when an item is not found in the character's inventory."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CannotEquipError(InventoryError):
|
||||||
|
"""Raised when an item cannot be equipped (wrong slot, level requirement, etc.)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidSlotError(InventoryError):
|
||||||
|
"""Raised when an invalid equipment slot is specified."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CannotUseItemError(InventoryError):
|
||||||
|
"""Raised when an item cannot be used (not consumable, etc.)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryFullError(InventoryError):
|
||||||
|
"""Raised when inventory capacity is exceeded."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Equipment Slot Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Valid equipment slots in the game
|
||||||
|
VALID_SLOTS = {
|
||||||
|
"weapon", # Primary weapon
|
||||||
|
"off_hand", # Shield or secondary weapon
|
||||||
|
"helmet", # Head armor
|
||||||
|
"chest", # Chest armor
|
||||||
|
"gloves", # Hand armor
|
||||||
|
"boots", # Foot armor
|
||||||
|
"accessory_1", # Ring, amulet, etc.
|
||||||
|
"accessory_2", # Secondary accessory
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map item types to allowed slots
|
||||||
|
ITEM_TYPE_SLOTS = {
|
||||||
|
ItemType.WEAPON: {"weapon", "off_hand"},
|
||||||
|
ItemType.ARMOR: {"helmet", "chest", "gloves", "boots"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Maximum inventory size (0 = unlimited)
|
||||||
|
MAX_INVENTORY_SIZE = 100
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Consumable Effect Result
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConsumableResult:
|
||||||
|
"""Result of using a consumable item."""
|
||||||
|
|
||||||
|
item_name: str
|
||||||
|
effects_applied: List[Dict[str, Any]]
|
||||||
|
hp_restored: int = 0
|
||||||
|
mp_restored: int = 0
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize to dictionary for API response."""
|
||||||
|
return {
|
||||||
|
"item_name": self.item_name,
|
||||||
|
"effects_applied": self.effects_applied,
|
||||||
|
"hp_restored": self.hp_restored,
|
||||||
|
"mp_restored": self.mp_restored,
|
||||||
|
"message": self.message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Inventory Service
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class InventoryService:
|
||||||
|
"""
|
||||||
|
Service for managing character inventory and equipment.
|
||||||
|
|
||||||
|
This service wraps the Character model's inventory methods with additional
|
||||||
|
validation, error handling, and persistence integration.
|
||||||
|
|
||||||
|
All methods that modify state will persist changes via CharacterService.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, character_service: Optional[CharacterService] = None):
|
||||||
|
"""
|
||||||
|
Initialize inventory service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_service: Optional CharacterService instance (uses global if not provided)
|
||||||
|
"""
|
||||||
|
self._character_service = character_service
|
||||||
|
logger.info("InventoryService initialized")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def character_service(self) -> CharacterService:
|
||||||
|
"""Get CharacterService instance (lazy-loaded)."""
|
||||||
|
if self._character_service is None:
|
||||||
|
self._character_service = get_character_service()
|
||||||
|
return self._character_service
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Read Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def get_inventory(self, character: Character) -> List[Item]:
|
||||||
|
"""
|
||||||
|
Get all items in character's inventory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Item objects in inventory
|
||||||
|
"""
|
||||||
|
return list(character.inventory)
|
||||||
|
|
||||||
|
def get_equipped_items(self, character: Character) -> Dict[str, Item]:
|
||||||
|
"""
|
||||||
|
Get all equipped items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping slot names to equipped Item objects
|
||||||
|
"""
|
||||||
|
return dict(character.equipped)
|
||||||
|
|
||||||
|
def get_item_by_id(self, character: Character, item_id: str) -> Optional[Item]:
|
||||||
|
"""
|
||||||
|
Find an item in inventory by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
item_id: Item ID to find
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Item if found, None otherwise
|
||||||
|
"""
|
||||||
|
for item in character.inventory:
|
||||||
|
if item.item_id == item_id:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_equipped_item(self, character: Character, slot: str) -> Optional[Item]:
|
||||||
|
"""
|
||||||
|
Get the item equipped in a specific slot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
slot: Equipment slot name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Item if slot is occupied, None otherwise
|
||||||
|
"""
|
||||||
|
return character.equipped.get(slot)
|
||||||
|
|
||||||
|
def get_inventory_count(self, character: Character) -> int:
|
||||||
|
"""
|
||||||
|
Get the number of items in inventory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of items in inventory
|
||||||
|
"""
|
||||||
|
return len(character.inventory)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Add/Remove Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def add_item(
|
||||||
|
self,
|
||||||
|
character: Character,
|
||||||
|
item: Item,
|
||||||
|
user_id: str,
|
||||||
|
save: bool = True
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Add an item to character's inventory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
item: Item to add
|
||||||
|
user_id: User ID for persistence authorization
|
||||||
|
save: Whether to persist changes (default True)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InventoryFullError: If inventory is at maximum capacity
|
||||||
|
"""
|
||||||
|
# Check inventory capacity
|
||||||
|
if MAX_INVENTORY_SIZE > 0 and len(character.inventory) >= MAX_INVENTORY_SIZE:
|
||||||
|
raise InventoryFullError(
|
||||||
|
f"Inventory is full ({MAX_INVENTORY_SIZE} items max)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add to inventory
|
||||||
|
character.add_item(item)
|
||||||
|
|
||||||
|
logger.info("Item added to inventory",
|
||||||
|
character_id=character.character_id,
|
||||||
|
item_id=item.item_id,
|
||||||
|
item_name=item.get_display_name())
|
||||||
|
|
||||||
|
# Persist changes
|
||||||
|
if save:
|
||||||
|
self.character_service.update_character(character, user_id)
|
||||||
|
|
||||||
|
def remove_item(
|
||||||
|
self,
|
||||||
|
character: Character,
|
||||||
|
item_id: str,
|
||||||
|
user_id: str,
|
||||||
|
save: bool = True
|
||||||
|
) -> Item:
|
||||||
|
"""
|
||||||
|
Remove an item from character's inventory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
item_id: ID of item to remove
|
||||||
|
user_id: User ID for persistence authorization
|
||||||
|
save: Whether to persist changes (default True)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The removed Item
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ItemNotFoundError: If item is not in inventory
|
||||||
|
"""
|
||||||
|
# Find item first (for better error message)
|
||||||
|
item = self.get_item_by_id(character, item_id)
|
||||||
|
if item is None:
|
||||||
|
raise ItemNotFoundError(f"Item not found in inventory: {item_id}")
|
||||||
|
|
||||||
|
# Remove from inventory
|
||||||
|
removed_item = character.remove_item(item_id)
|
||||||
|
|
||||||
|
logger.info("Item removed from inventory",
|
||||||
|
character_id=character.character_id,
|
||||||
|
item_id=item_id,
|
||||||
|
item_name=item.get_display_name())
|
||||||
|
|
||||||
|
# Persist changes
|
||||||
|
if save:
|
||||||
|
self.character_service.update_character(character, user_id)
|
||||||
|
|
||||||
|
return removed_item
|
||||||
|
|
||||||
|
def drop_item(
|
||||||
|
self,
|
||||||
|
character: Character,
|
||||||
|
item_id: str,
|
||||||
|
user_id: str
|
||||||
|
) -> Item:
|
||||||
|
"""
|
||||||
|
Drop an item (remove permanently with no return).
|
||||||
|
|
||||||
|
This is an alias for remove_item, but semantically indicates
|
||||||
|
the item is being discarded rather than transferred.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
item_id: ID of item to drop
|
||||||
|
user_id: User ID for persistence authorization
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The dropped Item (for logging/notification purposes)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ItemNotFoundError: If item is not in inventory
|
||||||
|
"""
|
||||||
|
return self.remove_item(character, item_id, user_id, save=True)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Equipment Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def equip_item(
|
||||||
|
self,
|
||||||
|
character: Character,
|
||||||
|
item_id: str,
|
||||||
|
slot: str,
|
||||||
|
user_id: str
|
||||||
|
) -> Optional[Item]:
|
||||||
|
"""
|
||||||
|
Equip an item from inventory to a specific slot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
item_id: ID of item to equip (must be in inventory)
|
||||||
|
slot: Equipment slot to use
|
||||||
|
user_id: User ID for persistence authorization
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Previously equipped item in that slot (or None)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ItemNotFoundError: If item is not in inventory
|
||||||
|
InvalidSlotError: If slot name is invalid
|
||||||
|
CannotEquipError: If item cannot be equipped (wrong type, level, etc.)
|
||||||
|
"""
|
||||||
|
# Validate slot
|
||||||
|
if slot not in VALID_SLOTS:
|
||||||
|
raise InvalidSlotError(
|
||||||
|
f"Invalid equipment slot: '{slot}'. "
|
||||||
|
f"Valid slots: {', '.join(sorted(VALID_SLOTS))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find item in inventory
|
||||||
|
item = self.get_item_by_id(character, item_id)
|
||||||
|
if item is None:
|
||||||
|
raise ItemNotFoundError(f"Item not found in inventory: {item_id}")
|
||||||
|
|
||||||
|
# Validate item can be equipped
|
||||||
|
self._validate_equip(character, item, slot)
|
||||||
|
|
||||||
|
# Perform equip (Character.equip_item handles inventory management)
|
||||||
|
previous_item = character.equip_item(item, slot)
|
||||||
|
|
||||||
|
logger.info("Item equipped",
|
||||||
|
character_id=character.character_id,
|
||||||
|
item_id=item_id,
|
||||||
|
slot=slot,
|
||||||
|
previous_item=previous_item.item_id if previous_item else None)
|
||||||
|
|
||||||
|
# Persist changes
|
||||||
|
self.character_service.update_character(character, user_id)
|
||||||
|
|
||||||
|
return previous_item
|
||||||
|
|
||||||
|
def unequip_item(
|
||||||
|
self,
|
||||||
|
character: Character,
|
||||||
|
slot: str,
|
||||||
|
user_id: str
|
||||||
|
) -> Optional[Item]:
|
||||||
|
"""
|
||||||
|
Unequip an item from a specific slot (returns to inventory).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
slot: Equipment slot to unequip from
|
||||||
|
user_id: User ID for persistence authorization
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The unequipped Item (or None if slot was empty)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidSlotError: If slot name is invalid
|
||||||
|
InventoryFullError: If inventory is full and cannot receive the item
|
||||||
|
"""
|
||||||
|
# Validate slot
|
||||||
|
if slot not in VALID_SLOTS:
|
||||||
|
raise InvalidSlotError(
|
||||||
|
f"Invalid equipment slot: '{slot}'. "
|
||||||
|
f"Valid slots: {', '.join(sorted(VALID_SLOTS))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if slot has an item
|
||||||
|
equipped_item = character.equipped.get(slot)
|
||||||
|
if equipped_item is None:
|
||||||
|
logger.debug("Unequip from empty slot",
|
||||||
|
character_id=character.character_id,
|
||||||
|
slot=slot)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check inventory capacity (item will return to inventory)
|
||||||
|
if MAX_INVENTORY_SIZE > 0 and len(character.inventory) >= MAX_INVENTORY_SIZE:
|
||||||
|
raise InventoryFullError(
|
||||||
|
"Cannot unequip: inventory is full"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perform unequip (Character.unequip_item handles inventory management)
|
||||||
|
unequipped_item = character.unequip_item(slot)
|
||||||
|
|
||||||
|
logger.info("Item unequipped",
|
||||||
|
character_id=character.character_id,
|
||||||
|
item_id=unequipped_item.item_id if unequipped_item else None,
|
||||||
|
slot=slot)
|
||||||
|
|
||||||
|
# Persist changes
|
||||||
|
self.character_service.update_character(character, user_id)
|
||||||
|
|
||||||
|
return unequipped_item
|
||||||
|
|
||||||
|
def swap_equipment(
|
||||||
|
self,
|
||||||
|
character: Character,
|
||||||
|
item_id: str,
|
||||||
|
slot: str,
|
||||||
|
user_id: str
|
||||||
|
) -> Optional[Item]:
|
||||||
|
"""
|
||||||
|
Swap equipment: equip item and return the previous item.
|
||||||
|
|
||||||
|
This is semantically the same as equip_item but makes the swap
|
||||||
|
intention explicit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
item_id: ID of item to equip
|
||||||
|
slot: Equipment slot to use
|
||||||
|
user_id: User ID for persistence authorization
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Previously equipped item (or None)
|
||||||
|
"""
|
||||||
|
return self.equip_item(character, item_id, slot, user_id)
|
||||||
|
|
||||||
|
def _validate_equip(self, character: Character, item: Item, slot: str) -> None:
|
||||||
|
"""
|
||||||
|
Validate that an item can be equipped to a slot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
item: Item to validate
|
||||||
|
slot: Target equipment slot
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CannotEquipError: If item cannot be equipped
|
||||||
|
"""
|
||||||
|
# Check item type is equippable
|
||||||
|
if item.item_type not in ITEM_TYPE_SLOTS:
|
||||||
|
raise CannotEquipError(
|
||||||
|
f"Cannot equip {item.item_type.value} items. "
|
||||||
|
f"Only weapons and armor can be equipped."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check slot matches item type
|
||||||
|
allowed_slots = ITEM_TYPE_SLOTS[item.item_type]
|
||||||
|
if slot not in allowed_slots:
|
||||||
|
raise CannotEquipError(
|
||||||
|
f"Cannot equip {item.item_type.value} to '{slot}' slot. "
|
||||||
|
f"Allowed slots: {', '.join(sorted(allowed_slots))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check level requirement
|
||||||
|
if not item.can_equip(character.level, character.class_id):
|
||||||
|
if character.level < item.required_level:
|
||||||
|
raise CannotEquipError(
|
||||||
|
f"Cannot equip '{item.get_display_name()}': "
|
||||||
|
f"requires level {item.required_level} (you are level {character.level})"
|
||||||
|
)
|
||||||
|
if item.required_class and item.required_class != character.class_id:
|
||||||
|
raise CannotEquipError(
|
||||||
|
f"Cannot equip '{item.get_display_name()}': "
|
||||||
|
f"requires class '{item.required_class}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Consumable Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def use_consumable(
|
||||||
|
self,
|
||||||
|
character: Character,
|
||||||
|
item_id: str,
|
||||||
|
user_id: str,
|
||||||
|
current_hp: Optional[int] = None,
|
||||||
|
max_hp: Optional[int] = None,
|
||||||
|
current_mp: Optional[int] = None,
|
||||||
|
max_mp: Optional[int] = None
|
||||||
|
) -> ConsumableResult:
|
||||||
|
"""
|
||||||
|
Use a consumable item and apply its effects.
|
||||||
|
|
||||||
|
For HP/MP restoration effects, provide current/max values to calculate
|
||||||
|
actual restoration (clamped to max). If not provided, uses character's
|
||||||
|
computed max_hp from stats.
|
||||||
|
|
||||||
|
Note: Outside of combat, characters are always at full HP. During combat,
|
||||||
|
HP tracking is handled by the combat system and current_hp should be passed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
item_id: ID of consumable to use
|
||||||
|
user_id: User ID for persistence authorization
|
||||||
|
current_hp: Current HP (for healing calculations)
|
||||||
|
max_hp: Maximum HP (for healing cap)
|
||||||
|
current_mp: Current MP (for mana restore calculations)
|
||||||
|
max_mp: Maximum MP (for mana restore cap)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ConsumableResult with details of effects applied
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ItemNotFoundError: If item is not in inventory
|
||||||
|
CannotUseItemError: If item is not a consumable
|
||||||
|
"""
|
||||||
|
# Find item in inventory
|
||||||
|
item = self.get_item_by_id(character, item_id)
|
||||||
|
if item is None:
|
||||||
|
raise ItemNotFoundError(f"Item not found in inventory: {item_id}")
|
||||||
|
|
||||||
|
# Validate item is consumable
|
||||||
|
if not item.is_consumable():
|
||||||
|
raise CannotUseItemError(
|
||||||
|
f"Cannot use '{item.get_display_name()}': not a consumable item"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use character computed values if not provided
|
||||||
|
if max_hp is None:
|
||||||
|
max_hp = character.max_hp
|
||||||
|
if current_hp is None:
|
||||||
|
current_hp = max_hp # Outside combat, assume full HP
|
||||||
|
|
||||||
|
# MP handling (if character has MP system)
|
||||||
|
effective_stats = character.get_effective_stats()
|
||||||
|
if max_mp is None:
|
||||||
|
max_mp = getattr(effective_stats, 'magic_points', 100)
|
||||||
|
if current_mp is None:
|
||||||
|
current_mp = max_mp # Outside combat, assume full MP
|
||||||
|
|
||||||
|
# Apply effects
|
||||||
|
result = self._apply_consumable_effects(
|
||||||
|
item, current_hp, max_hp, current_mp, max_mp
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove consumable from inventory (it's used up)
|
||||||
|
character.remove_item(item_id)
|
||||||
|
|
||||||
|
logger.info("Consumable used",
|
||||||
|
character_id=character.character_id,
|
||||||
|
item_id=item_id,
|
||||||
|
item_name=item.get_display_name(),
|
||||||
|
hp_restored=result.hp_restored,
|
||||||
|
mp_restored=result.mp_restored)
|
||||||
|
|
||||||
|
# Persist changes
|
||||||
|
self.character_service.update_character(character, user_id)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _apply_consumable_effects(
|
||||||
|
self,
|
||||||
|
item: Item,
|
||||||
|
current_hp: int,
|
||||||
|
max_hp: int,
|
||||||
|
current_mp: int,
|
||||||
|
max_mp: int
|
||||||
|
) -> ConsumableResult:
|
||||||
|
"""
|
||||||
|
Apply consumable effects and calculate results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item: Consumable item
|
||||||
|
current_hp: Current HP
|
||||||
|
max_hp: Maximum HP
|
||||||
|
current_mp: Current MP
|
||||||
|
max_mp: Maximum MP
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ConsumableResult with effect details
|
||||||
|
"""
|
||||||
|
effects_applied = []
|
||||||
|
total_hp_restored = 0
|
||||||
|
total_mp_restored = 0
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
for effect in item.effects_on_use:
|
||||||
|
effect_result = {
|
||||||
|
"effect_name": effect.name,
|
||||||
|
"effect_type": effect.effect_type.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if effect.effect_type == EffectType.HOT:
|
||||||
|
# Instant heal (for potions, treat HOT as instant outside combat)
|
||||||
|
heal_amount = effect.power * effect.stacks
|
||||||
|
actual_heal = min(heal_amount, max_hp - current_hp)
|
||||||
|
current_hp += actual_heal
|
||||||
|
total_hp_restored += actual_heal
|
||||||
|
|
||||||
|
effect_result["value"] = actual_heal
|
||||||
|
effect_result["message"] = f"Restored {actual_heal} HP"
|
||||||
|
messages.append(f"Restored {actual_heal} HP")
|
||||||
|
|
||||||
|
elif effect.effect_type == EffectType.BUFF:
|
||||||
|
# Stat buff - would be applied in combat context
|
||||||
|
stat_name = effect.stat_affected.value if effect.stat_affected else "unknown"
|
||||||
|
effect_result["stat_affected"] = stat_name
|
||||||
|
effect_result["modifier"] = effect.power
|
||||||
|
effect_result["duration"] = effect.duration
|
||||||
|
effect_result["message"] = f"+{effect.power} {stat_name} for {effect.duration} turns"
|
||||||
|
messages.append(f"+{effect.power} {stat_name}")
|
||||||
|
|
||||||
|
elif effect.effect_type == EffectType.SHIELD:
|
||||||
|
# Apply shield effect
|
||||||
|
shield_power = effect.power * effect.stacks
|
||||||
|
effect_result["shield_power"] = shield_power
|
||||||
|
effect_result["duration"] = effect.duration
|
||||||
|
effect_result["message"] = f"Shield for {shield_power} damage"
|
||||||
|
messages.append(f"Shield: {shield_power}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Other effect types (DOT, DEBUFF, STUN - unusual for consumables)
|
||||||
|
effect_result["power"] = effect.power
|
||||||
|
effect_result["duration"] = effect.duration
|
||||||
|
effect_result["message"] = f"{effect.name} applied"
|
||||||
|
|
||||||
|
effects_applied.append(effect_result)
|
||||||
|
|
||||||
|
# Build summary message
|
||||||
|
summary = f"Used {item.get_display_name()}"
|
||||||
|
if messages:
|
||||||
|
summary += f": {', '.join(messages)}"
|
||||||
|
|
||||||
|
return ConsumableResult(
|
||||||
|
item_name=item.get_display_name(),
|
||||||
|
effects_applied=effects_applied,
|
||||||
|
hp_restored=total_hp_restored,
|
||||||
|
mp_restored=total_mp_restored,
|
||||||
|
message=summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
def use_consumable_in_combat(
|
||||||
|
self,
|
||||||
|
character: Character,
|
||||||
|
item_id: str,
|
||||||
|
user_id: str,
|
||||||
|
current_hp: int,
|
||||||
|
max_hp: int,
|
||||||
|
current_mp: int = 0,
|
||||||
|
max_mp: int = 0
|
||||||
|
) -> Tuple[ConsumableResult, List[Effect]]:
|
||||||
|
"""
|
||||||
|
Use a consumable during combat.
|
||||||
|
|
||||||
|
Returns both the result summary and a list of Effect objects that
|
||||||
|
should be applied to the combatant for duration-based effects.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
item_id: ID of consumable to use
|
||||||
|
user_id: User ID for persistence authorization
|
||||||
|
current_hp: Current combat HP
|
||||||
|
max_hp: Maximum combat HP
|
||||||
|
current_mp: Current combat MP
|
||||||
|
max_mp: Maximum combat MP
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (ConsumableResult, List[Effect]) for combat system
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ItemNotFoundError: If item not in inventory
|
||||||
|
CannotUseItemError: If item is not consumable
|
||||||
|
"""
|
||||||
|
# Find item
|
||||||
|
item = self.get_item_by_id(character, item_id)
|
||||||
|
if item is None:
|
||||||
|
raise ItemNotFoundError(f"Item not found in inventory: {item_id}")
|
||||||
|
|
||||||
|
if not item.is_consumable():
|
||||||
|
raise CannotUseItemError(
|
||||||
|
f"Cannot use '{item.get_display_name()}': not a consumable"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Separate instant effects from duration effects
|
||||||
|
instant_effects = []
|
||||||
|
duration_effects = []
|
||||||
|
|
||||||
|
for effect in item.effects_on_use:
|
||||||
|
# HOT effects in combat should tick, not instant heal
|
||||||
|
if effect.duration > 1 or effect.effect_type in [
|
||||||
|
EffectType.BUFF, EffectType.DEBUFF, EffectType.DOT,
|
||||||
|
EffectType.HOT, EffectType.SHIELD, EffectType.STUN
|
||||||
|
]:
|
||||||
|
# Copy effect for combat tracking
|
||||||
|
combat_effect = Effect(
|
||||||
|
effect_id=f"{item.item_id}_{effect.effect_id}",
|
||||||
|
name=effect.name,
|
||||||
|
effect_type=effect.effect_type,
|
||||||
|
duration=effect.duration,
|
||||||
|
power=effect.power,
|
||||||
|
stat_affected=effect.stat_affected,
|
||||||
|
stacks=effect.stacks,
|
||||||
|
max_stacks=effect.max_stacks,
|
||||||
|
source=item.item_id,
|
||||||
|
)
|
||||||
|
duration_effects.append(combat_effect)
|
||||||
|
else:
|
||||||
|
instant_effects.append(effect)
|
||||||
|
|
||||||
|
# Calculate instant effect results
|
||||||
|
result = self._apply_consumable_effects(
|
||||||
|
item, current_hp, max_hp, current_mp, max_mp
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove from inventory
|
||||||
|
character.remove_item(item_id)
|
||||||
|
|
||||||
|
logger.info("Consumable used in combat",
|
||||||
|
character_id=character.character_id,
|
||||||
|
item_id=item_id,
|
||||||
|
duration_effects=len(duration_effects))
|
||||||
|
|
||||||
|
# Persist
|
||||||
|
self.character_service.update_character(character, user_id)
|
||||||
|
|
||||||
|
return result, duration_effects
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Bulk Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def add_items(
|
||||||
|
self,
|
||||||
|
character: Character,
|
||||||
|
items: List[Item],
|
||||||
|
user_id: str
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Add multiple items to inventory (e.g., loot drop).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
items: List of items to add
|
||||||
|
user_id: User ID for persistence
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of items actually added
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Stops adding if inventory becomes full. Does not raise error
|
||||||
|
for partial success.
|
||||||
|
"""
|
||||||
|
added_count = 0
|
||||||
|
for item in items:
|
||||||
|
try:
|
||||||
|
self.add_item(character, item, user_id, save=False)
|
||||||
|
added_count += 1
|
||||||
|
except InventoryFullError:
|
||||||
|
logger.warning("Inventory full, dropping remaining loot",
|
||||||
|
character_id=character.character_id,
|
||||||
|
items_dropped=len(items) - added_count)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Save once after all items added
|
||||||
|
if added_count > 0:
|
||||||
|
self.character_service.update_character(character, user_id)
|
||||||
|
|
||||||
|
return added_count
|
||||||
|
|
||||||
|
def get_items_by_type(
|
||||||
|
self,
|
||||||
|
character: Character,
|
||||||
|
item_type: ItemType
|
||||||
|
) -> List[Item]:
|
||||||
|
"""
|
||||||
|
Get all inventory items of a specific type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
item_type: Type to filter by
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching items
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
item for item in character.inventory
|
||||||
|
if item.item_type == item_type
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_equippable_items(
|
||||||
|
self,
|
||||||
|
character: Character,
|
||||||
|
slot: Optional[str] = None
|
||||||
|
) -> List[Item]:
|
||||||
|
"""
|
||||||
|
Get all items that can be equipped.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
slot: Optional slot to filter by
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of equippable items (optionally filtered by slot)
|
||||||
|
"""
|
||||||
|
equippable = []
|
||||||
|
for item in character.inventory:
|
||||||
|
# Skip non-equippable types
|
||||||
|
if item.item_type not in ITEM_TYPE_SLOTS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip items that don't meet requirements
|
||||||
|
if not item.can_equip(character.level, character.class_id):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filter by slot if specified
|
||||||
|
if slot:
|
||||||
|
allowed_slots = ITEM_TYPE_SLOTS[item.item_type]
|
||||||
|
if slot not in allowed_slots:
|
||||||
|
continue
|
||||||
|
|
||||||
|
equippable.append(item)
|
||||||
|
|
||||||
|
return equippable
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Global Instance
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
_service_instance: Optional[InventoryService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_inventory_service() -> InventoryService:
|
||||||
|
"""
|
||||||
|
Get the global InventoryService instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Singleton InventoryService instance
|
||||||
|
"""
|
||||||
|
global _service_instance
|
||||||
|
if _service_instance is None:
|
||||||
|
_service_instance = InventoryService()
|
||||||
|
return _service_instance
|
||||||
536
api/app/services/item_generator.py
Normal file
536
api/app/services/item_generator.py
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
"""
|
||||||
|
Item Generator Service - Procedural item generation with affixes.
|
||||||
|
|
||||||
|
This service generates Diablo-style items by combining base templates with
|
||||||
|
random affixes, creating items like "Flaming Dagger of Strength".
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import random
|
||||||
|
from typing import List, Optional, Tuple, Dict, Any
|
||||||
|
|
||||||
|
from app.models.items import Item
|
||||||
|
from app.models.affixes import Affix, BaseItemTemplate
|
||||||
|
from app.models.enums import ItemType, ItemRarity, DamageType, AffixTier
|
||||||
|
from app.services.affix_loader import get_affix_loader, AffixLoader
|
||||||
|
from app.services.base_item_loader import get_base_item_loader, BaseItemLoader
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
# Affix count by rarity (COMMON/UNCOMMON get 0 affixes - plain items)
|
||||||
|
AFFIX_COUNTS = {
|
||||||
|
ItemRarity.COMMON: 0,
|
||||||
|
ItemRarity.UNCOMMON: 0,
|
||||||
|
ItemRarity.RARE: 1,
|
||||||
|
ItemRarity.EPIC: 2,
|
||||||
|
ItemRarity.LEGENDARY: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tier selection probabilities by rarity
|
||||||
|
# Higher rarity items have better chance at higher tier affixes
|
||||||
|
TIER_WEIGHTS = {
|
||||||
|
ItemRarity.RARE: {
|
||||||
|
AffixTier.MINOR: 0.8,
|
||||||
|
AffixTier.MAJOR: 0.2,
|
||||||
|
AffixTier.LEGENDARY: 0.0,
|
||||||
|
},
|
||||||
|
ItemRarity.EPIC: {
|
||||||
|
AffixTier.MINOR: 0.3,
|
||||||
|
AffixTier.MAJOR: 0.7,
|
||||||
|
AffixTier.LEGENDARY: 0.0,
|
||||||
|
},
|
||||||
|
ItemRarity.LEGENDARY: {
|
||||||
|
AffixTier.MINOR: 0.1,
|
||||||
|
AffixTier.MAJOR: 0.4,
|
||||||
|
AffixTier.LEGENDARY: 0.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rarity value multipliers (higher rarity = more valuable)
|
||||||
|
RARITY_VALUE_MULTIPLIER = {
|
||||||
|
ItemRarity.COMMON: 1.0,
|
||||||
|
ItemRarity.UNCOMMON: 1.5,
|
||||||
|
ItemRarity.RARE: 2.5,
|
||||||
|
ItemRarity.EPIC: 5.0,
|
||||||
|
ItemRarity.LEGENDARY: 10.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ItemGenerator:
|
||||||
|
"""
|
||||||
|
Generates procedural items with Diablo-style naming.
|
||||||
|
|
||||||
|
This service combines base item templates with randomly selected affixes
|
||||||
|
to create unique items with combined stats and generated names.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
affix_loader: Optional[AffixLoader] = None,
|
||||||
|
base_item_loader: Optional[BaseItemLoader] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the item generator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
affix_loader: Optional custom AffixLoader instance
|
||||||
|
base_item_loader: Optional custom BaseItemLoader instance
|
||||||
|
"""
|
||||||
|
self.affix_loader = affix_loader or get_affix_loader()
|
||||||
|
self.base_item_loader = base_item_loader or get_base_item_loader()
|
||||||
|
|
||||||
|
logger.info("ItemGenerator initialized")
|
||||||
|
|
||||||
|
def generate_item(
|
||||||
|
self,
|
||||||
|
item_type: str,
|
||||||
|
rarity: ItemRarity,
|
||||||
|
character_level: int = 1,
|
||||||
|
base_template_id: Optional[str] = None
|
||||||
|
) -> Optional[Item]:
|
||||||
|
"""
|
||||||
|
Generate a procedural item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_type: "weapon" or "armor"
|
||||||
|
rarity: Target rarity
|
||||||
|
character_level: Player level for template eligibility
|
||||||
|
base_template_id: Optional specific base template to use
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Generated Item instance or None if generation fails
|
||||||
|
"""
|
||||||
|
# 1. Get base template
|
||||||
|
base_template = self._get_base_template(
|
||||||
|
item_type, rarity, character_level, base_template_id
|
||||||
|
)
|
||||||
|
if not base_template:
|
||||||
|
logger.warning(
|
||||||
|
"No base template available",
|
||||||
|
item_type=item_type,
|
||||||
|
rarity=rarity.value,
|
||||||
|
level=character_level
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 2. Get affix count for this rarity
|
||||||
|
affix_count = AFFIX_COUNTS.get(rarity, 0)
|
||||||
|
|
||||||
|
# 3. Select affixes
|
||||||
|
prefixes, suffixes = self._select_affixes(
|
||||||
|
base_template.item_type, rarity, affix_count
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Build the item
|
||||||
|
item = self._build_item(base_template, rarity, prefixes, suffixes)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Item generated",
|
||||||
|
item_id=item.item_id,
|
||||||
|
name=item.get_display_name(),
|
||||||
|
rarity=rarity.value,
|
||||||
|
affixes=[a.affix_id for a in prefixes + suffixes]
|
||||||
|
)
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
def _get_base_template(
|
||||||
|
self,
|
||||||
|
item_type: str,
|
||||||
|
rarity: ItemRarity,
|
||||||
|
character_level: int,
|
||||||
|
template_id: Optional[str] = None
|
||||||
|
) -> Optional[BaseItemTemplate]:
|
||||||
|
"""
|
||||||
|
Get a base template for item generation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_type: Type of item
|
||||||
|
rarity: Target rarity
|
||||||
|
character_level: Player level
|
||||||
|
template_id: Optional specific template ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BaseItemTemplate instance or None
|
||||||
|
"""
|
||||||
|
if template_id:
|
||||||
|
return self.base_item_loader.get_template(template_id)
|
||||||
|
|
||||||
|
return self.base_item_loader.get_random_template(
|
||||||
|
item_type, rarity.value, character_level
|
||||||
|
)
|
||||||
|
|
||||||
|
def _select_affixes(
|
||||||
|
self,
|
||||||
|
item_type: str,
|
||||||
|
rarity: ItemRarity,
|
||||||
|
count: int
|
||||||
|
) -> Tuple[List[Affix], List[Affix]]:
|
||||||
|
"""
|
||||||
|
Select random affixes for an item.
|
||||||
|
|
||||||
|
Distribution logic:
|
||||||
|
- RARE (1 affix): 50% chance prefix, 50% chance suffix
|
||||||
|
- EPIC (2 affixes): 1 prefix AND 1 suffix
|
||||||
|
- LEGENDARY (3 affixes): Mix of prefixes and suffixes
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_type: Type of item
|
||||||
|
rarity: Item rarity
|
||||||
|
count: Number of affixes to select
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (prefixes, suffixes)
|
||||||
|
"""
|
||||||
|
prefixes: List[Affix] = []
|
||||||
|
suffixes: List[Affix] = []
|
||||||
|
used_ids: List[str] = []
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
return prefixes, suffixes
|
||||||
|
|
||||||
|
# Determine tier for affix selection
|
||||||
|
tier = self._roll_affix_tier(rarity)
|
||||||
|
|
||||||
|
if count == 1:
|
||||||
|
# RARE: Either prefix OR suffix (50/50)
|
||||||
|
if random.random() < 0.5:
|
||||||
|
prefix = self.affix_loader.get_random_prefix(
|
||||||
|
item_type, rarity.value, tier, used_ids
|
||||||
|
)
|
||||||
|
if prefix:
|
||||||
|
prefixes.append(prefix)
|
||||||
|
used_ids.append(prefix.affix_id)
|
||||||
|
else:
|
||||||
|
suffix = self.affix_loader.get_random_suffix(
|
||||||
|
item_type, rarity.value, tier, used_ids
|
||||||
|
)
|
||||||
|
if suffix:
|
||||||
|
suffixes.append(suffix)
|
||||||
|
used_ids.append(suffix.affix_id)
|
||||||
|
|
||||||
|
elif count == 2:
|
||||||
|
# EPIC: 1 prefix AND 1 suffix
|
||||||
|
tier = self._roll_affix_tier(rarity)
|
||||||
|
prefix = self.affix_loader.get_random_prefix(
|
||||||
|
item_type, rarity.value, tier, used_ids
|
||||||
|
)
|
||||||
|
if prefix:
|
||||||
|
prefixes.append(prefix)
|
||||||
|
used_ids.append(prefix.affix_id)
|
||||||
|
|
||||||
|
tier = self._roll_affix_tier(rarity)
|
||||||
|
suffix = self.affix_loader.get_random_suffix(
|
||||||
|
item_type, rarity.value, tier, used_ids
|
||||||
|
)
|
||||||
|
if suffix:
|
||||||
|
suffixes.append(suffix)
|
||||||
|
used_ids.append(suffix.affix_id)
|
||||||
|
|
||||||
|
elif count >= 3:
|
||||||
|
# LEGENDARY: Mix of prefixes and suffixes
|
||||||
|
# Try: 2 prefixes + 1 suffix OR 1 prefix + 2 suffixes
|
||||||
|
distribution = random.choice([(2, 1), (1, 2)])
|
||||||
|
prefix_count, suffix_count = distribution
|
||||||
|
|
||||||
|
for _ in range(prefix_count):
|
||||||
|
tier = self._roll_affix_tier(rarity)
|
||||||
|
prefix = self.affix_loader.get_random_prefix(
|
||||||
|
item_type, rarity.value, tier, used_ids
|
||||||
|
)
|
||||||
|
if prefix:
|
||||||
|
prefixes.append(prefix)
|
||||||
|
used_ids.append(prefix.affix_id)
|
||||||
|
|
||||||
|
for _ in range(suffix_count):
|
||||||
|
tier = self._roll_affix_tier(rarity)
|
||||||
|
suffix = self.affix_loader.get_random_suffix(
|
||||||
|
item_type, rarity.value, tier, used_ids
|
||||||
|
)
|
||||||
|
if suffix:
|
||||||
|
suffixes.append(suffix)
|
||||||
|
used_ids.append(suffix.affix_id)
|
||||||
|
|
||||||
|
return prefixes, suffixes
|
||||||
|
|
||||||
|
def _roll_affix_tier(self, rarity: ItemRarity) -> Optional[AffixTier]:
|
||||||
|
"""
|
||||||
|
Roll for affix tier based on item rarity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rarity: Item rarity
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Selected AffixTier or None for no tier filter
|
||||||
|
"""
|
||||||
|
weights = TIER_WEIGHTS.get(rarity)
|
||||||
|
if not weights:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tiers = list(weights.keys())
|
||||||
|
tier_weights = list(weights.values())
|
||||||
|
|
||||||
|
# Filter out zero-weight options
|
||||||
|
valid_tiers = []
|
||||||
|
valid_weights = []
|
||||||
|
for t, w in zip(tiers, tier_weights):
|
||||||
|
if w > 0:
|
||||||
|
valid_tiers.append(t)
|
||||||
|
valid_weights.append(w)
|
||||||
|
|
||||||
|
if not valid_tiers:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return random.choices(valid_tiers, weights=valid_weights, k=1)[0]
|
||||||
|
|
||||||
|
def _build_item(
|
||||||
|
self,
|
||||||
|
base_template: BaseItemTemplate,
|
||||||
|
rarity: ItemRarity,
|
||||||
|
prefixes: List[Affix],
|
||||||
|
suffixes: List[Affix]
|
||||||
|
) -> Item:
|
||||||
|
"""
|
||||||
|
Build an Item from base template and affixes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_template: Base item template
|
||||||
|
rarity: Item rarity
|
||||||
|
prefixes: List of prefix affixes
|
||||||
|
suffixes: List of suffix affixes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Fully constructed Item instance
|
||||||
|
"""
|
||||||
|
# Generate unique ID
|
||||||
|
item_id = f"gen_{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
# Build generated name
|
||||||
|
generated_name = self._build_name(base_template.name, prefixes, suffixes)
|
||||||
|
|
||||||
|
# Combine stats from all affixes
|
||||||
|
combined_stats = self._combine_affix_stats(prefixes + suffixes)
|
||||||
|
|
||||||
|
# Calculate final item values
|
||||||
|
item_type = ItemType.WEAPON if base_template.item_type == "weapon" else ItemType.ARMOR
|
||||||
|
|
||||||
|
# Base values from template
|
||||||
|
damage = base_template.base_damage + combined_stats["damage_bonus"]
|
||||||
|
spell_power = base_template.base_spell_power # Magical weapon damage
|
||||||
|
defense = base_template.base_defense + combined_stats["defense_bonus"]
|
||||||
|
resistance = base_template.base_resistance + combined_stats["resistance_bonus"]
|
||||||
|
crit_chance = base_template.crit_chance + combined_stats["crit_chance_bonus"]
|
||||||
|
crit_multiplier = base_template.crit_multiplier + combined_stats["crit_multiplier_bonus"]
|
||||||
|
|
||||||
|
# Calculate value with rarity multiplier
|
||||||
|
base_value = base_template.base_value
|
||||||
|
rarity_mult = RARITY_VALUE_MULTIPLIER.get(rarity, 1.0)
|
||||||
|
# Add value for each affix
|
||||||
|
affix_value = len(prefixes + suffixes) * 25
|
||||||
|
final_value = int((base_value + affix_value) * rarity_mult)
|
||||||
|
|
||||||
|
# Determine elemental damage type (from prefix affixes)
|
||||||
|
elemental_damage_type = None
|
||||||
|
elemental_ratio = 0.0
|
||||||
|
for prefix in prefixes:
|
||||||
|
if prefix.applies_elemental_damage():
|
||||||
|
elemental_damage_type = prefix.damage_type
|
||||||
|
elemental_ratio = prefix.elemental_ratio
|
||||||
|
break # Use first elemental prefix
|
||||||
|
|
||||||
|
# Track applied affixes
|
||||||
|
applied_affixes = [a.affix_id for a in prefixes + suffixes]
|
||||||
|
|
||||||
|
# Create the item
|
||||||
|
item = Item(
|
||||||
|
item_id=item_id,
|
||||||
|
name=base_template.name, # Base name
|
||||||
|
item_type=item_type,
|
||||||
|
rarity=rarity,
|
||||||
|
description=base_template.description,
|
||||||
|
value=final_value,
|
||||||
|
is_tradeable=True,
|
||||||
|
stat_bonuses=combined_stats["stat_bonuses"],
|
||||||
|
effects_on_use=[], # Not a consumable
|
||||||
|
damage=damage,
|
||||||
|
spell_power=spell_power, # Magical weapon damage bonus
|
||||||
|
damage_type=DamageType(base_template.damage_type) if damage > 0 else None,
|
||||||
|
crit_chance=crit_chance,
|
||||||
|
crit_multiplier=crit_multiplier,
|
||||||
|
elemental_damage_type=elemental_damage_type,
|
||||||
|
physical_ratio=1.0 - elemental_ratio if elemental_ratio > 0 else 1.0,
|
||||||
|
elemental_ratio=elemental_ratio,
|
||||||
|
defense=defense,
|
||||||
|
resistance=resistance,
|
||||||
|
required_level=base_template.required_level,
|
||||||
|
required_class=None,
|
||||||
|
# Affix tracking
|
||||||
|
applied_affixes=applied_affixes,
|
||||||
|
base_template_id=base_template.template_id,
|
||||||
|
generated_name=generated_name,
|
||||||
|
is_generated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
def _build_name(
|
||||||
|
self,
|
||||||
|
base_name: str,
|
||||||
|
prefixes: List[Affix],
|
||||||
|
suffixes: List[Affix]
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Build the full item name with affixes.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- RARE (1 prefix): "Flaming Dagger"
|
||||||
|
- RARE (1 suffix): "Dagger of Strength"
|
||||||
|
- EPIC: "Flaming Dagger of Strength"
|
||||||
|
- LEGENDARY: "Blazing Glacial Dagger of the Titan"
|
||||||
|
|
||||||
|
Note: Rarity is NOT included in name (shown via UI).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_name: Base item name (e.g., "Dagger")
|
||||||
|
prefixes: List of prefix affixes
|
||||||
|
suffixes: List of suffix affixes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full generated name string
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Add prefix names (in order)
|
||||||
|
for prefix in prefixes:
|
||||||
|
parts.append(prefix.name)
|
||||||
|
|
||||||
|
# Add base name
|
||||||
|
parts.append(base_name)
|
||||||
|
|
||||||
|
# Build name string from parts
|
||||||
|
name = " ".join(parts)
|
||||||
|
|
||||||
|
# Add suffix names (they include "of")
|
||||||
|
for suffix in suffixes:
|
||||||
|
name += f" {suffix.name}"
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
def _combine_affix_stats(self, affixes: List[Affix]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Combine stats from multiple affixes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
affixes: List of affixes to combine
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with combined stat values
|
||||||
|
"""
|
||||||
|
combined = {
|
||||||
|
"stat_bonuses": {},
|
||||||
|
"damage_bonus": 0,
|
||||||
|
"defense_bonus": 0,
|
||||||
|
"resistance_bonus": 0,
|
||||||
|
"crit_chance_bonus": 0.0,
|
||||||
|
"crit_multiplier_bonus": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for affix in affixes:
|
||||||
|
# Combine stat bonuses
|
||||||
|
for stat_name, bonus in affix.stat_bonuses.items():
|
||||||
|
current = combined["stat_bonuses"].get(stat_name, 0)
|
||||||
|
combined["stat_bonuses"][stat_name] = current + bonus
|
||||||
|
|
||||||
|
# Combine direct bonuses
|
||||||
|
combined["damage_bonus"] += affix.damage_bonus
|
||||||
|
combined["defense_bonus"] += affix.defense_bonus
|
||||||
|
combined["resistance_bonus"] += affix.resistance_bonus
|
||||||
|
combined["crit_chance_bonus"] += affix.crit_chance_bonus
|
||||||
|
combined["crit_multiplier_bonus"] += affix.crit_multiplier_bonus
|
||||||
|
|
||||||
|
return combined
|
||||||
|
|
||||||
|
def generate_loot_drop(
|
||||||
|
self,
|
||||||
|
character_level: int,
|
||||||
|
luck_stat: int = 8,
|
||||||
|
item_type: Optional[str] = None
|
||||||
|
) -> Optional[Item]:
|
||||||
|
"""
|
||||||
|
Generate a random loot drop with luck-influenced rarity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_level: Player level
|
||||||
|
luck_stat: Player's luck stat (affects rarity chance)
|
||||||
|
item_type: Optional item type filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Generated Item or None
|
||||||
|
"""
|
||||||
|
# Choose random item type if not specified
|
||||||
|
if item_type is None:
|
||||||
|
item_type = random.choice(["weapon", "armor"])
|
||||||
|
|
||||||
|
# Roll rarity with luck bonus
|
||||||
|
rarity = self._roll_rarity(luck_stat)
|
||||||
|
|
||||||
|
return self.generate_item(item_type, rarity, character_level)
|
||||||
|
|
||||||
|
def _roll_rarity(self, luck_stat: int) -> ItemRarity:
|
||||||
|
"""
|
||||||
|
Roll item rarity with luck bonus.
|
||||||
|
|
||||||
|
Base chances (luck 8):
|
||||||
|
- COMMON: 50%
|
||||||
|
- UNCOMMON: 30%
|
||||||
|
- RARE: 15%
|
||||||
|
- EPIC: 4%
|
||||||
|
- LEGENDARY: 1%
|
||||||
|
|
||||||
|
Luck modifies these chances slightly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
luck_stat: Player's luck stat
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rolled ItemRarity
|
||||||
|
"""
|
||||||
|
# Calculate luck bonus (luck 8 = baseline)
|
||||||
|
luck_bonus = (luck_stat - 8) * 0.005
|
||||||
|
|
||||||
|
roll = random.random()
|
||||||
|
|
||||||
|
# Thresholds (cumulative)
|
||||||
|
legendary_threshold = 0.01 + luck_bonus
|
||||||
|
epic_threshold = legendary_threshold + 0.04 + luck_bonus * 2
|
||||||
|
rare_threshold = epic_threshold + 0.15 + luck_bonus * 3
|
||||||
|
uncommon_threshold = rare_threshold + 0.30
|
||||||
|
|
||||||
|
if roll < legendary_threshold:
|
||||||
|
return ItemRarity.LEGENDARY
|
||||||
|
elif roll < epic_threshold:
|
||||||
|
return ItemRarity.EPIC
|
||||||
|
elif roll < rare_threshold:
|
||||||
|
return ItemRarity.RARE
|
||||||
|
elif roll < uncommon_threshold:
|
||||||
|
return ItemRarity.UNCOMMON
|
||||||
|
else:
|
||||||
|
return ItemRarity.COMMON
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance for convenience
|
||||||
|
_generator_instance: Optional[ItemGenerator] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_item_generator() -> ItemGenerator:
|
||||||
|
"""
|
||||||
|
Get the global ItemGenerator instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Singleton ItemGenerator instance
|
||||||
|
"""
|
||||||
|
global _generator_instance
|
||||||
|
if _generator_instance is None:
|
||||||
|
_generator_instance = ItemGenerator()
|
||||||
|
return _generator_instance
|
||||||
@@ -272,9 +272,9 @@ class SessionService:
|
|||||||
session_json = json.dumps(session_dict)
|
session_json = json.dumps(session_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=session.session_id,
|
row_id=session.session_id,
|
||||||
data={
|
data={
|
||||||
'sessionData': session_json,
|
'sessionData': session_json,
|
||||||
'status': session.status.value
|
'status': session.status.value
|
||||||
|
|||||||
301
api/app/services/static_item_loader.py
Normal file
301
api/app/services/static_item_loader.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"""
|
||||||
|
Static Item Loader Service - YAML-based static item loading.
|
||||||
|
|
||||||
|
This service loads predefined item definitions (consumables, materials, quest items)
|
||||||
|
from YAML files, providing a way to reference specific items by ID in loot tables.
|
||||||
|
|
||||||
|
Static items differ from procedurally generated items in that they have fixed
|
||||||
|
properties defined in YAML rather than randomly generated affixes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
import uuid
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.models.items import Item
|
||||||
|
from app.models.effects import Effect
|
||||||
|
from app.models.enums import ItemType, ItemRarity, EffectType, DamageType
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
class StaticItemLoader:
|
||||||
|
"""
|
||||||
|
Loads and manages static item definitions from YAML configuration files.
|
||||||
|
|
||||||
|
Static items are predefined items (consumables, materials, quest items)
|
||||||
|
that can be referenced by item_id in enemy loot tables.
|
||||||
|
|
||||||
|
Items are loaded from:
|
||||||
|
- api/app/data/static_items/consumables.yaml
|
||||||
|
- api/app/data/static_items/materials.yaml
|
||||||
|
|
||||||
|
Each call to get_item() creates a new Item instance with a unique ID,
|
||||||
|
so multiple drops of the same item_id become distinct inventory items.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, data_dir: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Initialize the static item loader.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_dir: Path to directory containing static item YAML files.
|
||||||
|
Defaults to /app/data/static_items/
|
||||||
|
"""
|
||||||
|
if data_dir is None:
|
||||||
|
# Default to app/data/static_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" / "static_items")
|
||||||
|
|
||||||
|
self.data_dir = Path(data_dir)
|
||||||
|
self._cache: Dict[str, dict] = {}
|
||||||
|
self._loaded = False
|
||||||
|
|
||||||
|
logger.info("StaticItemLoader initialized", data_dir=str(self.data_dir))
|
||||||
|
|
||||||
|
def _ensure_loaded(self) -> None:
|
||||||
|
"""Ensure items are loaded before any operation."""
|
||||||
|
if not self._loaded:
|
||||||
|
self._load_all()
|
||||||
|
|
||||||
|
def _load_all(self) -> None:
|
||||||
|
"""Load all static item YAML files."""
|
||||||
|
if not self.data_dir.exists():
|
||||||
|
logger.warning(
|
||||||
|
"Static items directory not found",
|
||||||
|
path=str(self.data_dir)
|
||||||
|
)
|
||||||
|
self._loaded = True
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load all YAML files in the directory
|
||||||
|
for yaml_file in self.data_dir.glob("*.yaml"):
|
||||||
|
self._load_file(yaml_file)
|
||||||
|
|
||||||
|
self._loaded = True
|
||||||
|
logger.info("Static items loaded", count=len(self._cache))
|
||||||
|
|
||||||
|
def _load_file(self, yaml_file: Path) -> None:
|
||||||
|
"""
|
||||||
|
Load items from a single YAML file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
yaml_file: Path to the YAML file
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(yaml_file, 'r') as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
logger.warning("Empty YAML file", file=str(yaml_file))
|
||||||
|
return
|
||||||
|
|
||||||
|
items = data.get("items", {})
|
||||||
|
for item_id, item_data in items.items():
|
||||||
|
# Store the template data with its ID
|
||||||
|
item_data["_item_id"] = item_id
|
||||||
|
self._cache[item_id] = item_data
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Static items loaded from file",
|
||||||
|
file=str(yaml_file),
|
||||||
|
count=len(items)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to load static items file",
|
||||||
|
file=str(yaml_file),
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_item(self, item_id: str, quantity: int = 1) -> Optional[Item]:
|
||||||
|
"""
|
||||||
|
Get an item instance by ID.
|
||||||
|
|
||||||
|
Creates a new Item instance with a unique ID for each call,
|
||||||
|
so multiple drops become distinct inventory items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: The static item ID (e.g., "health_potion_small")
|
||||||
|
quantity: Requested quantity (not used for individual item,
|
||||||
|
but available for future stackable item support)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Item instance or None if item_id not found
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
|
||||||
|
template = self._cache.get(item_id)
|
||||||
|
if template is None:
|
||||||
|
logger.warning("Static item not found", item_id=item_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Create new instance with unique ID
|
||||||
|
instance_id = f"{item_id}_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
# Parse item type
|
||||||
|
item_type_str = template.get("item_type", "quest_item")
|
||||||
|
try:
|
||||||
|
item_type = ItemType(item_type_str)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"Unknown item type, defaulting to quest_item",
|
||||||
|
item_type=item_type_str,
|
||||||
|
item_id=item_id
|
||||||
|
)
|
||||||
|
item_type = ItemType.QUEST_ITEM
|
||||||
|
|
||||||
|
# Parse rarity
|
||||||
|
rarity_str = template.get("rarity", "common")
|
||||||
|
try:
|
||||||
|
rarity = ItemRarity(rarity_str)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"Unknown rarity, defaulting to common",
|
||||||
|
rarity=rarity_str,
|
||||||
|
item_id=item_id
|
||||||
|
)
|
||||||
|
rarity = ItemRarity.COMMON
|
||||||
|
|
||||||
|
# Parse effects if present
|
||||||
|
effects = []
|
||||||
|
for effect_data in template.get("effects_on_use", []):
|
||||||
|
try:
|
||||||
|
effect = self._parse_effect(effect_data)
|
||||||
|
if effect:
|
||||||
|
effects.append(effect)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to parse effect",
|
||||||
|
item_id=item_id,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse stat bonuses if present
|
||||||
|
stat_bonuses = template.get("stat_bonuses", {})
|
||||||
|
|
||||||
|
# Parse damage type if present (for weapons)
|
||||||
|
damage_type = None
|
||||||
|
damage_type_str = template.get("damage_type")
|
||||||
|
if damage_type_str:
|
||||||
|
try:
|
||||||
|
damage_type = DamageType(damage_type_str)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"Unknown damage type, defaulting to physical",
|
||||||
|
damage_type=damage_type_str,
|
||||||
|
item_id=item_id
|
||||||
|
)
|
||||||
|
damage_type = DamageType.PHYSICAL
|
||||||
|
|
||||||
|
return Item(
|
||||||
|
item_id=instance_id,
|
||||||
|
name=template.get("name", item_id),
|
||||||
|
item_type=item_type,
|
||||||
|
rarity=rarity,
|
||||||
|
description=template.get("description", ""),
|
||||||
|
value=template.get("value", 1),
|
||||||
|
is_tradeable=template.get("is_tradeable", True),
|
||||||
|
stat_bonuses=stat_bonuses,
|
||||||
|
effects_on_use=effects,
|
||||||
|
# Weapon-specific fields
|
||||||
|
damage=template.get("damage", 0),
|
||||||
|
spell_power=template.get("spell_power", 0),
|
||||||
|
damage_type=damage_type,
|
||||||
|
crit_chance=template.get("crit_chance", 0.05),
|
||||||
|
crit_multiplier=template.get("crit_multiplier", 2.0),
|
||||||
|
# Armor-specific fields
|
||||||
|
defense=template.get("defense", 0),
|
||||||
|
resistance=template.get("resistance", 0),
|
||||||
|
# Level requirements
|
||||||
|
required_level=template.get("required_level", 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_effect(self, effect_data: Dict) -> Optional[Effect]:
|
||||||
|
"""
|
||||||
|
Parse an effect from YAML data.
|
||||||
|
|
||||||
|
Supports simplified YAML format where effect_type is a string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
effect_data: Effect definition from YAML
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Effect instance or None if parsing fails
|
||||||
|
"""
|
||||||
|
# Parse effect type
|
||||||
|
effect_type_str = effect_data.get("effect_type", "buff")
|
||||||
|
try:
|
||||||
|
effect_type = EffectType(effect_type_str)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"Unknown effect type",
|
||||||
|
effect_type=effect_type_str
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Generate effect ID if not provided
|
||||||
|
effect_id = effect_data.get(
|
||||||
|
"effect_id",
|
||||||
|
f"effect_{uuid.uuid4().hex[:8]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return Effect(
|
||||||
|
effect_id=effect_id,
|
||||||
|
name=effect_data.get("name", "Unknown Effect"),
|
||||||
|
effect_type=effect_type,
|
||||||
|
duration=effect_data.get("duration", 1),
|
||||||
|
power=effect_data.get("power", 0),
|
||||||
|
stacks=effect_data.get("stacks", 1),
|
||||||
|
max_stacks=effect_data.get("max_stacks", 5),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_all_item_ids(self) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get list of all available static item IDs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of item_id strings
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
return list(self._cache.keys())
|
||||||
|
|
||||||
|
def has_item(self, item_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an item ID exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: The item ID to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if item exists in cache
|
||||||
|
"""
|
||||||
|
self._ensure_loaded()
|
||||||
|
return item_id in self._cache
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Clear the item cache, forcing reload on next access."""
|
||||||
|
self._cache.clear()
|
||||||
|
self._loaded = False
|
||||||
|
logger.debug("Static item cache cleared")
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance for convenience
|
||||||
|
_loader_instance: Optional[StaticItemLoader] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_static_item_loader() -> StaticItemLoader:
|
||||||
|
"""
|
||||||
|
Get the global StaticItemLoader instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Singleton StaticItemLoader instance
|
||||||
|
"""
|
||||||
|
global _loader_instance
|
||||||
|
if _loader_instance is None:
|
||||||
|
_loader_instance = StaticItemLoader()
|
||||||
|
return _loader_instance
|
||||||
144
api/app/tasks/combat_cleanup.py
Normal file
144
api/app/tasks/combat_cleanup.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""
|
||||||
|
Combat Cleanup Tasks.
|
||||||
|
|
||||||
|
This module provides scheduled tasks for cleaning up ended combat
|
||||||
|
encounters that are older than the retention period.
|
||||||
|
|
||||||
|
The cleanup can be scheduled to run periodically (daily recommended)
|
||||||
|
via APScheduler, cron, or manual invocation.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Manual invocation
|
||||||
|
from app.tasks.combat_cleanup import cleanup_old_combat_encounters
|
||||||
|
result = cleanup_old_combat_encounters(older_than_days=7)
|
||||||
|
|
||||||
|
# Via APScheduler
|
||||||
|
scheduler.add_job(
|
||||||
|
cleanup_old_combat_encounters,
|
||||||
|
'interval',
|
||||||
|
days=1,
|
||||||
|
kwargs={'older_than_days': 7}
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from app.services.combat_repository import get_combat_repository
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
# Default retention period in days
|
||||||
|
DEFAULT_RETENTION_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_combat_encounters(
|
||||||
|
older_than_days: int = DEFAULT_RETENTION_DAYS
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Delete ended combat encounters older than specified days.
|
||||||
|
|
||||||
|
This is the main cleanup function for time-based retention.
|
||||||
|
Should be scheduled to run periodically (daily recommended).
|
||||||
|
|
||||||
|
Only deletes ENDED encounters (victory, defeat, fled) - active
|
||||||
|
encounters are never deleted.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
older_than_days: Number of days after which to delete ended combats.
|
||||||
|
Default is 7 days.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing:
|
||||||
|
- deleted_encounters: Number of encounters deleted
|
||||||
|
- deleted_rounds: Approximate rounds deleted (cascaded)
|
||||||
|
- older_than_days: The threshold used
|
||||||
|
- success: Whether the operation completed successfully
|
||||||
|
- error: Error message if failed
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> result = cleanup_old_combat_encounters(older_than_days=7)
|
||||||
|
>>> print(f"Deleted {result['deleted_encounters']} encounters")
|
||||||
|
"""
|
||||||
|
logger.info("Starting combat encounter cleanup",
|
||||||
|
older_than_days=older_than_days)
|
||||||
|
|
||||||
|
try:
|
||||||
|
repo = get_combat_repository()
|
||||||
|
deleted_count = repo.delete_old_encounters(older_than_days)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"deleted_encounters": deleted_count,
|
||||||
|
"older_than_days": older_than_days,
|
||||||
|
"success": True,
|
||||||
|
"error": None
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Combat encounter cleanup completed successfully",
|
||||||
|
deleted_count=deleted_count,
|
||||||
|
older_than_days=older_than_days)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Combat encounter cleanup failed",
|
||||||
|
error=str(e),
|
||||||
|
older_than_days=older_than_days)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"deleted_encounters": 0,
|
||||||
|
"older_than_days": older_than_days,
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_encounters_for_session(session_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Delete all combat encounters for a specific session.
|
||||||
|
|
||||||
|
Call this when a session is being deleted to clean up
|
||||||
|
associated combat data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: The session ID to clean up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing:
|
||||||
|
- deleted_encounters: Number of encounters deleted
|
||||||
|
- session_id: The session ID processed
|
||||||
|
- success: Whether the operation completed successfully
|
||||||
|
- error: Error message if failed
|
||||||
|
"""
|
||||||
|
logger.info("Cleaning up combat encounters for session",
|
||||||
|
session_id=session_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
repo = get_combat_repository()
|
||||||
|
deleted_count = repo.delete_encounters_by_session(session_id)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"deleted_encounters": deleted_count,
|
||||||
|
"session_id": session_id,
|
||||||
|
"success": True,
|
||||||
|
"error": None
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Session combat cleanup completed",
|
||||||
|
session_id=session_id,
|
||||||
|
deleted_count=deleted_count)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Session combat cleanup failed",
|
||||||
|
session_id=session_id,
|
||||||
|
error=str(e))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"deleted_encounters": 0,
|
||||||
|
"session_id": session_id,
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,7 @@ All enum types are defined in `/app/models/enums.py` for type safety throughout
|
|||||||
| `INTELLIGENCE` | Magical power |
|
| `INTELLIGENCE` | Magical power |
|
||||||
| `WISDOM` | Perception and insight |
|
| `WISDOM` | Perception and insight |
|
||||||
| `CHARISMA` | Social influence |
|
| `CHARISMA` | Social influence |
|
||||||
|
| `LUCK` | Fortune and fate (affects crits, loot, random outcomes) |
|
||||||
|
|
||||||
### AbilityType
|
### AbilityType
|
||||||
|
|
||||||
@@ -597,14 +598,15 @@ success = service.soft_delete_message(
|
|||||||
|
|
||||||
### Stats
|
### Stats
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `strength` | int | Physical power |
|
| `strength` | int | 10 | Physical power |
|
||||||
| `dexterity` | int | Agility and precision |
|
| `dexterity` | int | 10 | Agility and precision |
|
||||||
| `constitution` | int | Endurance and health |
|
| `constitution` | int | 10 | Endurance and health |
|
||||||
| `intelligence` | int | Magical power |
|
| `intelligence` | int | 10 | Magical power |
|
||||||
| `wisdom` | int | Perception and insight |
|
| `wisdom` | int | 10 | Perception and insight |
|
||||||
| `charisma` | int | Social influence |
|
| `charisma` | int | 10 | Social influence |
|
||||||
|
| `luck` | int | 8 | Fortune and fate (affects crits, loot, random outcomes) |
|
||||||
|
|
||||||
**Derived Properties (Computed):**
|
**Derived Properties (Computed):**
|
||||||
- `hit_points` = 10 + (constitution × 2)
|
- `hit_points` = 10 + (constitution × 2)
|
||||||
@@ -614,6 +616,8 @@ success = service.soft_delete_message(
|
|||||||
|
|
||||||
**Note:** Defense and resistance are computed properties, not stored values. They are calculated on-the-fly from constitution and wisdom.
|
**Note:** Defense and resistance are computed properties, not stored values. They are calculated on-the-fly from constitution and wisdom.
|
||||||
|
|
||||||
|
**Luck Stat:** The luck stat has a lower default (8) compared to other stats (10). Each class has a specific luck value ranging from 7 (Necromancer) to 12 (Assassin). Luck will influence critical hit chance, hit/miss calculations, base damage variance, NPC interactions, loot generation, and spell power in future implementations.
|
||||||
|
|
||||||
### SkillNode
|
### SkillNode
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
@@ -662,16 +666,26 @@ success = service.soft_delete_message(
|
|||||||
|
|
||||||
### Initial 8 Player Classes
|
### Initial 8 Player Classes
|
||||||
|
|
||||||
| Class | Theme | Skill Tree 1 | Skill Tree 2 |
|
| Class | Theme | LUK | Skill Tree 1 | Skill Tree 2 |
|
||||||
|-------|-------|--------------|--------------|
|
|-------|-------|-----|--------------|--------------|
|
||||||
| **Vanguard** | Tank/melee | Defensive (shields, armor, taunts) | Offensive (weapon mastery, heavy strikes) |
|
| **Vanguard** | Tank/melee | 8 | Defensive (shields, armor, taunts) | Offensive (weapon mastery, heavy strikes) |
|
||||||
| **Assassin** | Stealth/critical | Assassination (critical hits, poisons) | Shadow (stealth, evasion) |
|
| **Assassin** | Stealth/critical | 12 | Assassination (critical hits, poisons) | Shadow (stealth, evasion) |
|
||||||
| **Arcanist** | Elemental spells | Pyromancy (fire spells, DoT) | Cryomancy (ice spells, crowd control) |
|
| **Arcanist** | Elemental spells | 9 | Pyromancy (fire spells, DoT) | Cryomancy (ice spells, crowd control) |
|
||||||
| **Luminary** | Healing/support | Holy (healing, buffs) | Divine Wrath (smite, undead damage) |
|
| **Luminary** | Healing/support | 11 | Holy (healing, buffs) | Divine Wrath (smite, undead damage) |
|
||||||
| **Wildstrider** | Ranged/nature | Marksmanship (bow skills, critical shots) | Beast Master (pet companion, nature magic) |
|
| **Wildstrider** | Ranged/nature | 10 | Marksmanship (bow skills, critical shots) | Beast Master (pet companion, nature magic) |
|
||||||
| **Oathkeeper** | Hybrid tank/healer | Protection (defensive auras, healing) | Retribution (holy damage, smites) |
|
| **Oathkeeper** | Hybrid tank/healer | 9 | Protection (defensive auras, healing) | Retribution (holy damage, smites) |
|
||||||
| **Necromancer** | Death magic/summon | Dark Arts (curses, life drain) | Summoning (undead minions) |
|
| **Necromancer** | Death magic/summon | 7 | Dark Arts (curses, life drain) | Summoning (undead minions) |
|
||||||
| **Lorekeeper** | Support/control | Performance (buffs, debuffs via music) | Trickery (illusions, charm) |
|
| **Lorekeeper** | Support/control | 10 | Performance (buffs, debuffs via music) | Trickery (illusions, charm) |
|
||||||
|
|
||||||
|
**Class Luck Values:**
|
||||||
|
- **Assassin (12):** Highest luck - critical strike specialists benefit most from fortune
|
||||||
|
- **Luminary (11):** Divine favor grants above-average luck
|
||||||
|
- **Wildstrider (10):** Average luck - self-reliant nature
|
||||||
|
- **Lorekeeper (10):** Average luck - knowledge is their advantage
|
||||||
|
- **Arcanist (9):** Slight chaos magic influence
|
||||||
|
- **Oathkeeper (9):** Honorable path grants modest fortune
|
||||||
|
- **Vanguard (8):** Relies on strength and skill, not luck
|
||||||
|
- **Necromancer (7):** Lowest luck - dark arts exact a toll
|
||||||
|
|
||||||
**Extensibility:** Class system designed to easily add more classes in future updates.
|
**Extensibility:** Class system designed to easily add more classes in future updates.
|
||||||
|
|
||||||
@@ -694,6 +708,149 @@ success = service.soft_delete_message(
|
|||||||
- **Consumable:** One-time use (potions, scrolls)
|
- **Consumable:** One-time use (potions, scrolls)
|
||||||
- **Quest Item:** Story-related, non-tradeable
|
- **Quest Item:** Story-related, non-tradeable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Procedural Item Generation (Affix System)
|
||||||
|
|
||||||
|
The game uses a Diablo-style procedural item generation system where weapons and armor
|
||||||
|
are created by combining base templates with random affixes.
|
||||||
|
|
||||||
|
### Core Models
|
||||||
|
|
||||||
|
#### Affix
|
||||||
|
|
||||||
|
Represents a prefix or suffix that modifies an item's stats and name.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `affix_id` | str | Unique identifier |
|
||||||
|
| `name` | str | Display name ("Flaming", "of Strength") |
|
||||||
|
| `affix_type` | AffixType | PREFIX or SUFFIX |
|
||||||
|
| `tier` | AffixTier | MINOR, MAJOR, or LEGENDARY |
|
||||||
|
| `description` | str | Affix description |
|
||||||
|
| `stat_bonuses` | Dict[str, int] | Stat modifications |
|
||||||
|
| `damage_bonus` | int | Flat damage increase |
|
||||||
|
| `defense_bonus` | int | Flat defense increase |
|
||||||
|
| `resistance_bonus` | int | Flat resistance increase |
|
||||||
|
| `damage_type` | DamageType | For elemental affixes |
|
||||||
|
| `elemental_ratio` | float | Portion of damage converted to element |
|
||||||
|
| `crit_chance_bonus` | float | Critical hit chance modifier |
|
||||||
|
| `crit_multiplier_bonus` | float | Critical damage modifier |
|
||||||
|
| `allowed_item_types` | List[str] | Item types this affix can apply to |
|
||||||
|
| `required_rarity` | str | Minimum rarity required (for legendary affixes) |
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `applies_elemental_damage() -> bool` - Check if affix adds elemental damage
|
||||||
|
- `is_legendary_only() -> bool` - Check if requires legendary rarity
|
||||||
|
- `can_apply_to(item_type, rarity) -> bool` - Check if affix can be applied
|
||||||
|
|
||||||
|
#### BaseItemTemplate
|
||||||
|
|
||||||
|
Foundation template for procedural item generation.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `template_id` | str | Unique identifier |
|
||||||
|
| `name` | str | Base item name ("Dagger") |
|
||||||
|
| `item_type` | str | "weapon" or "armor" |
|
||||||
|
| `description` | str | Template description |
|
||||||
|
| `base_damage` | int | Starting damage value |
|
||||||
|
| `base_defense` | int | Starting defense value |
|
||||||
|
| `base_resistance` | int | Starting resistance value |
|
||||||
|
| `base_value` | int | Base gold value |
|
||||||
|
| `damage_type` | str | Physical, fire, etc. |
|
||||||
|
| `crit_chance` | float | Base critical chance |
|
||||||
|
| `crit_multiplier` | float | Base critical multiplier |
|
||||||
|
| `required_level` | int | Minimum level to use |
|
||||||
|
| `min_rarity` | str | Minimum rarity this generates as |
|
||||||
|
| `drop_weight` | int | Relative drop probability |
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `can_generate_at_rarity(rarity) -> bool` - Check if template supports rarity
|
||||||
|
- `can_drop_for_level(level) -> bool` - Check level requirement
|
||||||
|
|
||||||
|
### Item Model Updates for Generated Items
|
||||||
|
|
||||||
|
The `Item` dataclass includes fields for tracking generated items:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `applied_affixes` | List[str] | IDs of affixes on this item |
|
||||||
|
| `base_template_id` | str | ID of base template used |
|
||||||
|
| `generated_name` | str | Full name with affixes (e.g., "Flaming Dagger of Strength") |
|
||||||
|
| `is_generated` | bool | True if procedurally generated |
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `get_display_name() -> str` - Returns generated_name if available, otherwise base name
|
||||||
|
|
||||||
|
### Generation Enumerations
|
||||||
|
|
||||||
|
#### ItemRarity
|
||||||
|
|
||||||
|
Item quality tiers affecting affix count and value:
|
||||||
|
|
||||||
|
| Value | Affix Count | Value Multiplier |
|
||||||
|
|-------|-------------|------------------|
|
||||||
|
| `COMMON` | 0 | 1.0× |
|
||||||
|
| `UNCOMMON` | 0 | 1.5× |
|
||||||
|
| `RARE` | 1 | 2.5× |
|
||||||
|
| `EPIC` | 2 | 5.0× |
|
||||||
|
| `LEGENDARY` | 3 | 10.0× |
|
||||||
|
|
||||||
|
#### AffixType
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `PREFIX` | Appears before item name ("Flaming Dagger") |
|
||||||
|
| `SUFFIX` | Appears after item name ("Dagger of Strength") |
|
||||||
|
|
||||||
|
#### AffixTier
|
||||||
|
|
||||||
|
Affix power level, determines eligibility by item rarity:
|
||||||
|
|
||||||
|
| Value | Description | Available For |
|
||||||
|
|-------|-------------|---------------|
|
||||||
|
| `MINOR` | Basic affixes | RARE+ |
|
||||||
|
| `MAJOR` | Stronger affixes | RARE+ (higher weight at EPIC+) |
|
||||||
|
| `LEGENDARY` | Most powerful affixes | LEGENDARY only |
|
||||||
|
|
||||||
|
### Item Generation Service
|
||||||
|
|
||||||
|
**Location:** `/app/services/item_generator.py`
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```python
|
||||||
|
from app.services.item_generator import get_item_generator
|
||||||
|
from app.models.enums import ItemRarity
|
||||||
|
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
# Generate specific item
|
||||||
|
item = generator.generate_item(
|
||||||
|
item_type="weapon",
|
||||||
|
rarity=ItemRarity.EPIC,
|
||||||
|
character_level=5
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate random loot drop with luck influence
|
||||||
|
item = generator.generate_loot_drop(
|
||||||
|
character_level=10,
|
||||||
|
luck_stat=12
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Related Loaders:**
|
||||||
|
- `AffixLoader` (`/app/services/affix_loader.py`) - Loads affix definitions from YAML
|
||||||
|
- `BaseItemLoader` (`/app/services/base_item_loader.py`) - Loads base templates from YAML
|
||||||
|
|
||||||
|
**Data Files:**
|
||||||
|
- `/app/data/affixes/prefixes.yaml` - Prefix definitions
|
||||||
|
- `/app/data/affixes/suffixes.yaml` - Suffix definitions
|
||||||
|
- `/app/data/base_items/weapons.yaml` - Weapon templates
|
||||||
|
- `/app/data/base_items/armor.yaml` - Armor templates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Ability
|
### Ability
|
||||||
|
|
||||||
Abilities represent actions that can be taken in combat (attacks, spells, skills, item uses).
|
Abilities represent actions that can be taken in combat (attacks, spells, skills, item uses).
|
||||||
|
|||||||
@@ -402,6 +402,111 @@ effects_applied:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Procedural Item Generation
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Weapons and armor are procedurally generated using a Diablo-style affix system.
|
||||||
|
Items are created by combining:
|
||||||
|
1. **Base Template** - Defines item type, base stats, level requirement
|
||||||
|
2. **Affixes** - Prefixes and suffixes that add stats and modify the name
|
||||||
|
|
||||||
|
### Generation Process
|
||||||
|
|
||||||
|
1. Select base template (filtered by level, rarity)
|
||||||
|
2. Determine affix count based on rarity (0-3)
|
||||||
|
3. Roll affix tier based on rarity weights
|
||||||
|
4. Select random affixes avoiding duplicates
|
||||||
|
5. Combine stats and generate name
|
||||||
|
|
||||||
|
### Rarity System
|
||||||
|
|
||||||
|
| Rarity | Affixes | Value Multiplier | Color |
|
||||||
|
|--------|---------|------------------|-------|
|
||||||
|
| COMMON | 0 | 1.0× | Gray |
|
||||||
|
| UNCOMMON | 0 | 1.5× | Green |
|
||||||
|
| RARE | 1 | 2.5× | Blue |
|
||||||
|
| EPIC | 2 | 5.0× | Purple |
|
||||||
|
| LEGENDARY | 3 | 10.0× | Orange |
|
||||||
|
|
||||||
|
### Affix Distribution
|
||||||
|
|
||||||
|
| Rarity | Affix Count | Distribution |
|
||||||
|
|--------|-------------|--------------|
|
||||||
|
| RARE | 1 | 50% prefix OR 50% suffix |
|
||||||
|
| EPIC | 2 | 1 prefix AND 1 suffix |
|
||||||
|
| LEGENDARY | 3 | Mix (2+1 or 1+2) |
|
||||||
|
|
||||||
|
### Affix Tiers
|
||||||
|
|
||||||
|
Higher rarity items have better chances at higher tier affixes:
|
||||||
|
|
||||||
|
| Rarity | MINOR | MAJOR | LEGENDARY |
|
||||||
|
|--------|-------|-------|-----------|
|
||||||
|
| RARE | 80% | 20% | 0% |
|
||||||
|
| EPIC | 30% | 70% | 0% |
|
||||||
|
| LEGENDARY | 10% | 40% | 50% |
|
||||||
|
|
||||||
|
### Name Generation Examples
|
||||||
|
|
||||||
|
- **COMMON:** "Dagger"
|
||||||
|
- **RARE (prefix):** "Flaming Dagger"
|
||||||
|
- **RARE (suffix):** "Dagger of Strength"
|
||||||
|
- **EPIC:** "Flaming Dagger of Strength"
|
||||||
|
- **LEGENDARY:** "Blazing Glacial Dagger of the Titan"
|
||||||
|
|
||||||
|
### Luck Influence
|
||||||
|
|
||||||
|
Player's LUK stat affects rarity rolls for loot drops:
|
||||||
|
|
||||||
|
**Base chances at LUK 8:**
|
||||||
|
- COMMON: 50%
|
||||||
|
- UNCOMMON: 30%
|
||||||
|
- RARE: 15%
|
||||||
|
- EPIC: 4%
|
||||||
|
- LEGENDARY: 1%
|
||||||
|
|
||||||
|
**Luck Bonus:**
|
||||||
|
Each point of LUK above 8 adds +0.5% to higher rarity chances.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
- LUK 8 (baseline): 1% legendary chance
|
||||||
|
- LUK 12: ~3% legendary chance
|
||||||
|
- LUK 16: ~5% legendary chance
|
||||||
|
|
||||||
|
### Service Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.services.item_generator import get_item_generator
|
||||||
|
from app.models.enums import ItemRarity
|
||||||
|
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
# Generate item of specific rarity
|
||||||
|
sword = generator.generate_item(
|
||||||
|
item_type="weapon",
|
||||||
|
rarity=ItemRarity.EPIC,
|
||||||
|
character_level=5
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate random loot with luck bonus
|
||||||
|
loot = generator.generate_loot_drop(
|
||||||
|
character_level=10,
|
||||||
|
luck_stat=15
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `/app/data/base_items/weapons.yaml` | 13 weapon templates |
|
||||||
|
| `/app/data/base_items/armor.yaml` | 12 armor templates |
|
||||||
|
| `/app/data/affixes/prefixes.yaml` | 18 prefix affixes |
|
||||||
|
| `/app/data/affixes/suffixes.yaml` | 11 suffix affixes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quest System (Future)
|
## Quest System (Future)
|
||||||
|
|
||||||
### Quest Types
|
### Quest Types
|
||||||
|
|||||||
245
api/scripts/migrate_combat_data.py
Normal file
245
api/scripts/migrate_combat_data.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Combat Data Migration Script.
|
||||||
|
|
||||||
|
This script migrates existing inline combat encounter data from game_sessions
|
||||||
|
to the dedicated combat_encounters table.
|
||||||
|
|
||||||
|
The migration is idempotent - it's safe to run multiple times. Sessions that
|
||||||
|
have already been migrated (have active_combat_encounter_id) are skipped.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/migrate_combat_data.py
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- Run this after deploying the new combat database schema
|
||||||
|
- The application handles automatic migration on-demand, so this is optional
|
||||||
|
- This script is useful for proactively migrating all data at once
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
project_root = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables before importing app modules
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from app.services.database_service import get_database_service
|
||||||
|
from app.services.combat_repository import get_combat_repository
|
||||||
|
from app.models.session import GameSession
|
||||||
|
from app.models.combat import CombatEncounter
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_inline_combat_encounters() -> dict:
|
||||||
|
"""
|
||||||
|
Migrate all inline combat encounters to the dedicated table.
|
||||||
|
|
||||||
|
Scans all game sessions for inline combat_encounter data and migrates
|
||||||
|
them to the combat_encounters table. Updates sessions to use the new
|
||||||
|
active_combat_encounter_id reference.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with migration statistics:
|
||||||
|
- total_sessions: Number of sessions scanned
|
||||||
|
- migrated: Number of sessions with combat data migrated
|
||||||
|
- skipped: Number of sessions already migrated or without combat
|
||||||
|
- errors: Number of sessions that failed to migrate
|
||||||
|
"""
|
||||||
|
db = get_database_service()
|
||||||
|
repo = get_combat_repository()
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'total_sessions': 0,
|
||||||
|
'migrated': 0,
|
||||||
|
'skipped': 0,
|
||||||
|
'errors': 0,
|
||||||
|
'error_details': []
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Scanning game_sessions for inline combat data...")
|
||||||
|
|
||||||
|
# Query all sessions (paginated)
|
||||||
|
offset = 0
|
||||||
|
limit = 100
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
rows = db.list_rows(
|
||||||
|
table_id='game_sessions',
|
||||||
|
limit=limit,
|
||||||
|
offset=offset
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to query sessions", error=str(e))
|
||||||
|
print(f"Error querying sessions: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
stats['total_sessions'] += 1
|
||||||
|
session_id = row.id
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse session data
|
||||||
|
session_json = row.data.get('sessionData', '{}')
|
||||||
|
session_data = json.loads(session_json)
|
||||||
|
|
||||||
|
# Check if already migrated (has reference, no inline data)
|
||||||
|
if (session_data.get('active_combat_encounter_id') and
|
||||||
|
not session_data.get('combat_encounter')):
|
||||||
|
stats['skipped'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if has inline combat data to migrate
|
||||||
|
combat_data = session_data.get('combat_encounter')
|
||||||
|
if not combat_data:
|
||||||
|
stats['skipped'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse combat encounter
|
||||||
|
encounter = CombatEncounter.from_dict(combat_data)
|
||||||
|
user_id = session_data.get('user_id', row.data.get('userId', ''))
|
||||||
|
|
||||||
|
logger.info("Migrating inline combat encounter",
|
||||||
|
session_id=session_id,
|
||||||
|
encounter_id=encounter.encounter_id)
|
||||||
|
|
||||||
|
# Check if encounter already exists in repository
|
||||||
|
existing = repo.get_encounter(encounter.encounter_id)
|
||||||
|
if existing:
|
||||||
|
# Already migrated, just update session reference
|
||||||
|
session_data['active_combat_encounter_id'] = encounter.encounter_id
|
||||||
|
session_data['combat_encounter'] = None
|
||||||
|
else:
|
||||||
|
# Save to repository
|
||||||
|
repo.create_encounter(
|
||||||
|
encounter=encounter,
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
session_data['active_combat_encounter_id'] = encounter.encounter_id
|
||||||
|
session_data['combat_encounter'] = None
|
||||||
|
|
||||||
|
# Update session
|
||||||
|
db.update_row(
|
||||||
|
table_id='game_sessions',
|
||||||
|
row_id=session_id,
|
||||||
|
data={'sessionData': json.dumps(session_data)}
|
||||||
|
)
|
||||||
|
|
||||||
|
stats['migrated'] += 1
|
||||||
|
print(f" Migrated: {session_id} -> {encounter.encounter_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
stats['errors'] += 1
|
||||||
|
error_msg = f"Session {session_id}: {str(e)}"
|
||||||
|
stats['error_details'].append(error_msg)
|
||||||
|
logger.error("Failed to migrate session",
|
||||||
|
session_id=session_id,
|
||||||
|
error=str(e))
|
||||||
|
print(f" Error: {session_id} - {e}")
|
||||||
|
|
||||||
|
offset += limit
|
||||||
|
|
||||||
|
# Safety check to prevent infinite loop
|
||||||
|
if offset > 10000:
|
||||||
|
print("Warning: Stopped after 10000 sessions (safety limit)")
|
||||||
|
break
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run the migration."""
|
||||||
|
print("=" * 60)
|
||||||
|
print("Code of Conquest - Combat Data Migration")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Verify environment variables
|
||||||
|
required_vars = [
|
||||||
|
'APPWRITE_ENDPOINT',
|
||||||
|
'APPWRITE_PROJECT_ID',
|
||||||
|
'APPWRITE_API_KEY',
|
||||||
|
'APPWRITE_DATABASE_ID'
|
||||||
|
]
|
||||||
|
|
||||||
|
missing_vars = [var for var in required_vars if not os.getenv(var)]
|
||||||
|
if missing_vars:
|
||||||
|
print("ERROR: Missing required environment variables:")
|
||||||
|
for var in missing_vars:
|
||||||
|
print(f" - {var}")
|
||||||
|
print()
|
||||||
|
print("Please ensure your .env file is configured correctly.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("Environment configuration:")
|
||||||
|
print(f" Endpoint: {os.getenv('APPWRITE_ENDPOINT')}")
|
||||||
|
print(f" Project: {os.getenv('APPWRITE_PROJECT_ID')}")
|
||||||
|
print(f" Database: {os.getenv('APPWRITE_DATABASE_ID')}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Confirm before proceeding
|
||||||
|
print("This script will migrate inline combat data to the dedicated")
|
||||||
|
print("combat_encounters table. This operation is safe and idempotent.")
|
||||||
|
print()
|
||||||
|
response = input("Proceed with migration? (y/N): ").strip().lower()
|
||||||
|
if response != 'y':
|
||||||
|
print("Migration cancelled.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Starting migration...")
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
stats = migrate_inline_combat_encounters()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("Migration Results")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
print(f"Total sessions scanned: {stats['total_sessions']}")
|
||||||
|
print(f"Successfully migrated: {stats['migrated']}")
|
||||||
|
print(f"Skipped (no combat): {stats['skipped']}")
|
||||||
|
print(f"Errors: {stats['errors']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if stats['error_details']:
|
||||||
|
print("Error details:")
|
||||||
|
for error in stats['error_details'][:10]: # Show first 10
|
||||||
|
print(f" - {error}")
|
||||||
|
if len(stats['error_details']) > 10:
|
||||||
|
print(f" ... and {len(stats['error_details']) - 10} more")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if stats['errors'] > 0:
|
||||||
|
print("Some sessions failed to migrate. Check logs for details.")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("Migration completed successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Migration failed", error=str(e))
|
||||||
|
print()
|
||||||
|
print(f"MIGRATION FAILED: {str(e)}")
|
||||||
|
print()
|
||||||
|
print("Check logs for details.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -452,3 +452,183 @@ def test_character_round_trip_serialization(basic_character):
|
|||||||
assert restored.unlocked_skills == basic_character.unlocked_skills
|
assert restored.unlocked_skills == basic_character.unlocked_skills
|
||||||
assert "weapon" in restored.equipped
|
assert "weapon" in restored.equipped
|
||||||
assert restored.equipped["weapon"].item_id == "sword"
|
assert restored.equipped["weapon"].item_id == "sword"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Equipment Combat Bonuses (Task 2.5)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def test_get_effective_stats_weapon_damage_bonus(basic_character):
|
||||||
|
"""Test that weapon damage is added to effective stats damage_bonus."""
|
||||||
|
# Create weapon with damage
|
||||||
|
weapon = Item(
|
||||||
|
item_id="iron_sword",
|
||||||
|
name="Iron Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
description="A sturdy iron sword",
|
||||||
|
damage=15, # 15 damage
|
||||||
|
)
|
||||||
|
|
||||||
|
basic_character.equipped["weapon"] = weapon
|
||||||
|
effective = basic_character.get_effective_stats()
|
||||||
|
|
||||||
|
# Base strength is 12, so base damage = int(12 * 0.75) = 9
|
||||||
|
# Weapon damage = 15
|
||||||
|
# Total damage property = 9 + 15 = 24
|
||||||
|
assert effective.damage_bonus == 15
|
||||||
|
assert effective.damage == 24 # int(12 * 0.75) + 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_effective_stats_armor_defense_bonus(basic_character):
|
||||||
|
"""Test that armor defense is added to effective stats defense_bonus."""
|
||||||
|
# Create armor with defense
|
||||||
|
armor = Item(
|
||||||
|
item_id="iron_chestplate",
|
||||||
|
name="Iron Chestplate",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
description="A sturdy iron chestplate",
|
||||||
|
defense=10,
|
||||||
|
resistance=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
basic_character.equipped["chest"] = armor
|
||||||
|
effective = basic_character.get_effective_stats()
|
||||||
|
|
||||||
|
# Base constitution is 14, so base defense = 14 // 2 = 7
|
||||||
|
# Armor defense = 10
|
||||||
|
# Total defense property = 7 + 10 = 17
|
||||||
|
assert effective.defense_bonus == 10
|
||||||
|
assert effective.defense == 17 # (14 // 2) + 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_effective_stats_armor_resistance_bonus(basic_character):
|
||||||
|
"""Test that armor resistance is added to effective stats resistance_bonus."""
|
||||||
|
# Create armor with resistance
|
||||||
|
robe = Item(
|
||||||
|
item_id="magic_robe",
|
||||||
|
name="Magic Robe",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
description="An enchanted robe",
|
||||||
|
defense=2,
|
||||||
|
resistance=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
basic_character.equipped["chest"] = robe
|
||||||
|
effective = basic_character.get_effective_stats()
|
||||||
|
|
||||||
|
# Base wisdom is 10, so base resistance = 10 // 2 = 5
|
||||||
|
# Armor resistance = 8
|
||||||
|
# Total resistance property = 5 + 8 = 13
|
||||||
|
assert effective.resistance_bonus == 8
|
||||||
|
assert effective.resistance == 13 # (10 // 2) + 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_effective_stats_multiple_armor_pieces(basic_character):
|
||||||
|
"""Test that multiple armor pieces stack their bonuses."""
|
||||||
|
# Create multiple armor pieces
|
||||||
|
helmet = Item(
|
||||||
|
item_id="iron_helmet",
|
||||||
|
name="Iron Helmet",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
description="Protects your head",
|
||||||
|
defense=5,
|
||||||
|
resistance=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
chestplate = Item(
|
||||||
|
item_id="iron_chestplate",
|
||||||
|
name="Iron Chestplate",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
description="Protects your torso",
|
||||||
|
defense=10,
|
||||||
|
resistance=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
boots = Item(
|
||||||
|
item_id="iron_boots",
|
||||||
|
name="Iron Boots",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
description="Protects your feet",
|
||||||
|
defense=3,
|
||||||
|
resistance=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
basic_character.equipped["helmet"] = helmet
|
||||||
|
basic_character.equipped["chest"] = chestplate
|
||||||
|
basic_character.equipped["boots"] = boots
|
||||||
|
|
||||||
|
effective = basic_character.get_effective_stats()
|
||||||
|
|
||||||
|
# Total defense bonus = 5 + 10 + 3 = 18
|
||||||
|
# Total resistance bonus = 2 + 3 + 1 = 6
|
||||||
|
assert effective.defense_bonus == 18
|
||||||
|
assert effective.resistance_bonus == 6
|
||||||
|
|
||||||
|
# Base constitution is 14: base defense = 7
|
||||||
|
# Base wisdom is 10: base resistance = 5
|
||||||
|
assert effective.defense == 25 # 7 + 18
|
||||||
|
assert effective.resistance == 11 # 5 + 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_effective_stats_weapon_and_armor_combined(basic_character):
|
||||||
|
"""Test that weapon damage and armor defense/resistance work together."""
|
||||||
|
# Create weapon
|
||||||
|
weapon = Item(
|
||||||
|
item_id="flaming_sword",
|
||||||
|
name="Flaming Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
description="A sword wreathed in flame",
|
||||||
|
damage=18,
|
||||||
|
stat_bonuses={"strength": 3}, # Also has stat bonus
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create armor
|
||||||
|
armor = Item(
|
||||||
|
item_id="dragon_armor",
|
||||||
|
name="Dragon Armor",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
description="Forged from dragon scales",
|
||||||
|
defense=15,
|
||||||
|
resistance=10,
|
||||||
|
stat_bonuses={"constitution": 2}, # Also has stat bonus
|
||||||
|
)
|
||||||
|
|
||||||
|
basic_character.equipped["weapon"] = weapon
|
||||||
|
basic_character.equipped["chest"] = armor
|
||||||
|
|
||||||
|
effective = basic_character.get_effective_stats()
|
||||||
|
|
||||||
|
# Weapon: damage=18, +3 STR
|
||||||
|
# Armor: defense=15, resistance=10, +2 CON
|
||||||
|
# Base STR=12 -> 12+3=15, damage = int(15 * 0.75) + 18 = 11 + 18 = 29
|
||||||
|
assert effective.strength == 15
|
||||||
|
assert effective.damage_bonus == 18
|
||||||
|
assert effective.damage == 29
|
||||||
|
|
||||||
|
# Base CON=14 -> 14+2=16, defense = (16//2) + 15 = 8 + 15 = 23
|
||||||
|
assert effective.constitution == 16
|
||||||
|
assert effective.defense_bonus == 15
|
||||||
|
assert effective.defense == 23
|
||||||
|
|
||||||
|
# Base WIS=10, resistance = (10//2) + 10 = 5 + 10 = 15
|
||||||
|
assert effective.resistance_bonus == 10
|
||||||
|
assert effective.resistance == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_effective_stats_no_equipment_bonuses(basic_character):
|
||||||
|
"""Test that bonus fields are zero when no equipment is equipped."""
|
||||||
|
effective = basic_character.get_effective_stats()
|
||||||
|
|
||||||
|
assert effective.damage_bonus == 0
|
||||||
|
assert effective.defense_bonus == 0
|
||||||
|
assert effective.resistance_bonus == 0
|
||||||
|
|
||||||
|
# Damage/defense/resistance should just be base stat derived values
|
||||||
|
# Base STR=12, damage = int(12 * 0.75) = 9
|
||||||
|
assert effective.damage == 9
|
||||||
|
|
||||||
|
# Base CON=14, defense = 14 // 2 = 7
|
||||||
|
assert effective.defense == 7
|
||||||
|
|
||||||
|
# Base WIS=10, resistance = 10 // 2 = 5
|
||||||
|
assert effective.resistance == 5
|
||||||
|
|||||||
376
api/tests/test_combat_api.py
Normal file
376
api/tests/test_combat_api.py
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for Combat API endpoints.
|
||||||
|
|
||||||
|
Tests the REST API endpoints for combat functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
from flask import Flask
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from app.api.combat import combat_bp
|
||||||
|
from app.models.combat import CombatEncounter, Combatant, CombatStatus
|
||||||
|
from app.models.stats import Stats
|
||||||
|
from app.models.enemy import EnemyTemplate, EnemyDifficulty
|
||||||
|
from app.services.combat_service import CombatService, ActionResult, CombatRewards
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test Fixtures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
"""Create test Flask application."""
|
||||||
|
app = create_app('development')
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
"""Create test client."""
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_stats():
|
||||||
|
"""Sample stats for testing."""
|
||||||
|
return Stats(
|
||||||
|
strength=12,
|
||||||
|
dexterity=14,
|
||||||
|
constitution=10,
|
||||||
|
intelligence=10,
|
||||||
|
wisdom=10,
|
||||||
|
charisma=10,
|
||||||
|
luck=10
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_combatant(sample_stats):
|
||||||
|
"""Sample player combatant."""
|
||||||
|
return Combatant(
|
||||||
|
combatant_id="test_char_001",
|
||||||
|
name="Test Hero",
|
||||||
|
is_player=True,
|
||||||
|
current_hp=50,
|
||||||
|
max_hp=50,
|
||||||
|
current_mp=30,
|
||||||
|
max_mp=30,
|
||||||
|
stats=sample_stats,
|
||||||
|
abilities=["basic_attack", "power_strike"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_enemy_combatant(sample_stats):
|
||||||
|
"""Sample enemy combatant."""
|
||||||
|
return Combatant(
|
||||||
|
combatant_id="test_goblin_0",
|
||||||
|
name="Test Goblin",
|
||||||
|
is_player=False,
|
||||||
|
current_hp=25,
|
||||||
|
max_hp=25,
|
||||||
|
current_mp=10,
|
||||||
|
max_mp=10,
|
||||||
|
stats=sample_stats,
|
||||||
|
abilities=["basic_attack"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_encounter(sample_combatant, sample_enemy_combatant):
|
||||||
|
"""Sample combat encounter."""
|
||||||
|
encounter = CombatEncounter(
|
||||||
|
encounter_id="test_encounter_001",
|
||||||
|
combatants=[sample_combatant, sample_enemy_combatant],
|
||||||
|
turn_order=[sample_combatant.combatant_id, sample_enemy_combatant.combatant_id],
|
||||||
|
round_number=1,
|
||||||
|
current_turn_index=0,
|
||||||
|
status=CombatStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
return encounter
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# List Enemies Endpoint Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestListEnemiesEndpoint:
|
||||||
|
"""Tests for GET /api/v1/combat/enemies endpoint."""
|
||||||
|
|
||||||
|
def test_list_enemies_success(self, client):
|
||||||
|
"""Test listing all enemy templates."""
|
||||||
|
response = client.get('/api/v1/combat/enemies')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert data['status'] == 200
|
||||||
|
assert 'result' in data
|
||||||
|
assert 'enemies' in data['result']
|
||||||
|
|
||||||
|
enemies = data['result']['enemies']
|
||||||
|
assert isinstance(enemies, list)
|
||||||
|
assert len(enemies) >= 6 # We have 6 sample enemies
|
||||||
|
|
||||||
|
# Verify enemy structure
|
||||||
|
enemy_ids = [e['enemy_id'] for e in enemies]
|
||||||
|
assert 'goblin' in enemy_ids
|
||||||
|
|
||||||
|
def test_list_enemies_filter_by_difficulty(self, client):
|
||||||
|
"""Test filtering enemies by difficulty."""
|
||||||
|
response = client.get('/api/v1/combat/enemies?difficulty=easy')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
enemies = data['result']['enemies']
|
||||||
|
for enemy in enemies:
|
||||||
|
assert enemy['difficulty'] == 'easy'
|
||||||
|
|
||||||
|
def test_list_enemies_filter_by_tag(self, client):
|
||||||
|
"""Test filtering enemies by tag."""
|
||||||
|
response = client.get('/api/v1/combat/enemies?tag=humanoid')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
enemies = data['result']['enemies']
|
||||||
|
for enemy in enemies:
|
||||||
|
assert 'humanoid' in [t.lower() for t in enemy['tags']]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Get Enemy Details Endpoint Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestGetEnemyEndpoint:
|
||||||
|
"""Tests for GET /api/v1/combat/enemies/<enemy_id> endpoint."""
|
||||||
|
|
||||||
|
def test_get_enemy_success(self, client):
|
||||||
|
"""Test getting enemy details."""
|
||||||
|
response = client.get('/api/v1/combat/enemies/goblin')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert data['status'] == 200
|
||||||
|
# Enemy data is returned directly in result (not nested under 'enemy' key)
|
||||||
|
assert data['result']['enemy_id'] == 'goblin'
|
||||||
|
assert 'base_stats' in data['result']
|
||||||
|
assert 'loot_table' in data['result']
|
||||||
|
|
||||||
|
def test_get_enemy_not_found(self, client):
|
||||||
|
"""Test getting non-existent enemy."""
|
||||||
|
response = client.get('/api/v1/combat/enemies/nonexistent_12345')
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 404
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Start Combat Endpoint Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestStartCombatEndpoint:
|
||||||
|
"""Tests for POST /api/v1/combat/start endpoint."""
|
||||||
|
|
||||||
|
def test_start_combat_requires_auth(self, client):
|
||||||
|
"""Test that start combat endpoint requires authentication."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/combat/start',
|
||||||
|
json={
|
||||||
|
'session_id': 'test_session_001',
|
||||||
|
'enemy_ids': ['goblin', 'goblin']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return 401 Unauthorized without valid session
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_start_combat_missing_session_id(self, client):
|
||||||
|
"""Test starting combat without session_id."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/combat/start',
|
||||||
|
json={'enemy_ids': ['goblin']},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code in [400, 401]
|
||||||
|
|
||||||
|
def test_start_combat_missing_enemies(self, client):
|
||||||
|
"""Test starting combat without enemies."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/combat/start',
|
||||||
|
json={'session_id': 'test_session'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code in [400, 401]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Execute Action Endpoint Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestExecuteActionEndpoint:
|
||||||
|
"""Tests for POST /api/v1/combat/<session_id>/action endpoint."""
|
||||||
|
|
||||||
|
def test_action_requires_auth(self, client):
|
||||||
|
"""Test that action endpoint requires authentication."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/combat/test_session/action',
|
||||||
|
json={
|
||||||
|
'action_type': 'attack',
|
||||||
|
'target_ids': ['enemy_001']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return 401 Unauthorized without valid session
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_action_missing_type(self, client):
|
||||||
|
"""Test action with missing action_type still requires auth."""
|
||||||
|
# Without auth, returns 401 regardless of payload issues
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/combat/test_session/action',
|
||||||
|
json={'target_ids': ['enemy_001']}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Enemy Turn Endpoint Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestEnemyTurnEndpoint:
|
||||||
|
"""Tests for POST /api/v1/combat/<session_id>/enemy-turn endpoint."""
|
||||||
|
|
||||||
|
def test_enemy_turn_requires_auth(self, client):
|
||||||
|
"""Test that enemy turn endpoint requires authentication."""
|
||||||
|
response = client.post('/api/v1/combat/test_session/enemy-turn')
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Flee Endpoint Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestFleeEndpoint:
|
||||||
|
"""Tests for POST /api/v1/combat/<session_id>/flee endpoint."""
|
||||||
|
|
||||||
|
def test_flee_requires_auth(self, client):
|
||||||
|
"""Test that flee endpoint requires authentication."""
|
||||||
|
response = client.post('/api/v1/combat/test_session/flee')
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Get Combat State Endpoint Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestGetCombatStateEndpoint:
|
||||||
|
"""Tests for GET /api/v1/combat/<session_id>/state endpoint."""
|
||||||
|
|
||||||
|
def test_state_requires_auth(self, client):
|
||||||
|
"""Test that state endpoint requires authentication."""
|
||||||
|
response = client.get('/api/v1/combat/test_session/state')
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# End Combat Endpoint Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestEndCombatEndpoint:
|
||||||
|
"""Tests for POST /api/v1/combat/<session_id>/end endpoint."""
|
||||||
|
|
||||||
|
def test_end_requires_auth(self, client):
|
||||||
|
"""Test that end combat endpoint requires authentication."""
|
||||||
|
response = client.post('/api/v1/combat/test_session/end')
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Response Format Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCombatAPIResponseFormat:
|
||||||
|
"""Tests for API response format consistency."""
|
||||||
|
|
||||||
|
def test_enemies_response_format(self, client):
|
||||||
|
"""Test that enemies list has standard response format."""
|
||||||
|
response = client.get('/api/v1/combat/enemies')
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
# Standard response fields
|
||||||
|
assert 'app' in data
|
||||||
|
assert 'version' in data
|
||||||
|
assert 'status' in data
|
||||||
|
assert 'timestamp' in data
|
||||||
|
assert 'result' in data
|
||||||
|
|
||||||
|
# Should not have error for successful request
|
||||||
|
assert data['error'] is None or 'error' not in data or data['error'] == {}
|
||||||
|
|
||||||
|
def test_enemy_details_response_format(self, client):
|
||||||
|
"""Test that enemy details has standard response format."""
|
||||||
|
response = client.get('/api/v1/combat/enemies/goblin')
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert data['status'] == 200
|
||||||
|
assert 'result' in data
|
||||||
|
|
||||||
|
# Enemy data is returned directly in result
|
||||||
|
enemy = data['result']
|
||||||
|
# Required enemy fields
|
||||||
|
assert 'enemy_id' in enemy
|
||||||
|
assert 'name' in enemy
|
||||||
|
assert 'description' in enemy
|
||||||
|
assert 'base_stats' in enemy
|
||||||
|
assert 'difficulty' in enemy
|
||||||
|
|
||||||
|
def test_not_found_response_format(self, client):
|
||||||
|
"""Test 404 response format."""
|
||||||
|
response = client.get('/api/v1/combat/enemies/nonexistent_enemy_xyz')
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert data['status'] == 404
|
||||||
|
assert 'error' in data
|
||||||
|
assert data['error'] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Content Type Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCombatAPIContentType:
|
||||||
|
"""Tests for content type handling."""
|
||||||
|
|
||||||
|
def test_json_content_type_response(self, client):
|
||||||
|
"""Test that API returns JSON content type."""
|
||||||
|
response = client.get('/api/v1/combat/enemies')
|
||||||
|
|
||||||
|
assert response.content_type == 'application/json'
|
||||||
|
|
||||||
|
def test_accepts_json_payload(self, client):
|
||||||
|
"""Test that API accepts JSON payloads."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/combat/start',
|
||||||
|
data=json.dumps({
|
||||||
|
'session_id': 'test',
|
||||||
|
'enemy_ids': ['goblin']
|
||||||
|
}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should process JSON (even if auth fails)
|
||||||
|
assert response.status_code in [200, 400, 401]
|
||||||
428
api/tests/test_combat_loot_service.py
Normal file
428
api/tests/test_combat_loot_service.py
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
"""
|
||||||
|
Tests for CombatLootService.
|
||||||
|
|
||||||
|
Tests the service that orchestrates loot generation from combat,
|
||||||
|
supporting both static and procedural loot drops.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from app.services.combat_loot_service import (
|
||||||
|
CombatLootService,
|
||||||
|
LootContext,
|
||||||
|
get_combat_loot_service,
|
||||||
|
DIFFICULTY_RARITY_BONUS,
|
||||||
|
LUCK_CONVERSION_FACTOR
|
||||||
|
)
|
||||||
|
from app.models.enemy import (
|
||||||
|
EnemyTemplate,
|
||||||
|
EnemyDifficulty,
|
||||||
|
LootEntry,
|
||||||
|
LootType
|
||||||
|
)
|
||||||
|
from app.models.stats import Stats
|
||||||
|
from app.models.items import Item
|
||||||
|
from app.models.enums import ItemType, ItemRarity
|
||||||
|
|
||||||
|
|
||||||
|
class TestLootContext:
|
||||||
|
"""Test LootContext dataclass."""
|
||||||
|
|
||||||
|
def test_default_values(self):
|
||||||
|
"""Test default context values."""
|
||||||
|
context = LootContext()
|
||||||
|
|
||||||
|
assert context.party_average_level == 1
|
||||||
|
assert context.enemy_difficulty == EnemyDifficulty.EASY
|
||||||
|
assert context.luck_stat == 8
|
||||||
|
assert context.loot_bonus == 0.0
|
||||||
|
|
||||||
|
def test_custom_values(self):
|
||||||
|
"""Test creating context with custom values."""
|
||||||
|
context = LootContext(
|
||||||
|
party_average_level=10,
|
||||||
|
enemy_difficulty=EnemyDifficulty.HARD,
|
||||||
|
luck_stat=15,
|
||||||
|
loot_bonus=0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
assert context.party_average_level == 10
|
||||||
|
assert context.enemy_difficulty == EnemyDifficulty.HARD
|
||||||
|
assert context.luck_stat == 15
|
||||||
|
assert context.loot_bonus == 0.1
|
||||||
|
|
||||||
|
|
||||||
|
class TestDifficultyBonuses:
|
||||||
|
"""Test difficulty rarity bonus constants."""
|
||||||
|
|
||||||
|
def test_easy_bonus(self):
|
||||||
|
"""Easy enemies have no bonus."""
|
||||||
|
assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.EASY] == 0.0
|
||||||
|
|
||||||
|
def test_medium_bonus(self):
|
||||||
|
"""Medium enemies have small bonus."""
|
||||||
|
assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.MEDIUM] == 0.05
|
||||||
|
|
||||||
|
def test_hard_bonus(self):
|
||||||
|
"""Hard enemies have moderate bonus."""
|
||||||
|
assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.HARD] == 0.15
|
||||||
|
|
||||||
|
def test_boss_bonus(self):
|
||||||
|
"""Boss enemies have large bonus."""
|
||||||
|
assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.BOSS] == 0.30
|
||||||
|
|
||||||
|
|
||||||
|
class TestCombatLootServiceInit:
|
||||||
|
"""Test service initialization."""
|
||||||
|
|
||||||
|
def test_init_uses_defaults(self):
|
||||||
|
"""Service should initialize with default dependencies."""
|
||||||
|
service = CombatLootService()
|
||||||
|
|
||||||
|
assert service.item_generator is not None
|
||||||
|
assert service.static_loader is not None
|
||||||
|
|
||||||
|
def test_singleton_returns_same_instance(self):
|
||||||
|
"""get_combat_loot_service should return singleton."""
|
||||||
|
service1 = get_combat_loot_service()
|
||||||
|
service2 = get_combat_loot_service()
|
||||||
|
|
||||||
|
assert service1 is service2
|
||||||
|
|
||||||
|
|
||||||
|
class TestCombatLootServiceEffectiveLuck:
|
||||||
|
"""Test effective luck calculation."""
|
||||||
|
|
||||||
|
def test_base_luck_no_bonus(self):
|
||||||
|
"""With no bonuses, effective luck equals base luck."""
|
||||||
|
service = CombatLootService()
|
||||||
|
|
||||||
|
entry = LootEntry(
|
||||||
|
loot_type=LootType.PROCEDURAL,
|
||||||
|
item_type="weapon",
|
||||||
|
rarity_bonus=0.0
|
||||||
|
)
|
||||||
|
context = LootContext(
|
||||||
|
luck_stat=8,
|
||||||
|
enemy_difficulty=EnemyDifficulty.EASY,
|
||||||
|
loot_bonus=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
effective = service._calculate_effective_luck(entry, context)
|
||||||
|
|
||||||
|
# No bonus, so effective should equal base
|
||||||
|
assert effective == 8
|
||||||
|
|
||||||
|
def test_difficulty_bonus_adds_luck(self):
|
||||||
|
"""Difficulty bonus should increase effective luck."""
|
||||||
|
service = CombatLootService()
|
||||||
|
|
||||||
|
entry = LootEntry(
|
||||||
|
loot_type=LootType.PROCEDURAL,
|
||||||
|
item_type="weapon",
|
||||||
|
rarity_bonus=0.0
|
||||||
|
)
|
||||||
|
context = LootContext(
|
||||||
|
luck_stat=8,
|
||||||
|
enemy_difficulty=EnemyDifficulty.BOSS, # 0.30 bonus
|
||||||
|
loot_bonus=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
effective = service._calculate_effective_luck(entry, context)
|
||||||
|
|
||||||
|
# Boss bonus = 0.30 * 20 = 6 extra luck
|
||||||
|
assert effective == 8 + 6
|
||||||
|
|
||||||
|
def test_entry_rarity_bonus_adds_luck(self):
|
||||||
|
"""Entry rarity bonus should increase effective luck."""
|
||||||
|
service = CombatLootService()
|
||||||
|
|
||||||
|
entry = LootEntry(
|
||||||
|
loot_type=LootType.PROCEDURAL,
|
||||||
|
item_type="weapon",
|
||||||
|
rarity_bonus=0.10 # Entry-specific bonus
|
||||||
|
)
|
||||||
|
context = LootContext(
|
||||||
|
luck_stat=8,
|
||||||
|
enemy_difficulty=EnemyDifficulty.EASY,
|
||||||
|
loot_bonus=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
effective = service._calculate_effective_luck(entry, context)
|
||||||
|
|
||||||
|
# 0.10 * 20 = 2 extra luck
|
||||||
|
assert effective == 8 + 2
|
||||||
|
|
||||||
|
def test_combined_bonuses(self):
|
||||||
|
"""All bonuses should stack."""
|
||||||
|
service = CombatLootService()
|
||||||
|
|
||||||
|
entry = LootEntry(
|
||||||
|
loot_type=LootType.PROCEDURAL,
|
||||||
|
item_type="weapon",
|
||||||
|
rarity_bonus=0.10
|
||||||
|
)
|
||||||
|
context = LootContext(
|
||||||
|
luck_stat=10,
|
||||||
|
enemy_difficulty=EnemyDifficulty.HARD, # 0.15
|
||||||
|
loot_bonus=0.05
|
||||||
|
)
|
||||||
|
|
||||||
|
effective = service._calculate_effective_luck(entry, context)
|
||||||
|
|
||||||
|
# Total bonus = 0.10 + 0.15 + 0.05 = 0.30
|
||||||
|
# Extra luck = 0.30 * 20 = 6
|
||||||
|
expected = 10 + 6
|
||||||
|
assert effective == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestCombatLootServiceStaticItems:
|
||||||
|
"""Test static item generation."""
|
||||||
|
|
||||||
|
def test_generate_static_items_returns_items(self):
|
||||||
|
"""Should return Item instances for static entries."""
|
||||||
|
service = CombatLootService()
|
||||||
|
|
||||||
|
entry = LootEntry(
|
||||||
|
loot_type=LootType.STATIC,
|
||||||
|
item_id="health_potion_small",
|
||||||
|
drop_chance=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
items = service._generate_static_items(entry, quantity=1)
|
||||||
|
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0].name == "Small Health Potion"
|
||||||
|
|
||||||
|
def test_generate_static_items_respects_quantity(self):
|
||||||
|
"""Should generate correct quantity of items."""
|
||||||
|
service = CombatLootService()
|
||||||
|
|
||||||
|
entry = LootEntry(
|
||||||
|
loot_type=LootType.STATIC,
|
||||||
|
item_id="goblin_ear",
|
||||||
|
drop_chance=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
items = service._generate_static_items(entry, quantity=3)
|
||||||
|
|
||||||
|
assert len(items) == 3
|
||||||
|
# All should be goblin ears with unique IDs
|
||||||
|
for item in items:
|
||||||
|
assert "goblin_ear" in item.item_id
|
||||||
|
|
||||||
|
def test_generate_static_items_missing_id(self):
|
||||||
|
"""Should return empty list if item_id is missing."""
|
||||||
|
service = CombatLootService()
|
||||||
|
|
||||||
|
entry = LootEntry(
|
||||||
|
loot_type=LootType.STATIC,
|
||||||
|
item_id=None,
|
||||||
|
drop_chance=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
items = service._generate_static_items(entry, quantity=1)
|
||||||
|
|
||||||
|
assert len(items) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCombatLootServiceProceduralItems:
|
||||||
|
"""Test procedural item generation."""
|
||||||
|
|
||||||
|
def test_generate_procedural_items_returns_items(self):
|
||||||
|
"""Should return generated Item instances."""
|
||||||
|
service = CombatLootService()
|
||||||
|
|
||||||
|
entry = LootEntry(
|
||||||
|
loot_type=LootType.PROCEDURAL,
|
||||||
|
item_type="weapon",
|
||||||
|
drop_chance=1.0,
|
||||||
|
rarity_bonus=0.0
|
||||||
|
)
|
||||||
|
context = LootContext(party_average_level=5)
|
||||||
|
|
||||||
|
items = service._generate_procedural_items(entry, quantity=1, context=context)
|
||||||
|
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0].is_weapon()
|
||||||
|
|
||||||
|
def test_generate_procedural_armor(self):
|
||||||
|
"""Should generate armor when item_type is armor."""
|
||||||
|
service = CombatLootService()
|
||||||
|
|
||||||
|
entry = LootEntry(
|
||||||
|
loot_type=LootType.PROCEDURAL,
|
||||||
|
item_type="armor",
|
||||||
|
drop_chance=1.0
|
||||||
|
)
|
||||||
|
context = LootContext(party_average_level=5)
|
||||||
|
|
||||||
|
items = service._generate_procedural_items(entry, quantity=1, context=context)
|
||||||
|
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0].is_armor()
|
||||||
|
|
||||||
|
def test_generate_procedural_missing_type(self):
|
||||||
|
"""Should return empty list if item_type is missing."""
|
||||||
|
service = CombatLootService()
|
||||||
|
|
||||||
|
entry = LootEntry(
|
||||||
|
loot_type=LootType.PROCEDURAL,
|
||||||
|
item_type=None,
|
||||||
|
drop_chance=1.0
|
||||||
|
)
|
||||||
|
context = LootContext()
|
||||||
|
|
||||||
|
items = service._generate_procedural_items(entry, quantity=1, context=context)
|
||||||
|
|
||||||
|
assert len(items) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCombatLootServiceGenerateFromEnemy:
|
||||||
|
"""Test full loot generation from enemy templates."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_enemy(self):
|
||||||
|
"""Create a sample enemy template for testing."""
|
||||||
|
return EnemyTemplate(
|
||||||
|
enemy_id="test_goblin",
|
||||||
|
name="Test Goblin",
|
||||||
|
description="A test goblin",
|
||||||
|
base_stats=Stats(),
|
||||||
|
abilities=["basic_attack"],
|
||||||
|
loot_table=[
|
||||||
|
LootEntry(
|
||||||
|
loot_type=LootType.STATIC,
|
||||||
|
item_id="goblin_ear",
|
||||||
|
drop_chance=1.0, # Guaranteed drop for testing
|
||||||
|
quantity_min=1,
|
||||||
|
quantity_max=1
|
||||||
|
)
|
||||||
|
],
|
||||||
|
experience_reward=10,
|
||||||
|
difficulty=EnemyDifficulty.EASY
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_generate_loot_from_enemy_basic(self, sample_enemy):
|
||||||
|
"""Should generate loot from enemy loot table."""
|
||||||
|
service = CombatLootService()
|
||||||
|
context = LootContext()
|
||||||
|
|
||||||
|
items = service.generate_loot_from_enemy(sample_enemy, context)
|
||||||
|
|
||||||
|
assert len(items) == 1
|
||||||
|
assert "goblin_ear" in items[0].item_id
|
||||||
|
|
||||||
|
def test_generate_loot_respects_drop_chance(self):
|
||||||
|
"""Items with 0 drop chance should never drop."""
|
||||||
|
enemy = EnemyTemplate(
|
||||||
|
enemy_id="test_enemy",
|
||||||
|
name="Test Enemy",
|
||||||
|
description="Test",
|
||||||
|
base_stats=Stats(),
|
||||||
|
abilities=[],
|
||||||
|
loot_table=[
|
||||||
|
LootEntry(
|
||||||
|
loot_type=LootType.STATIC,
|
||||||
|
item_id="rare_item",
|
||||||
|
drop_chance=0.0, # Never drops
|
||||||
|
)
|
||||||
|
],
|
||||||
|
difficulty=EnemyDifficulty.EASY
|
||||||
|
)
|
||||||
|
service = CombatLootService()
|
||||||
|
context = LootContext()
|
||||||
|
|
||||||
|
# Run multiple times to ensure it never drops
|
||||||
|
for _ in range(10):
|
||||||
|
items = service.generate_loot_from_enemy(enemy, context)
|
||||||
|
assert len(items) == 0
|
||||||
|
|
||||||
|
def test_generate_loot_multiple_entries(self):
|
||||||
|
"""Should process all loot table entries."""
|
||||||
|
enemy = EnemyTemplate(
|
||||||
|
enemy_id="test_enemy",
|
||||||
|
name="Test Enemy",
|
||||||
|
description="Test",
|
||||||
|
base_stats=Stats(),
|
||||||
|
abilities=[],
|
||||||
|
loot_table=[
|
||||||
|
LootEntry(
|
||||||
|
loot_type=LootType.STATIC,
|
||||||
|
item_id="goblin_ear",
|
||||||
|
drop_chance=1.0,
|
||||||
|
),
|
||||||
|
LootEntry(
|
||||||
|
loot_type=LootType.STATIC,
|
||||||
|
item_id="health_potion_small",
|
||||||
|
drop_chance=1.0,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
difficulty=EnemyDifficulty.EASY
|
||||||
|
)
|
||||||
|
service = CombatLootService()
|
||||||
|
context = LootContext()
|
||||||
|
|
||||||
|
items = service.generate_loot_from_enemy(enemy, context)
|
||||||
|
|
||||||
|
assert len(items) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestCombatLootServiceBossLoot:
|
||||||
|
"""Test boss loot generation."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def boss_enemy(self):
|
||||||
|
"""Create a boss enemy template for testing."""
|
||||||
|
return EnemyTemplate(
|
||||||
|
enemy_id="test_boss",
|
||||||
|
name="Test Boss",
|
||||||
|
description="A test boss",
|
||||||
|
base_stats=Stats(strength=20, constitution=20),
|
||||||
|
abilities=["basic_attack"],
|
||||||
|
loot_table=[
|
||||||
|
LootEntry(
|
||||||
|
loot_type=LootType.STATIC,
|
||||||
|
item_id="goblin_chieftain_token",
|
||||||
|
drop_chance=1.0,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
experience_reward=100,
|
||||||
|
difficulty=EnemyDifficulty.BOSS
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_generate_boss_loot_includes_guaranteed_drops(self, boss_enemy):
|
||||||
|
"""Boss loot should include guaranteed equipment drops."""
|
||||||
|
service = CombatLootService()
|
||||||
|
context = LootContext(party_average_level=10)
|
||||||
|
|
||||||
|
items = service.generate_boss_loot(boss_enemy, context, guaranteed_drops=1)
|
||||||
|
|
||||||
|
# Should have at least the loot table drop + guaranteed drop
|
||||||
|
assert len(items) >= 2
|
||||||
|
|
||||||
|
def test_generate_boss_loot_non_boss_skips_guaranteed(self):
|
||||||
|
"""Non-boss enemies shouldn't get guaranteed drops."""
|
||||||
|
enemy = EnemyTemplate(
|
||||||
|
enemy_id="test_enemy",
|
||||||
|
name="Test Enemy",
|
||||||
|
description="Test",
|
||||||
|
base_stats=Stats(),
|
||||||
|
abilities=[],
|
||||||
|
loot_table=[
|
||||||
|
LootEntry(
|
||||||
|
loot_type=LootType.STATIC,
|
||||||
|
item_id="goblin_ear",
|
||||||
|
drop_chance=1.0,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
difficulty=EnemyDifficulty.EASY # Not a boss
|
||||||
|
)
|
||||||
|
service = CombatLootService()
|
||||||
|
context = LootContext()
|
||||||
|
|
||||||
|
items = service.generate_boss_loot(enemy, context, guaranteed_drops=2)
|
||||||
|
|
||||||
|
# Should only have the one loot table drop
|
||||||
|
assert len(items) == 1
|
||||||
657
api/tests/test_combat_service.py
Normal file
657
api/tests/test_combat_service.py
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for CombatService.
|
||||||
|
|
||||||
|
Tests combat lifecycle, action execution, and reward distribution.
|
||||||
|
Uses mocked dependencies to isolate combat logic testing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, MagicMock, patch
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.models.combat import Combatant, CombatEncounter
|
||||||
|
from app.models.character import Character
|
||||||
|
from app.models.stats import Stats
|
||||||
|
from app.models.enemy import EnemyTemplate, EnemyDifficulty
|
||||||
|
from app.models.enums import CombatStatus, AbilityType, DamageType
|
||||||
|
from app.models.abilities import Ability
|
||||||
|
from app.services.combat_service import (
|
||||||
|
CombatService,
|
||||||
|
CombatAction,
|
||||||
|
ActionResult,
|
||||||
|
CombatRewards,
|
||||||
|
NotInCombatError,
|
||||||
|
AlreadyInCombatError,
|
||||||
|
InvalidActionError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test Fixtures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_stats():
|
||||||
|
"""Create mock stats for testing."""
|
||||||
|
return Stats(
|
||||||
|
strength=12,
|
||||||
|
dexterity=10,
|
||||||
|
constitution=14,
|
||||||
|
intelligence=10,
|
||||||
|
wisdom=10,
|
||||||
|
charisma=8,
|
||||||
|
luck=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_character(mock_stats):
|
||||||
|
"""Create a mock character for testing."""
|
||||||
|
char = Mock(spec=Character)
|
||||||
|
char.character_id = "test_char_001"
|
||||||
|
char.name = "Test Hero"
|
||||||
|
char.user_id = "test_user"
|
||||||
|
char.level = 5
|
||||||
|
char.experience = 1000
|
||||||
|
char.gold = 100
|
||||||
|
char.unlocked_skills = ["power_strike"]
|
||||||
|
char.equipped = {} # No equipment by default
|
||||||
|
char.get_effective_stats = Mock(return_value=mock_stats)
|
||||||
|
return char
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_enemy_template():
|
||||||
|
"""Create a mock enemy template."""
|
||||||
|
return EnemyTemplate(
|
||||||
|
enemy_id="test_goblin",
|
||||||
|
name="Test Goblin",
|
||||||
|
description="A test goblin",
|
||||||
|
base_stats=Stats(
|
||||||
|
strength=8,
|
||||||
|
dexterity=12,
|
||||||
|
constitution=6,
|
||||||
|
intelligence=6,
|
||||||
|
wisdom=6,
|
||||||
|
charisma=4,
|
||||||
|
luck=8,
|
||||||
|
),
|
||||||
|
abilities=["basic_attack"],
|
||||||
|
experience_reward=15,
|
||||||
|
gold_reward_min=2,
|
||||||
|
gold_reward_max=8,
|
||||||
|
difficulty=EnemyDifficulty.EASY,
|
||||||
|
tags=["humanoid", "goblinoid"],
|
||||||
|
base_damage=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_combatant():
|
||||||
|
"""Create a mock player combatant."""
|
||||||
|
return Combatant(
|
||||||
|
combatant_id="test_char_001",
|
||||||
|
name="Test Hero",
|
||||||
|
is_player=True,
|
||||||
|
current_hp=38, # 10 + 14*2
|
||||||
|
max_hp=38,
|
||||||
|
current_mp=30, # 10 + 10*2
|
||||||
|
max_mp=30,
|
||||||
|
stats=Stats(
|
||||||
|
strength=12,
|
||||||
|
dexterity=10,
|
||||||
|
constitution=14,
|
||||||
|
intelligence=10,
|
||||||
|
wisdom=10,
|
||||||
|
charisma=8,
|
||||||
|
luck=8,
|
||||||
|
),
|
||||||
|
abilities=["basic_attack", "power_strike"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_enemy_combatant():
|
||||||
|
"""Create a mock enemy combatant."""
|
||||||
|
return Combatant(
|
||||||
|
combatant_id="test_goblin_0",
|
||||||
|
name="Test Goblin",
|
||||||
|
is_player=False,
|
||||||
|
current_hp=22, # 10 + 6*2
|
||||||
|
max_hp=22,
|
||||||
|
current_mp=22,
|
||||||
|
max_mp=22,
|
||||||
|
stats=Stats(
|
||||||
|
strength=8,
|
||||||
|
dexterity=12,
|
||||||
|
constitution=6,
|
||||||
|
intelligence=6,
|
||||||
|
wisdom=6,
|
||||||
|
charisma=4,
|
||||||
|
luck=8,
|
||||||
|
),
|
||||||
|
abilities=["basic_attack"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_encounter(mock_combatant, mock_enemy_combatant):
|
||||||
|
"""Create a mock combat encounter."""
|
||||||
|
encounter = CombatEncounter(
|
||||||
|
encounter_id="test_encounter_001",
|
||||||
|
combatants=[mock_combatant, mock_enemy_combatant],
|
||||||
|
)
|
||||||
|
encounter.initialize_combat()
|
||||||
|
return encounter
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_session(mock_encounter):
|
||||||
|
"""Create a mock game session."""
|
||||||
|
session = Mock()
|
||||||
|
session.session_id = "test_session_001"
|
||||||
|
session.solo_character_id = "test_char_001"
|
||||||
|
session.is_solo = Mock(return_value=True)
|
||||||
|
session.is_in_combat = Mock(return_value=False)
|
||||||
|
session.combat_encounter = None
|
||||||
|
session.start_combat = Mock()
|
||||||
|
session.end_combat = Mock()
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CombatAction Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCombatAction:
|
||||||
|
"""Tests for CombatAction dataclass."""
|
||||||
|
|
||||||
|
def test_create_attack_action(self):
|
||||||
|
"""Test creating an attack action."""
|
||||||
|
action = CombatAction(
|
||||||
|
action_type="attack",
|
||||||
|
target_ids=["enemy_1"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert action.action_type == "attack"
|
||||||
|
assert action.target_ids == ["enemy_1"]
|
||||||
|
assert action.ability_id is None
|
||||||
|
|
||||||
|
def test_create_ability_action(self):
|
||||||
|
"""Test creating an ability action."""
|
||||||
|
action = CombatAction(
|
||||||
|
action_type="ability",
|
||||||
|
target_ids=["enemy_1", "enemy_2"],
|
||||||
|
ability_id="fireball",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert action.action_type == "ability"
|
||||||
|
assert action.ability_id == "fireball"
|
||||||
|
assert len(action.target_ids) == 2
|
||||||
|
|
||||||
|
def test_from_dict(self):
|
||||||
|
"""Test creating action from dictionary."""
|
||||||
|
data = {
|
||||||
|
"action_type": "ability",
|
||||||
|
"target_ids": ["enemy_1"],
|
||||||
|
"ability_id": "heal",
|
||||||
|
}
|
||||||
|
|
||||||
|
action = CombatAction.from_dict(data)
|
||||||
|
|
||||||
|
assert action.action_type == "ability"
|
||||||
|
assert action.ability_id == "heal"
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
"""Test serializing action to dictionary."""
|
||||||
|
action = CombatAction(
|
||||||
|
action_type="defend",
|
||||||
|
target_ids=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
data = action.to_dict()
|
||||||
|
|
||||||
|
assert data["action_type"] == "defend"
|
||||||
|
assert data["target_ids"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ActionResult Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestActionResult:
|
||||||
|
"""Tests for ActionResult dataclass."""
|
||||||
|
|
||||||
|
def test_create_success_result(self):
|
||||||
|
"""Test creating a successful action result."""
|
||||||
|
result = ActionResult(
|
||||||
|
success=True,
|
||||||
|
message="Attack hits for 15 damage!",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert "15 damage" in result.message
|
||||||
|
assert result.combat_ended is False
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
"""Test serializing result to dictionary."""
|
||||||
|
result = ActionResult(
|
||||||
|
success=True,
|
||||||
|
message="Victory!",
|
||||||
|
combat_ended=True,
|
||||||
|
combat_status=CombatStatus.VICTORY,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = result.to_dict()
|
||||||
|
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["combat_ended"] is True
|
||||||
|
assert data["combat_status"] == "victory"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CombatRewards Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCombatRewards:
|
||||||
|
"""Tests for CombatRewards dataclass."""
|
||||||
|
|
||||||
|
def test_create_rewards(self):
|
||||||
|
"""Test creating combat rewards."""
|
||||||
|
rewards = CombatRewards(
|
||||||
|
experience=100,
|
||||||
|
gold=50,
|
||||||
|
items=[{"item_id": "sword", "quantity": 1}],
|
||||||
|
level_ups=["char_1"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rewards.experience == 100
|
||||||
|
assert rewards.gold == 50
|
||||||
|
assert len(rewards.items) == 1
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
"""Test serializing rewards to dictionary."""
|
||||||
|
rewards = CombatRewards(experience=50, gold=25)
|
||||||
|
data = rewards.to_dict()
|
||||||
|
|
||||||
|
assert data["experience"] == 50
|
||||||
|
assert data["gold"] == 25
|
||||||
|
assert data["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Combatant Creation Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCombatantCreation:
|
||||||
|
"""Tests for combatant creation methods."""
|
||||||
|
|
||||||
|
def test_create_combatant_from_character(self, mock_character):
|
||||||
|
"""Test creating a combatant from a player character."""
|
||||||
|
service = CombatService.__new__(CombatService)
|
||||||
|
|
||||||
|
combatant = service._create_combatant_from_character(mock_character)
|
||||||
|
|
||||||
|
assert combatant.combatant_id == mock_character.character_id
|
||||||
|
assert combatant.name == mock_character.name
|
||||||
|
assert combatant.is_player is True
|
||||||
|
assert combatant.current_hp == combatant.max_hp
|
||||||
|
assert "basic_attack" in combatant.abilities
|
||||||
|
|
||||||
|
def test_create_combatant_from_enemy(self, mock_enemy_template):
|
||||||
|
"""Test creating a combatant from an enemy template."""
|
||||||
|
service = CombatService.__new__(CombatService)
|
||||||
|
|
||||||
|
combatant = service._create_combatant_from_enemy(mock_enemy_template, instance_index=0)
|
||||||
|
|
||||||
|
assert combatant.combatant_id == "test_goblin_0"
|
||||||
|
assert combatant.name == mock_enemy_template.name
|
||||||
|
assert combatant.is_player is False
|
||||||
|
assert combatant.current_hp == combatant.max_hp
|
||||||
|
assert "basic_attack" in combatant.abilities
|
||||||
|
|
||||||
|
def test_create_multiple_enemy_instances(self, mock_enemy_template):
|
||||||
|
"""Test creating multiple instances of same enemy."""
|
||||||
|
service = CombatService.__new__(CombatService)
|
||||||
|
|
||||||
|
combatant1 = service._create_combatant_from_enemy(mock_enemy_template, instance_index=0)
|
||||||
|
combatant2 = service._create_combatant_from_enemy(mock_enemy_template, instance_index=1)
|
||||||
|
combatant3 = service._create_combatant_from_enemy(mock_enemy_template, instance_index=2)
|
||||||
|
|
||||||
|
# IDs should be unique
|
||||||
|
assert combatant1.combatant_id != combatant2.combatant_id
|
||||||
|
assert combatant2.combatant_id != combatant3.combatant_id
|
||||||
|
|
||||||
|
# Names should be numbered
|
||||||
|
assert "#" in combatant2.name
|
||||||
|
assert "#" in combatant3.name
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Combat Lifecycle Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCombatLifecycle:
|
||||||
|
"""Tests for combat lifecycle methods."""
|
||||||
|
|
||||||
|
@patch('app.services.combat_service.get_session_service')
|
||||||
|
@patch('app.services.combat_service.get_character_service')
|
||||||
|
@patch('app.services.combat_service.get_enemy_loader')
|
||||||
|
def test_start_combat_success(
|
||||||
|
self,
|
||||||
|
mock_get_enemy_loader,
|
||||||
|
mock_get_char_service,
|
||||||
|
mock_get_session_service,
|
||||||
|
mock_session,
|
||||||
|
mock_character,
|
||||||
|
mock_enemy_template,
|
||||||
|
):
|
||||||
|
"""Test starting combat successfully."""
|
||||||
|
# Setup mocks
|
||||||
|
mock_session_service = Mock()
|
||||||
|
mock_session_service.get_session.return_value = mock_session
|
||||||
|
mock_session_service.update_session = Mock()
|
||||||
|
mock_get_session_service.return_value = mock_session_service
|
||||||
|
|
||||||
|
mock_char_service = Mock()
|
||||||
|
mock_char_service.get_character.return_value = mock_character
|
||||||
|
mock_get_char_service.return_value = mock_char_service
|
||||||
|
|
||||||
|
mock_enemy_loader = Mock()
|
||||||
|
mock_enemy_loader.load_enemy.return_value = mock_enemy_template
|
||||||
|
mock_get_enemy_loader.return_value = mock_enemy_loader
|
||||||
|
|
||||||
|
# Create service and start combat
|
||||||
|
service = CombatService()
|
||||||
|
encounter = service.start_combat(
|
||||||
|
session_id="test_session",
|
||||||
|
user_id="test_user",
|
||||||
|
enemy_ids=["test_goblin"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert encounter is not None
|
||||||
|
assert encounter.status == CombatStatus.ACTIVE
|
||||||
|
assert len(encounter.combatants) == 2 # 1 player + 1 enemy
|
||||||
|
assert len(encounter.turn_order) == 2
|
||||||
|
mock_session.start_combat.assert_called_once()
|
||||||
|
|
||||||
|
@patch('app.services.combat_service.get_session_service')
|
||||||
|
@patch('app.services.combat_service.get_character_service')
|
||||||
|
@patch('app.services.combat_service.get_enemy_loader')
|
||||||
|
def test_start_combat_already_in_combat(
|
||||||
|
self,
|
||||||
|
mock_get_enemy_loader,
|
||||||
|
mock_get_char_service,
|
||||||
|
mock_get_session_service,
|
||||||
|
mock_session,
|
||||||
|
):
|
||||||
|
"""Test starting combat when already in combat."""
|
||||||
|
mock_session.is_in_combat.return_value = True
|
||||||
|
|
||||||
|
mock_session_service = Mock()
|
||||||
|
mock_session_service.get_session.return_value = mock_session
|
||||||
|
mock_get_session_service.return_value = mock_session_service
|
||||||
|
|
||||||
|
service = CombatService()
|
||||||
|
|
||||||
|
with pytest.raises(AlreadyInCombatError):
|
||||||
|
service.start_combat(
|
||||||
|
session_id="test_session",
|
||||||
|
user_id="test_user",
|
||||||
|
enemy_ids=["goblin"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch('app.services.combat_service.get_session_service')
|
||||||
|
def test_get_combat_state_not_in_combat(
|
||||||
|
self,
|
||||||
|
mock_get_session_service,
|
||||||
|
mock_session,
|
||||||
|
):
|
||||||
|
"""Test getting combat state when not in combat."""
|
||||||
|
mock_session.combat_encounter = None
|
||||||
|
|
||||||
|
mock_session_service = Mock()
|
||||||
|
mock_session_service.get_session.return_value = mock_session
|
||||||
|
mock_get_session_service.return_value = mock_session_service
|
||||||
|
|
||||||
|
service = CombatService()
|
||||||
|
result = service.get_combat_state("test_session", "test_user")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Attack Execution Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestAttackExecution:
|
||||||
|
"""Tests for attack action execution."""
|
||||||
|
|
||||||
|
def test_execute_attack_hit(self, mock_encounter, mock_combatant, mock_enemy_combatant):
|
||||||
|
"""Test executing a successful attack."""
|
||||||
|
service = CombatService.__new__(CombatService)
|
||||||
|
service.ability_loader = Mock()
|
||||||
|
|
||||||
|
# Mock attacker as current combatant
|
||||||
|
mock_encounter.turn_order = [mock_combatant.combatant_id, mock_enemy_combatant.combatant_id]
|
||||||
|
mock_encounter.current_turn_index = 0
|
||||||
|
|
||||||
|
result = service._execute_attack(
|
||||||
|
mock_encounter,
|
||||||
|
mock_combatant,
|
||||||
|
[mock_enemy_combatant.combatant_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert len(result.damage_results) == 1
|
||||||
|
# Damage should have been dealt (HP should be reduced)
|
||||||
|
|
||||||
|
def test_execute_attack_no_target(self, mock_encounter, mock_combatant):
|
||||||
|
"""Test attack with auto-targeting."""
|
||||||
|
service = CombatService.__new__(CombatService)
|
||||||
|
service.ability_loader = Mock()
|
||||||
|
|
||||||
|
result = service._execute_attack(
|
||||||
|
mock_encounter,
|
||||||
|
mock_combatant,
|
||||||
|
[] # No targets specified
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should auto-target and succeed
|
||||||
|
assert result.success is True
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Defend Action Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestDefendExecution:
|
||||||
|
"""Tests for defend action execution."""
|
||||||
|
|
||||||
|
def test_execute_defend(self, mock_encounter, mock_combatant):
|
||||||
|
"""Test executing a defend action."""
|
||||||
|
service = CombatService.__new__(CombatService)
|
||||||
|
|
||||||
|
initial_effects = len(mock_combatant.active_effects)
|
||||||
|
|
||||||
|
result = service._execute_defend(mock_encounter, mock_combatant)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert "defensive stance" in result.message.lower()
|
||||||
|
assert len(result.effects_applied) == 1
|
||||||
|
# Combatant should have a new effect
|
||||||
|
assert len(mock_combatant.active_effects) == initial_effects + 1
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Flee Action Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestFleeExecution:
|
||||||
|
"""Tests for flee action execution."""
|
||||||
|
|
||||||
|
def test_execute_flee_success(self, mock_encounter, mock_combatant, mock_session):
|
||||||
|
"""Test successful flee attempt."""
|
||||||
|
service = CombatService.__new__(CombatService)
|
||||||
|
|
||||||
|
# Force success by patching random
|
||||||
|
with patch('random.random', return_value=0.1): # Low roll = success
|
||||||
|
result = service._execute_flee(
|
||||||
|
mock_encounter,
|
||||||
|
mock_combatant,
|
||||||
|
mock_session,
|
||||||
|
"test_user"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.combat_ended is True
|
||||||
|
assert result.combat_status == CombatStatus.FLED
|
||||||
|
|
||||||
|
def test_execute_flee_failure(self, mock_encounter, mock_combatant, mock_session):
|
||||||
|
"""Test failed flee attempt."""
|
||||||
|
service = CombatService.__new__(CombatService)
|
||||||
|
|
||||||
|
# Force failure by patching random
|
||||||
|
with patch('random.random', return_value=0.9): # High roll = failure
|
||||||
|
result = service._execute_flee(
|
||||||
|
mock_encounter,
|
||||||
|
mock_combatant,
|
||||||
|
mock_session,
|
||||||
|
"test_user"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert result.combat_ended is False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Enemy AI Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestEnemyAI:
|
||||||
|
"""Tests for enemy AI logic."""
|
||||||
|
|
||||||
|
def test_choose_enemy_action(self, mock_encounter, mock_enemy_combatant):
|
||||||
|
"""Test enemy AI action selection."""
|
||||||
|
service = CombatService.__new__(CombatService)
|
||||||
|
|
||||||
|
action_type, targets = service._choose_enemy_action(
|
||||||
|
mock_encounter,
|
||||||
|
mock_enemy_combatant
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should choose attack or ability
|
||||||
|
assert action_type in ["attack", "ability"]
|
||||||
|
# Should target a player
|
||||||
|
assert len(targets) > 0
|
||||||
|
|
||||||
|
def test_choose_enemy_targets_lowest_hp(self, mock_encounter, mock_enemy_combatant):
|
||||||
|
"""Test that enemy AI targets lowest HP player."""
|
||||||
|
# Add another player with lower HP
|
||||||
|
low_hp_player = Combatant(
|
||||||
|
combatant_id="low_hp_player",
|
||||||
|
name="Wounded Hero",
|
||||||
|
is_player=True,
|
||||||
|
current_hp=5, # Very low HP
|
||||||
|
max_hp=38,
|
||||||
|
current_mp=30,
|
||||||
|
max_mp=30,
|
||||||
|
stats=Stats(),
|
||||||
|
abilities=["basic_attack"],
|
||||||
|
)
|
||||||
|
mock_encounter.combatants.append(low_hp_player)
|
||||||
|
|
||||||
|
service = CombatService.__new__(CombatService)
|
||||||
|
|
||||||
|
_, targets = service._choose_enemy_action(
|
||||||
|
mock_encounter,
|
||||||
|
mock_enemy_combatant
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should target the lowest HP player
|
||||||
|
assert targets[0] == "low_hp_player"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Combat End Condition Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCombatEndConditions:
|
||||||
|
"""Tests for combat end condition checking."""
|
||||||
|
|
||||||
|
def test_victory_when_all_enemies_dead(self, mock_encounter, mock_combatant, mock_enemy_combatant):
|
||||||
|
"""Test victory is detected when all enemies are dead."""
|
||||||
|
# Kill the enemy
|
||||||
|
mock_enemy_combatant.current_hp = 0
|
||||||
|
|
||||||
|
status = mock_encounter.check_end_condition()
|
||||||
|
|
||||||
|
assert status == CombatStatus.VICTORY
|
||||||
|
|
||||||
|
def test_defeat_when_all_players_dead(self, mock_encounter, mock_combatant, mock_enemy_combatant):
|
||||||
|
"""Test defeat is detected when all players are dead."""
|
||||||
|
# Kill the player
|
||||||
|
mock_combatant.current_hp = 0
|
||||||
|
|
||||||
|
status = mock_encounter.check_end_condition()
|
||||||
|
|
||||||
|
assert status == CombatStatus.DEFEAT
|
||||||
|
|
||||||
|
def test_active_when_both_alive(self, mock_encounter, mock_combatant, mock_enemy_combatant):
|
||||||
|
"""Test combat remains active when both sides have survivors."""
|
||||||
|
# Both alive
|
||||||
|
assert mock_combatant.current_hp > 0
|
||||||
|
assert mock_enemy_combatant.current_hp > 0
|
||||||
|
|
||||||
|
status = mock_encounter.check_end_condition()
|
||||||
|
|
||||||
|
assert status == CombatStatus.ACTIVE
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Rewards Calculation Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestRewardsCalculation:
|
||||||
|
"""Tests for reward distribution."""
|
||||||
|
|
||||||
|
def test_calculate_rewards_from_enemies(self, mock_encounter, mock_enemy_combatant):
|
||||||
|
"""Test reward calculation from defeated enemies."""
|
||||||
|
# Mark enemy as dead
|
||||||
|
mock_enemy_combatant.current_hp = 0
|
||||||
|
|
||||||
|
service = CombatService.__new__(CombatService)
|
||||||
|
service.enemy_loader = Mock()
|
||||||
|
service.character_service = Mock()
|
||||||
|
service.loot_service = Mock()
|
||||||
|
|
||||||
|
# Mock enemy template for rewards
|
||||||
|
mock_template = Mock()
|
||||||
|
mock_template.experience_reward = 50
|
||||||
|
mock_template.get_gold_reward.return_value = 25
|
||||||
|
mock_template.difficulty = Mock()
|
||||||
|
mock_template.difficulty.value = "easy"
|
||||||
|
mock_template.is_boss.return_value = False
|
||||||
|
service.enemy_loader.load_enemy.return_value = mock_template
|
||||||
|
|
||||||
|
# Mock loot service to return mock items
|
||||||
|
mock_item = Mock()
|
||||||
|
mock_item.to_dict.return_value = {"item_id": "sword", "quantity": 1}
|
||||||
|
service.loot_service.generate_loot_from_enemy.return_value = [mock_item]
|
||||||
|
|
||||||
|
mock_session = Mock()
|
||||||
|
mock_session.is_solo.return_value = True
|
||||||
|
mock_session.solo_character_id = "test_char"
|
||||||
|
|
||||||
|
mock_char = Mock()
|
||||||
|
mock_char.level = 1
|
||||||
|
mock_char.experience = 0
|
||||||
|
mock_char.gold = 0
|
||||||
|
service.character_service.get_character.return_value = mock_char
|
||||||
|
service.character_service.update_character = Mock()
|
||||||
|
|
||||||
|
rewards = service._calculate_rewards(mock_encounter, mock_session, "test_user")
|
||||||
|
|
||||||
|
assert rewards.experience == 50
|
||||||
|
assert rewards.gold == 25
|
||||||
|
assert len(rewards.items) == 1
|
||||||
674
api/tests/test_damage_calculator.py
Normal file
674
api/tests/test_damage_calculator.py
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the DamageCalculator service.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Hit chance calculations with LUK/DEX
|
||||||
|
- Critical hit chance calculations
|
||||||
|
- Damage variance with lucky rolls
|
||||||
|
- Physical damage formula
|
||||||
|
- Magical damage formula
|
||||||
|
- Elemental split damage
|
||||||
|
- Defense mitigation with minimum guarantee
|
||||||
|
- AoE damage calculations
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import random
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.models.stats import Stats
|
||||||
|
from app.models.enums import DamageType
|
||||||
|
from app.services.damage_calculator import (
|
||||||
|
DamageCalculator,
|
||||||
|
DamageResult,
|
||||||
|
CombatConstants,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Hit Chance Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestHitChance:
|
||||||
|
"""Tests for calculate_hit_chance()."""
|
||||||
|
|
||||||
|
def test_base_hit_chance_with_average_stats(self):
|
||||||
|
"""Test hit chance with average LUK (8) and DEX (10)."""
|
||||||
|
# LUK 8: miss = 10% - 4% = 6%
|
||||||
|
hit_chance = DamageCalculator.calculate_hit_chance(
|
||||||
|
attacker_luck=8,
|
||||||
|
defender_dexterity=10,
|
||||||
|
)
|
||||||
|
assert hit_chance == pytest.approx(0.94, abs=0.001)
|
||||||
|
|
||||||
|
def test_high_luck_reduces_miss_chance(self):
|
||||||
|
"""Test that high LUK reduces miss chance."""
|
||||||
|
# LUK 12: miss = 10% - 6% = 4%, but capped at 5%
|
||||||
|
hit_chance = DamageCalculator.calculate_hit_chance(
|
||||||
|
attacker_luck=12,
|
||||||
|
defender_dexterity=10,
|
||||||
|
)
|
||||||
|
assert hit_chance == pytest.approx(0.95, abs=0.001)
|
||||||
|
|
||||||
|
def test_miss_chance_hard_cap_at_five_percent(self):
|
||||||
|
"""Test that miss chance cannot go below 5% (hard cap)."""
|
||||||
|
# LUK 20: would be 10% - 10% = 0%, but capped at 5%
|
||||||
|
hit_chance = DamageCalculator.calculate_hit_chance(
|
||||||
|
attacker_luck=20,
|
||||||
|
defender_dexterity=10,
|
||||||
|
)
|
||||||
|
assert hit_chance == pytest.approx(0.95, abs=0.001)
|
||||||
|
|
||||||
|
def test_high_dex_increases_evasion(self):
|
||||||
|
"""Test that defender's high DEX increases miss chance."""
|
||||||
|
# LUK 8, DEX 15: miss = 10% - 4% + 1.25% = 7.25%
|
||||||
|
hit_chance = DamageCalculator.calculate_hit_chance(
|
||||||
|
attacker_luck=8,
|
||||||
|
defender_dexterity=15,
|
||||||
|
)
|
||||||
|
assert hit_chance == pytest.approx(0.9275, abs=0.001)
|
||||||
|
|
||||||
|
def test_dex_below_ten_has_no_evasion_bonus(self):
|
||||||
|
"""Test that DEX below 10 doesn't reduce attacker's hit chance."""
|
||||||
|
# DEX 5 should be same as DEX 10 (no negative evasion)
|
||||||
|
hit_low_dex = DamageCalculator.calculate_hit_chance(
|
||||||
|
attacker_luck=8,
|
||||||
|
defender_dexterity=5,
|
||||||
|
)
|
||||||
|
hit_base_dex = DamageCalculator.calculate_hit_chance(
|
||||||
|
attacker_luck=8,
|
||||||
|
defender_dexterity=10,
|
||||||
|
)
|
||||||
|
assert hit_low_dex == hit_base_dex
|
||||||
|
|
||||||
|
def test_skill_bonus_improves_hit_chance(self):
|
||||||
|
"""Test that skill bonus adds to hit chance."""
|
||||||
|
base_hit = DamageCalculator.calculate_hit_chance(
|
||||||
|
attacker_luck=8,
|
||||||
|
defender_dexterity=10,
|
||||||
|
)
|
||||||
|
skill_hit = DamageCalculator.calculate_hit_chance(
|
||||||
|
attacker_luck=8,
|
||||||
|
defender_dexterity=10,
|
||||||
|
skill_bonus=0.05, # 5% bonus
|
||||||
|
)
|
||||||
|
assert skill_hit > base_hit
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Critical Hit Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCritChance:
|
||||||
|
"""Tests for calculate_crit_chance()."""
|
||||||
|
|
||||||
|
def test_base_crit_with_average_luck(self):
|
||||||
|
"""Test crit chance with average LUK (8)."""
|
||||||
|
# Base 5% + LUK 8 * 0.5% = 5% + 4% = 9%
|
||||||
|
crit_chance = DamageCalculator.calculate_crit_chance(
|
||||||
|
attacker_luck=8,
|
||||||
|
)
|
||||||
|
assert crit_chance == pytest.approx(0.09, abs=0.001)
|
||||||
|
|
||||||
|
def test_high_luck_increases_crit(self):
|
||||||
|
"""Test that high LUK increases crit chance."""
|
||||||
|
# Base 5% + LUK 12 * 0.5% = 5% + 6% = 11%
|
||||||
|
crit_chance = DamageCalculator.calculate_crit_chance(
|
||||||
|
attacker_luck=12,
|
||||||
|
)
|
||||||
|
assert crit_chance == pytest.approx(0.11, abs=0.001)
|
||||||
|
|
||||||
|
def test_weapon_crit_stacks_with_luck(self):
|
||||||
|
"""Test that weapon crit chance stacks with LUK bonus."""
|
||||||
|
# Weapon 10% + LUK 12 * 0.5% = 10% + 6% = 16%
|
||||||
|
crit_chance = DamageCalculator.calculate_crit_chance(
|
||||||
|
attacker_luck=12,
|
||||||
|
weapon_crit_chance=0.10,
|
||||||
|
)
|
||||||
|
assert crit_chance == pytest.approx(0.16, abs=0.001)
|
||||||
|
|
||||||
|
def test_crit_chance_hard_cap_at_25_percent(self):
|
||||||
|
"""Test that crit chance is capped at 25%."""
|
||||||
|
# Weapon 20% + LUK 20 * 0.5% = 20% + 10% = 30%, but capped at 25%
|
||||||
|
crit_chance = DamageCalculator.calculate_crit_chance(
|
||||||
|
attacker_luck=20,
|
||||||
|
weapon_crit_chance=0.20,
|
||||||
|
)
|
||||||
|
assert crit_chance == pytest.approx(0.25, abs=0.001)
|
||||||
|
|
||||||
|
def test_skill_bonus_adds_to_crit(self):
|
||||||
|
"""Test that skill bonus adds to crit chance."""
|
||||||
|
base_crit = DamageCalculator.calculate_crit_chance(
|
||||||
|
attacker_luck=8,
|
||||||
|
)
|
||||||
|
skill_crit = DamageCalculator.calculate_crit_chance(
|
||||||
|
attacker_luck=8,
|
||||||
|
skill_bonus=0.05,
|
||||||
|
)
|
||||||
|
assert skill_crit == base_crit + 0.05
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Damage Variance Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestDamageVariance:
|
||||||
|
"""Tests for calculate_variance()."""
|
||||||
|
|
||||||
|
@patch('random.random')
|
||||||
|
@patch('random.uniform')
|
||||||
|
def test_normal_variance_roll(self, mock_uniform, mock_random):
|
||||||
|
"""Test normal variance roll (95%-105%)."""
|
||||||
|
# Not a lucky roll (random returns high value)
|
||||||
|
mock_random.return_value = 0.99
|
||||||
|
mock_uniform.return_value = 1.0
|
||||||
|
|
||||||
|
variance = DamageCalculator.calculate_variance(attacker_luck=8)
|
||||||
|
|
||||||
|
# Should call uniform with base variance range
|
||||||
|
mock_uniform.assert_called_with(
|
||||||
|
CombatConstants.BASE_VARIANCE_MIN,
|
||||||
|
CombatConstants.BASE_VARIANCE_MAX,
|
||||||
|
)
|
||||||
|
assert variance == 1.0
|
||||||
|
|
||||||
|
@patch('random.random')
|
||||||
|
@patch('random.uniform')
|
||||||
|
def test_lucky_variance_roll(self, mock_uniform, mock_random):
|
||||||
|
"""Test lucky variance roll (100%-110%)."""
|
||||||
|
# Lucky roll (random returns low value)
|
||||||
|
mock_random.return_value = 0.01
|
||||||
|
mock_uniform.return_value = 1.08
|
||||||
|
|
||||||
|
variance = DamageCalculator.calculate_variance(attacker_luck=8)
|
||||||
|
|
||||||
|
# Should call uniform with lucky variance range
|
||||||
|
mock_uniform.assert_called_with(
|
||||||
|
CombatConstants.LUCKY_VARIANCE_MIN,
|
||||||
|
CombatConstants.LUCKY_VARIANCE_MAX,
|
||||||
|
)
|
||||||
|
assert variance == 1.08
|
||||||
|
|
||||||
|
def test_high_luck_increases_lucky_chance(self):
|
||||||
|
"""Test that high LUK increases chance for lucky roll."""
|
||||||
|
# LUK 8: lucky chance = 5% + 2% = 7%
|
||||||
|
# LUK 12: lucky chance = 5% + 3% = 8%
|
||||||
|
# Run many iterations to verify probability
|
||||||
|
lucky_count_low = 0
|
||||||
|
lucky_count_high = 0
|
||||||
|
iterations = 10000
|
||||||
|
|
||||||
|
random.seed(42) # Reproducible
|
||||||
|
for _ in range(iterations):
|
||||||
|
variance = DamageCalculator.calculate_variance(8)
|
||||||
|
if variance >= 1.0:
|
||||||
|
lucky_count_low += 1
|
||||||
|
|
||||||
|
random.seed(42) # Same seed
|
||||||
|
for _ in range(iterations):
|
||||||
|
variance = DamageCalculator.calculate_variance(12)
|
||||||
|
if variance >= 1.0:
|
||||||
|
lucky_count_high += 1
|
||||||
|
|
||||||
|
# Higher LUK should have more lucky rolls
|
||||||
|
# Note: This is a statistical test, might have some variance
|
||||||
|
# Just verify the high LUK isn't dramatically lower
|
||||||
|
assert lucky_count_high >= lucky_count_low * 0.9
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Defense Mitigation Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestDefenseMitigation:
|
||||||
|
"""Tests for apply_defense()."""
|
||||||
|
|
||||||
|
def test_normal_defense_mitigation(self):
|
||||||
|
"""Test standard defense subtraction."""
|
||||||
|
# 20 damage - 5 defense = 15 damage
|
||||||
|
result = DamageCalculator.apply_defense(raw_damage=20, defense=5)
|
||||||
|
assert result == 15
|
||||||
|
|
||||||
|
def test_minimum_damage_guarantee(self):
|
||||||
|
"""Test that minimum 20% damage always goes through."""
|
||||||
|
# 20 damage - 18 defense = 2 damage (but min is 20% of 20 = 4)
|
||||||
|
result = DamageCalculator.apply_defense(raw_damage=20, defense=18)
|
||||||
|
assert result == 4
|
||||||
|
|
||||||
|
def test_defense_higher_than_damage(self):
|
||||||
|
"""Test when defense exceeds raw damage."""
|
||||||
|
# 10 damage - 100 defense = -90, but min is 20% of 10 = 2
|
||||||
|
result = DamageCalculator.apply_defense(raw_damage=10, defense=100)
|
||||||
|
assert result == 2
|
||||||
|
|
||||||
|
def test_absolute_minimum_damage_is_one(self):
|
||||||
|
"""Test that absolute minimum damage is 1."""
|
||||||
|
# 3 damage - 100 defense = negative, but 20% of 3 = 0.6, so min is 1
|
||||||
|
result = DamageCalculator.apply_defense(raw_damage=3, defense=100)
|
||||||
|
assert result == 1
|
||||||
|
|
||||||
|
def test_custom_minimum_ratio(self):
|
||||||
|
"""Test custom minimum damage ratio."""
|
||||||
|
# 20 damage with 30% minimum = at least 6 damage
|
||||||
|
result = DamageCalculator.apply_defense(
|
||||||
|
raw_damage=20,
|
||||||
|
defense=18,
|
||||||
|
min_damage_ratio=0.30,
|
||||||
|
)
|
||||||
|
assert result == 6
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Physical Damage Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestPhysicalDamage:
|
||||||
|
"""Tests for calculate_physical_damage()."""
|
||||||
|
|
||||||
|
def test_basic_physical_damage_formula(self):
|
||||||
|
"""Test the basic physical damage formula."""
|
||||||
|
# Formula: (stats.damage + ability_power) * Variance - DEF
|
||||||
|
# where stats.damage = int(STR * 0.75) + damage_bonus
|
||||||
|
attacker = Stats(strength=14, luck=0, damage_bonus=8) # Weapon damage in bonus
|
||||||
|
defender = Stats(constitution=10, dexterity=10) # DEF = 5
|
||||||
|
|
||||||
|
# Mock to ensure no miss and no crit, variance = 1.0
|
||||||
|
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||||
|
result = DamageCalculator.calculate_physical_damage(
|
||||||
|
attacker_stats=attacker,
|
||||||
|
defender_stats=defender,
|
||||||
|
)
|
||||||
|
|
||||||
|
# int(14 * 0.75) + 8 = 10 + 8 = 18, 18 - 5 DEF = 13
|
||||||
|
assert result.total_damage == 13
|
||||||
|
assert result.is_miss is False
|
||||||
|
assert result.is_critical is False
|
||||||
|
assert result.damage_type == DamageType.PHYSICAL
|
||||||
|
|
||||||
|
def test_physical_damage_miss(self):
|
||||||
|
"""Test that misses deal zero damage."""
|
||||||
|
attacker = Stats(strength=14, luck=0, damage_bonus=8) # Weapon damage in bonus
|
||||||
|
defender = Stats(dexterity=30) # Very high DEX
|
||||||
|
|
||||||
|
# Force a miss
|
||||||
|
with patch('random.random', return_value=0.99):
|
||||||
|
result = DamageCalculator.calculate_physical_damage(
|
||||||
|
attacker_stats=attacker,
|
||||||
|
defender_stats=defender,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.is_miss is True
|
||||||
|
assert result.total_damage == 0
|
||||||
|
assert "missed" in result.message.lower()
|
||||||
|
|
||||||
|
def test_physical_damage_critical_hit(self):
|
||||||
|
"""Test critical hit doubles damage."""
|
||||||
|
attacker = Stats(strength=14, luck=20, damage_bonus=8) # High LUK for crit, weapon in bonus
|
||||||
|
defender = Stats(constitution=10, dexterity=10)
|
||||||
|
|
||||||
|
# Force hit and crit
|
||||||
|
with patch('random.random', side_effect=[0.01, 0.01]): # Hit, then crit
|
||||||
|
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||||
|
result = DamageCalculator.calculate_physical_damage(
|
||||||
|
attacker_stats=attacker,
|
||||||
|
defender_stats=defender,
|
||||||
|
weapon_crit_multiplier=2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.is_critical is True
|
||||||
|
# Base: int(14 * 0.75) + 8 = 10 + 8 = 18
|
||||||
|
# Crit: 18 * 2 = 36
|
||||||
|
# After DEF 5: 36 - 5 = 31
|
||||||
|
assert result.total_damage == 31
|
||||||
|
assert "critical" in result.message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Magical Damage Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestMagicalDamage:
|
||||||
|
"""Tests for calculate_magical_damage()."""
|
||||||
|
|
||||||
|
def test_basic_magical_damage_formula(self):
|
||||||
|
"""Test the basic magical damage formula."""
|
||||||
|
# Formula: (Ability + INT * 0.75) * Variance - RES
|
||||||
|
attacker = Stats(intelligence=15, luck=0)
|
||||||
|
defender = Stats(wisdom=10, dexterity=10) # RES = 5
|
||||||
|
|
||||||
|
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||||
|
result = DamageCalculator.calculate_magical_damage(
|
||||||
|
attacker_stats=attacker,
|
||||||
|
defender_stats=defender,
|
||||||
|
ability_base_power=12,
|
||||||
|
damage_type=DamageType.FIRE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 12 + (15 * 0.75) = 12 + 11.25 = 23.25 -> 23 - 5 = 18
|
||||||
|
assert result.total_damage == 18
|
||||||
|
assert result.damage_type == DamageType.FIRE
|
||||||
|
assert result.is_miss is False
|
||||||
|
|
||||||
|
def test_spells_can_critically_hit(self):
|
||||||
|
"""Test that spells can crit (per user requirement)."""
|
||||||
|
attacker = Stats(intelligence=15, luck=20)
|
||||||
|
defender = Stats(wisdom=10, dexterity=10)
|
||||||
|
|
||||||
|
# Force hit and crit
|
||||||
|
with patch('random.random', side_effect=[0.01, 0.01]):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||||
|
result = DamageCalculator.calculate_magical_damage(
|
||||||
|
attacker_stats=attacker,
|
||||||
|
defender_stats=defender,
|
||||||
|
ability_base_power=12,
|
||||||
|
damage_type=DamageType.FIRE,
|
||||||
|
weapon_crit_multiplier=2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.is_critical is True
|
||||||
|
# Base: 12 + 15*0.75 = 23.25 -> 23
|
||||||
|
# Crit: 23 * 2 = 46
|
||||||
|
# After RES 5: 46 - 5 = 41
|
||||||
|
assert result.total_damage == 41
|
||||||
|
|
||||||
|
def test_magical_damage_with_different_types(self):
|
||||||
|
"""Test that different damage types are recorded correctly."""
|
||||||
|
attacker = Stats(intelligence=10)
|
||||||
|
defender = Stats(wisdom=10, dexterity=10)
|
||||||
|
|
||||||
|
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||||
|
for damage_type in [DamageType.ICE, DamageType.LIGHTNING, DamageType.HOLY]:
|
||||||
|
result = DamageCalculator.calculate_magical_damage(
|
||||||
|
attacker_stats=attacker,
|
||||||
|
defender_stats=defender,
|
||||||
|
ability_base_power=10,
|
||||||
|
damage_type=damage_type,
|
||||||
|
)
|
||||||
|
assert result.damage_type == damage_type
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Elemental Weapon (Split Damage) Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestElementalWeaponDamage:
|
||||||
|
"""Tests for calculate_elemental_weapon_damage()."""
|
||||||
|
|
||||||
|
def test_split_damage_calculation(self):
|
||||||
|
"""Test 70/30 physical/fire split damage."""
|
||||||
|
# Fire Sword: 70% physical, 30% fire
|
||||||
|
# Weapon contributes to both damage_bonus (physical) and spell_power_bonus (elemental)
|
||||||
|
attacker = Stats(strength=14, intelligence=8, luck=0, damage_bonus=15, spell_power_bonus=15)
|
||||||
|
defender = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5
|
||||||
|
|
||||||
|
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||||
|
result = DamageCalculator.calculate_elemental_weapon_damage(
|
||||||
|
attacker_stats=attacker,
|
||||||
|
defender_stats=defender,
|
||||||
|
weapon_crit_chance=0.05,
|
||||||
|
weapon_crit_multiplier=2.0,
|
||||||
|
physical_ratio=0.7,
|
||||||
|
elemental_ratio=0.3,
|
||||||
|
elemental_type=DamageType.FIRE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# stats.damage = int(14 * 0.75) + 15 = 10 + 15 = 25
|
||||||
|
# stats.spell_power = int(8 * 0.75) + 15 = 6 + 15 = 21
|
||||||
|
# Physical: 25 * 0.7 = 17.5 -> 17 - 5 DEF = 12
|
||||||
|
# Elemental: 21 * 0.3 = 6.3 -> 6 - 5 RES = 1
|
||||||
|
|
||||||
|
assert result.physical_damage > 0
|
||||||
|
assert result.elemental_damage >= 1 # At least minimum damage
|
||||||
|
assert result.total_damage == result.physical_damage + result.elemental_damage
|
||||||
|
assert result.elemental_type == DamageType.FIRE
|
||||||
|
|
||||||
|
def test_50_50_split_damage(self):
|
||||||
|
"""Test 50/50 physical/elemental split (Lightning Spear)."""
|
||||||
|
# Same stats and weapon bonuses means similar damage on both sides
|
||||||
|
attacker = Stats(strength=12, intelligence=12, luck=0, damage_bonus=20, spell_power_bonus=20)
|
||||||
|
defender = Stats(constitution=10, wisdom=10, dexterity=10)
|
||||||
|
|
||||||
|
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||||
|
result = DamageCalculator.calculate_elemental_weapon_damage(
|
||||||
|
attacker_stats=attacker,
|
||||||
|
defender_stats=defender,
|
||||||
|
weapon_crit_chance=0.05,
|
||||||
|
weapon_crit_multiplier=2.0,
|
||||||
|
physical_ratio=0.5,
|
||||||
|
elemental_ratio=0.5,
|
||||||
|
elemental_type=DamageType.LIGHTNING,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both components should be similar (same stat values and weapon bonuses)
|
||||||
|
assert abs(result.physical_damage - result.elemental_damage) <= 2
|
||||||
|
|
||||||
|
def test_elemental_crit_applies_to_both_components(self):
|
||||||
|
"""Test that crit multiplier applies to both damage types."""
|
||||||
|
attacker = Stats(strength=14, intelligence=8, luck=20, damage_bonus=15, spell_power_bonus=15)
|
||||||
|
defender = Stats(constitution=10, wisdom=10, dexterity=10)
|
||||||
|
|
||||||
|
# Force hit and crit
|
||||||
|
with patch('random.random', side_effect=[0.01, 0.01]):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||||
|
result = DamageCalculator.calculate_elemental_weapon_damage(
|
||||||
|
attacker_stats=attacker,
|
||||||
|
defender_stats=defender,
|
||||||
|
weapon_crit_chance=0.05,
|
||||||
|
weapon_crit_multiplier=2.0,
|
||||||
|
physical_ratio=0.7,
|
||||||
|
elemental_ratio=0.3,
|
||||||
|
elemental_type=DamageType.FIRE,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.is_critical is True
|
||||||
|
# Both components should be doubled
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AoE Damage Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestAoEDamage:
|
||||||
|
"""Tests for calculate_aoe_damage()."""
|
||||||
|
|
||||||
|
def test_aoe_full_damage_to_all_targets(self):
|
||||||
|
"""Test that AoE deals full damage to each target."""
|
||||||
|
attacker = Stats(intelligence=15, luck=0)
|
||||||
|
defenders = [
|
||||||
|
Stats(wisdom=10, dexterity=10),
|
||||||
|
Stats(wisdom=10, dexterity=10),
|
||||||
|
Stats(wisdom=10, dexterity=10),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||||
|
results = DamageCalculator.calculate_aoe_damage(
|
||||||
|
attacker_stats=attacker,
|
||||||
|
defender_stats_list=defenders,
|
||||||
|
ability_base_power=20,
|
||||||
|
damage_type=DamageType.FIRE,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(results) == 3
|
||||||
|
# All targets should take the same damage (same stats)
|
||||||
|
for result in results:
|
||||||
|
assert result.total_damage == results[0].total_damage
|
||||||
|
|
||||||
|
def test_aoe_independent_hit_checks(self):
|
||||||
|
"""Test that each target has independent hit/miss rolls."""
|
||||||
|
attacker = Stats(intelligence=15, luck=8)
|
||||||
|
defenders = [
|
||||||
|
Stats(wisdom=10, dexterity=10),
|
||||||
|
Stats(wisdom=10, dexterity=10),
|
||||||
|
]
|
||||||
|
|
||||||
|
# First target hit, second target miss
|
||||||
|
hit_sequence = [0.5, 0.99] # Below hit chance, above hit chance
|
||||||
|
with patch('random.random', side_effect=hit_sequence * 2): # Extra for crit checks
|
||||||
|
results = DamageCalculator.calculate_aoe_damage(
|
||||||
|
attacker_stats=attacker,
|
||||||
|
defender_stats_list=defenders,
|
||||||
|
ability_base_power=20,
|
||||||
|
damage_type=DamageType.FIRE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# At least verify we got results for both
|
||||||
|
assert len(results) == 2
|
||||||
|
|
||||||
|
def test_aoe_with_varying_resistance(self):
|
||||||
|
"""Test that AoE respects different resistances per target."""
|
||||||
|
attacker = Stats(intelligence=15, luck=0)
|
||||||
|
defenders = [
|
||||||
|
Stats(wisdom=10, dexterity=10), # RES = 5
|
||||||
|
Stats(wisdom=20, dexterity=10), # RES = 10
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||||
|
results = DamageCalculator.calculate_aoe_damage(
|
||||||
|
attacker_stats=attacker,
|
||||||
|
defender_stats_list=defenders,
|
||||||
|
ability_base_power=20,
|
||||||
|
damage_type=DamageType.FIRE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# First target (lower RES) should take more damage
|
||||||
|
assert results[0].total_damage > results[1].total_damage
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DamageResult Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestDamageResult:
|
||||||
|
"""Tests for DamageResult dataclass."""
|
||||||
|
|
||||||
|
def test_damage_result_to_dict(self):
|
||||||
|
"""Test serialization of DamageResult."""
|
||||||
|
result = DamageResult(
|
||||||
|
total_damage=25,
|
||||||
|
physical_damage=25,
|
||||||
|
elemental_damage=0,
|
||||||
|
damage_type=DamageType.PHYSICAL,
|
||||||
|
is_critical=True,
|
||||||
|
is_miss=False,
|
||||||
|
variance_roll=1.05,
|
||||||
|
raw_damage=30,
|
||||||
|
message="Dealt 25 physical damage. CRITICAL HIT!",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = result.to_dict()
|
||||||
|
|
||||||
|
assert data["total_damage"] == 25
|
||||||
|
assert data["physical_damage"] == 25
|
||||||
|
assert data["damage_type"] == "physical"
|
||||||
|
assert data["is_critical"] is True
|
||||||
|
assert data["is_miss"] is False
|
||||||
|
assert data["variance_roll"] == pytest.approx(1.05, abs=0.001)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Combat Constants Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCombatConstants:
|
||||||
|
"""Tests for CombatConstants configuration."""
|
||||||
|
|
||||||
|
def test_stat_scaling_factor(self):
|
||||||
|
"""Verify scaling factor is 0.75."""
|
||||||
|
assert CombatConstants.STAT_SCALING_FACTOR == 0.75
|
||||||
|
|
||||||
|
def test_miss_chance_hard_cap(self):
|
||||||
|
"""Verify miss chance hard cap is 5%."""
|
||||||
|
assert CombatConstants.MIN_MISS_CHANCE == 0.05
|
||||||
|
|
||||||
|
def test_crit_chance_cap(self):
|
||||||
|
"""Verify crit chance cap is 25%."""
|
||||||
|
assert CombatConstants.MAX_CRIT_CHANCE == 0.25
|
||||||
|
|
||||||
|
def test_minimum_damage_ratio(self):
|
||||||
|
"""Verify minimum damage ratio is 20%."""
|
||||||
|
assert CombatConstants.MIN_DAMAGE_RATIO == 0.20
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Integration Tests (Full Combat Flow)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCombatIntegration:
|
||||||
|
"""Integration tests for complete combat scenarios."""
|
||||||
|
|
||||||
|
def test_vanguard_attack_scenario(self):
|
||||||
|
"""Test Vanguard (STR 14) basic attack."""
|
||||||
|
# Vanguard: STR 14, LUK 8, equipped with Rusty Sword (8 damage)
|
||||||
|
vanguard = Stats(strength=14, dexterity=10, constitution=14, luck=8, damage_bonus=8)
|
||||||
|
goblin = Stats(constitution=10, dexterity=10) # DEF = 5
|
||||||
|
|
||||||
|
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||||
|
result = DamageCalculator.calculate_physical_damage(
|
||||||
|
attacker_stats=vanguard,
|
||||||
|
defender_stats=goblin,
|
||||||
|
)
|
||||||
|
|
||||||
|
# int(14 * 0.75) + 8 = 10 + 8 = 18, 18 - 5 DEF = 13
|
||||||
|
assert result.total_damage == 13
|
||||||
|
|
||||||
|
def test_arcanist_fireball_scenario(self):
|
||||||
|
"""Test Arcanist (INT 15) Fireball."""
|
||||||
|
# Arcanist: INT 15, LUK 9 (no staff equipped, pure ability damage)
|
||||||
|
arcanist = Stats(intelligence=15, dexterity=10, wisdom=12, luck=9)
|
||||||
|
goblin = Stats(wisdom=10, dexterity=10) # RES = 5
|
||||||
|
|
||||||
|
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||||
|
result = DamageCalculator.calculate_magical_damage(
|
||||||
|
attacker_stats=arcanist,
|
||||||
|
defender_stats=goblin,
|
||||||
|
ability_base_power=12, # Fireball base
|
||||||
|
damage_type=DamageType.FIRE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# stats.spell_power = int(15 * 0.75) + 0 = 11
|
||||||
|
# 11 + 12 (ability) = 23 - 5 RES = 18
|
||||||
|
assert result.total_damage == 18
|
||||||
|
|
||||||
|
def test_physical_vs_magical_balance(self):
|
||||||
|
"""Test that physical and magical damage are comparable."""
|
||||||
|
# Same-tier characters should deal similar damage
|
||||||
|
vanguard = Stats(strength=14, luck=8, damage_bonus=8) # Melee with weapon
|
||||||
|
arcanist = Stats(intelligence=15, luck=9) # Caster (no staff)
|
||||||
|
target = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5
|
||||||
|
|
||||||
|
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||||
|
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||||
|
phys_result = DamageCalculator.calculate_physical_damage(
|
||||||
|
attacker_stats=vanguard,
|
||||||
|
defender_stats=target,
|
||||||
|
)
|
||||||
|
magic_result = DamageCalculator.calculate_magical_damage(
|
||||||
|
attacker_stats=arcanist,
|
||||||
|
defender_stats=target,
|
||||||
|
ability_base_power=12,
|
||||||
|
damage_type=DamageType.FIRE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mage should deal slightly more (compensates for mana cost)
|
||||||
|
assert magic_result.total_damage >= phys_result.total_damage
|
||||||
|
# But not drastically more (within ~50%)
|
||||||
|
assert magic_result.total_damage <= phys_result.total_damage * 1.5
|
||||||
399
api/tests/test_enemy_loader.py
Normal file
399
api/tests/test_enemy_loader.py
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for EnemyTemplate model and EnemyLoader service.
|
||||||
|
|
||||||
|
Tests enemy loading, serialization, and filtering functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.models.enemy import EnemyTemplate, EnemyDifficulty, LootEntry
|
||||||
|
from app.models.stats import Stats
|
||||||
|
from app.services.enemy_loader import EnemyLoader
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EnemyTemplate Model Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestEnemyTemplate:
|
||||||
|
"""Tests for EnemyTemplate dataclass."""
|
||||||
|
|
||||||
|
def test_create_basic_enemy(self):
|
||||||
|
"""Test creating an enemy with minimal attributes."""
|
||||||
|
enemy = EnemyTemplate(
|
||||||
|
enemy_id="test_enemy",
|
||||||
|
name="Test Enemy",
|
||||||
|
description="A test enemy",
|
||||||
|
base_stats=Stats(strength=10, constitution=8),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert enemy.enemy_id == "test_enemy"
|
||||||
|
assert enemy.name == "Test Enemy"
|
||||||
|
assert enemy.base_stats.strength == 10
|
||||||
|
assert enemy.difficulty == EnemyDifficulty.EASY # Default
|
||||||
|
|
||||||
|
def test_enemy_with_full_attributes(self):
|
||||||
|
"""Test creating an enemy with all attributes."""
|
||||||
|
loot = [
|
||||||
|
LootEntry(item_id="sword", drop_chance=0.5),
|
||||||
|
LootEntry(item_id="gold", drop_chance=1.0, quantity_min=5, quantity_max=10),
|
||||||
|
]
|
||||||
|
|
||||||
|
enemy = EnemyTemplate(
|
||||||
|
enemy_id="goblin_boss",
|
||||||
|
name="Goblin Boss",
|
||||||
|
description="A fearsome goblin leader",
|
||||||
|
base_stats=Stats(strength=14, dexterity=12, constitution=12),
|
||||||
|
abilities=["basic_attack", "power_strike"],
|
||||||
|
loot_table=loot,
|
||||||
|
experience_reward=100,
|
||||||
|
gold_reward_min=20,
|
||||||
|
gold_reward_max=50,
|
||||||
|
difficulty=EnemyDifficulty.HARD,
|
||||||
|
tags=["humanoid", "goblinoid", "boss"],
|
||||||
|
base_damage=12,
|
||||||
|
crit_chance=0.15,
|
||||||
|
flee_chance=0.25,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert enemy.enemy_id == "goblin_boss"
|
||||||
|
assert enemy.experience_reward == 100
|
||||||
|
assert enemy.difficulty == EnemyDifficulty.HARD
|
||||||
|
assert len(enemy.loot_table) == 2
|
||||||
|
assert len(enemy.abilities) == 2
|
||||||
|
assert "boss" in enemy.tags
|
||||||
|
|
||||||
|
def test_is_boss(self):
|
||||||
|
"""Test boss detection."""
|
||||||
|
easy_enemy = EnemyTemplate(
|
||||||
|
enemy_id="minion",
|
||||||
|
name="Minion",
|
||||||
|
description="",
|
||||||
|
base_stats=Stats(),
|
||||||
|
difficulty=EnemyDifficulty.EASY,
|
||||||
|
)
|
||||||
|
boss_enemy = EnemyTemplate(
|
||||||
|
enemy_id="boss",
|
||||||
|
name="Boss",
|
||||||
|
description="",
|
||||||
|
base_stats=Stats(),
|
||||||
|
difficulty=EnemyDifficulty.BOSS,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not easy_enemy.is_boss()
|
||||||
|
assert boss_enemy.is_boss()
|
||||||
|
|
||||||
|
def test_has_tag(self):
|
||||||
|
"""Test tag checking."""
|
||||||
|
enemy = EnemyTemplate(
|
||||||
|
enemy_id="zombie",
|
||||||
|
name="Zombie",
|
||||||
|
description="",
|
||||||
|
base_stats=Stats(),
|
||||||
|
tags=["undead", "slow", "Humanoid"], # Mixed case
|
||||||
|
)
|
||||||
|
|
||||||
|
assert enemy.has_tag("undead")
|
||||||
|
assert enemy.has_tag("UNDEAD") # Case insensitive
|
||||||
|
assert enemy.has_tag("humanoid")
|
||||||
|
assert not enemy.has_tag("beast")
|
||||||
|
|
||||||
|
def test_get_gold_reward(self):
|
||||||
|
"""Test gold reward generation."""
|
||||||
|
enemy = EnemyTemplate(
|
||||||
|
enemy_id="test",
|
||||||
|
name="Test",
|
||||||
|
description="",
|
||||||
|
base_stats=Stats(),
|
||||||
|
gold_reward_min=10,
|
||||||
|
gold_reward_max=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run multiple times to check range
|
||||||
|
for _ in range(50):
|
||||||
|
gold = enemy.get_gold_reward()
|
||||||
|
assert 10 <= gold <= 20
|
||||||
|
|
||||||
|
def test_roll_loot_empty_table(self):
|
||||||
|
"""Test loot rolling with empty table."""
|
||||||
|
enemy = EnemyTemplate(
|
||||||
|
enemy_id="test",
|
||||||
|
name="Test",
|
||||||
|
description="",
|
||||||
|
base_stats=Stats(),
|
||||||
|
loot_table=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
drops = enemy.roll_loot()
|
||||||
|
assert drops == []
|
||||||
|
|
||||||
|
def test_roll_loot_guaranteed_drop(self):
|
||||||
|
"""Test loot rolling with guaranteed drop."""
|
||||||
|
enemy = EnemyTemplate(
|
||||||
|
enemy_id="test",
|
||||||
|
name="Test",
|
||||||
|
description="",
|
||||||
|
base_stats=Stats(),
|
||||||
|
loot_table=[
|
||||||
|
LootEntry(item_id="guaranteed_item", drop_chance=1.0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
drops = enemy.roll_loot()
|
||||||
|
assert len(drops) == 1
|
||||||
|
assert drops[0]["item_id"] == "guaranteed_item"
|
||||||
|
|
||||||
|
def test_serialization_round_trip(self):
|
||||||
|
"""Test that to_dict/from_dict preserves data."""
|
||||||
|
original = EnemyTemplate(
|
||||||
|
enemy_id="test_enemy",
|
||||||
|
name="Test Enemy",
|
||||||
|
description="A test description",
|
||||||
|
base_stats=Stats(strength=15, dexterity=12, luck=10),
|
||||||
|
abilities=["attack", "defend"],
|
||||||
|
loot_table=[
|
||||||
|
LootEntry(item_id="sword", drop_chance=0.5),
|
||||||
|
],
|
||||||
|
experience_reward=50,
|
||||||
|
gold_reward_min=10,
|
||||||
|
gold_reward_max=25,
|
||||||
|
difficulty=EnemyDifficulty.MEDIUM,
|
||||||
|
tags=["humanoid", "test"],
|
||||||
|
base_damage=8,
|
||||||
|
crit_chance=0.10,
|
||||||
|
flee_chance=0.40,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Serialize and deserialize
|
||||||
|
data = original.to_dict()
|
||||||
|
restored = EnemyTemplate.from_dict(data)
|
||||||
|
|
||||||
|
# Verify all fields match
|
||||||
|
assert restored.enemy_id == original.enemy_id
|
||||||
|
assert restored.name == original.name
|
||||||
|
assert restored.description == original.description
|
||||||
|
assert restored.base_stats.strength == original.base_stats.strength
|
||||||
|
assert restored.base_stats.luck == original.base_stats.luck
|
||||||
|
assert restored.abilities == original.abilities
|
||||||
|
assert len(restored.loot_table) == len(original.loot_table)
|
||||||
|
assert restored.experience_reward == original.experience_reward
|
||||||
|
assert restored.gold_reward_min == original.gold_reward_min
|
||||||
|
assert restored.gold_reward_max == original.gold_reward_max
|
||||||
|
assert restored.difficulty == original.difficulty
|
||||||
|
assert restored.tags == original.tags
|
||||||
|
assert restored.base_damage == original.base_damage
|
||||||
|
assert restored.crit_chance == pytest.approx(original.crit_chance)
|
||||||
|
assert restored.flee_chance == pytest.approx(original.flee_chance)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLootEntry:
|
||||||
|
"""Tests for LootEntry dataclass."""
|
||||||
|
|
||||||
|
def test_create_loot_entry(self):
|
||||||
|
"""Test creating a loot entry."""
|
||||||
|
entry = LootEntry(
|
||||||
|
item_id="gold_coin",
|
||||||
|
drop_chance=0.75,
|
||||||
|
quantity_min=5,
|
||||||
|
quantity_max=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert entry.item_id == "gold_coin"
|
||||||
|
assert entry.drop_chance == 0.75
|
||||||
|
assert entry.quantity_min == 5
|
||||||
|
assert entry.quantity_max == 15
|
||||||
|
|
||||||
|
def test_loot_entry_defaults(self):
|
||||||
|
"""Test loot entry default values."""
|
||||||
|
entry = LootEntry(item_id="item")
|
||||||
|
|
||||||
|
assert entry.drop_chance == 0.1
|
||||||
|
assert entry.quantity_min == 1
|
||||||
|
assert entry.quantity_max == 1
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EnemyLoader Service Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestEnemyLoader:
|
||||||
|
"""Tests for EnemyLoader service."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def loader(self):
|
||||||
|
"""Create an enemy loader with the actual data directory."""
|
||||||
|
return EnemyLoader()
|
||||||
|
|
||||||
|
def test_load_goblin(self, loader):
|
||||||
|
"""Test loading the goblin enemy."""
|
||||||
|
enemy = loader.load_enemy("goblin")
|
||||||
|
|
||||||
|
assert enemy is not None
|
||||||
|
assert enemy.enemy_id == "goblin"
|
||||||
|
assert enemy.name == "Goblin Scout"
|
||||||
|
assert enemy.difficulty == EnemyDifficulty.EASY
|
||||||
|
assert "humanoid" in enemy.tags
|
||||||
|
assert "goblinoid" in enemy.tags
|
||||||
|
|
||||||
|
def test_load_goblin_shaman(self, loader):
|
||||||
|
"""Test loading the goblin shaman."""
|
||||||
|
enemy = loader.load_enemy("goblin_shaman")
|
||||||
|
|
||||||
|
assert enemy is not None
|
||||||
|
assert enemy.enemy_id == "goblin_shaman"
|
||||||
|
assert enemy.base_stats.intelligence == 12 # Caster stats
|
||||||
|
assert "caster" in enemy.tags
|
||||||
|
|
||||||
|
def test_load_dire_wolf(self, loader):
|
||||||
|
"""Test loading the dire wolf."""
|
||||||
|
enemy = loader.load_enemy("dire_wolf")
|
||||||
|
|
||||||
|
assert enemy is not None
|
||||||
|
assert enemy.difficulty == EnemyDifficulty.MEDIUM
|
||||||
|
assert "beast" in enemy.tags
|
||||||
|
assert enemy.base_stats.strength == 14
|
||||||
|
|
||||||
|
def test_load_bandit(self, loader):
|
||||||
|
"""Test loading the bandit."""
|
||||||
|
enemy = loader.load_enemy("bandit")
|
||||||
|
|
||||||
|
assert enemy is not None
|
||||||
|
assert enemy.difficulty == EnemyDifficulty.MEDIUM
|
||||||
|
assert "rogue" in enemy.tags
|
||||||
|
assert enemy.crit_chance == 0.12
|
||||||
|
|
||||||
|
def test_load_skeleton_warrior(self, loader):
|
||||||
|
"""Test loading the skeleton warrior."""
|
||||||
|
enemy = loader.load_enemy("skeleton_warrior")
|
||||||
|
|
||||||
|
assert enemy is not None
|
||||||
|
assert "undead" in enemy.tags
|
||||||
|
assert "fearless" in enemy.tags
|
||||||
|
|
||||||
|
def test_load_orc_berserker(self, loader):
|
||||||
|
"""Test loading the orc berserker."""
|
||||||
|
enemy = loader.load_enemy("orc_berserker")
|
||||||
|
|
||||||
|
assert enemy is not None
|
||||||
|
assert enemy.difficulty == EnemyDifficulty.HARD
|
||||||
|
assert enemy.base_stats.strength == 18
|
||||||
|
assert enemy.base_damage == 15
|
||||||
|
|
||||||
|
def test_load_nonexistent_enemy(self, loader):
|
||||||
|
"""Test loading an enemy that doesn't exist."""
|
||||||
|
enemy = loader.load_enemy("nonexistent_enemy_12345")
|
||||||
|
|
||||||
|
assert enemy is None
|
||||||
|
|
||||||
|
def test_load_all_enemies(self, loader):
|
||||||
|
"""Test loading all enemies."""
|
||||||
|
enemies = loader.load_all_enemies()
|
||||||
|
|
||||||
|
# Should have at least our 6 sample enemies
|
||||||
|
assert len(enemies) >= 6
|
||||||
|
assert "goblin" in enemies
|
||||||
|
assert "goblin_shaman" in enemies
|
||||||
|
assert "dire_wolf" in enemies
|
||||||
|
assert "bandit" in enemies
|
||||||
|
assert "skeleton_warrior" in enemies
|
||||||
|
assert "orc_berserker" in enemies
|
||||||
|
|
||||||
|
def test_get_enemies_by_difficulty(self, loader):
|
||||||
|
"""Test filtering enemies by difficulty."""
|
||||||
|
loader.load_all_enemies() # Ensure loaded
|
||||||
|
|
||||||
|
easy_enemies = loader.get_enemies_by_difficulty(EnemyDifficulty.EASY)
|
||||||
|
medium_enemies = loader.get_enemies_by_difficulty(EnemyDifficulty.MEDIUM)
|
||||||
|
hard_enemies = loader.get_enemies_by_difficulty(EnemyDifficulty.HARD)
|
||||||
|
|
||||||
|
# Check we got enemies in each category
|
||||||
|
assert len(easy_enemies) >= 2 # goblin, goblin_shaman
|
||||||
|
assert len(medium_enemies) >= 3 # dire_wolf, bandit, skeleton_warrior
|
||||||
|
assert len(hard_enemies) >= 1 # orc_berserker
|
||||||
|
|
||||||
|
# Verify difficulty is correct
|
||||||
|
for enemy in easy_enemies:
|
||||||
|
assert enemy.difficulty == EnemyDifficulty.EASY
|
||||||
|
|
||||||
|
def test_get_enemies_by_tag(self, loader):
|
||||||
|
"""Test filtering enemies by tag."""
|
||||||
|
loader.load_all_enemies()
|
||||||
|
|
||||||
|
humanoids = loader.get_enemies_by_tag("humanoid")
|
||||||
|
undead = loader.get_enemies_by_tag("undead")
|
||||||
|
beasts = loader.get_enemies_by_tag("beast")
|
||||||
|
|
||||||
|
# Verify results
|
||||||
|
assert len(humanoids) >= 3 # goblin, goblin_shaman, bandit, orc
|
||||||
|
assert len(undead) >= 1 # skeleton_warrior
|
||||||
|
assert len(beasts) >= 1 # dire_wolf
|
||||||
|
|
||||||
|
# Verify tags
|
||||||
|
for enemy in humanoids:
|
||||||
|
assert enemy.has_tag("humanoid")
|
||||||
|
|
||||||
|
def test_get_random_enemies(self, loader):
|
||||||
|
"""Test random enemy selection."""
|
||||||
|
loader.load_all_enemies()
|
||||||
|
|
||||||
|
# Get 3 random enemies
|
||||||
|
random_enemies = loader.get_random_enemies(count=3)
|
||||||
|
|
||||||
|
assert len(random_enemies) == 3
|
||||||
|
# All should be EnemyTemplate instances
|
||||||
|
for enemy in random_enemies:
|
||||||
|
assert isinstance(enemy, EnemyTemplate)
|
||||||
|
|
||||||
|
def test_get_random_enemies_with_filters(self, loader):
|
||||||
|
"""Test random selection with difficulty filter."""
|
||||||
|
loader.load_all_enemies()
|
||||||
|
|
||||||
|
# Get only easy enemies
|
||||||
|
easy_enemies = loader.get_random_enemies(
|
||||||
|
count=5,
|
||||||
|
difficulty=EnemyDifficulty.EASY,
|
||||||
|
)
|
||||||
|
|
||||||
|
# All returned enemies should be easy
|
||||||
|
for enemy in easy_enemies:
|
||||||
|
assert enemy.difficulty == EnemyDifficulty.EASY
|
||||||
|
|
||||||
|
def test_cache_behavior(self, loader):
|
||||||
|
"""Test that caching works correctly."""
|
||||||
|
# Load an enemy twice
|
||||||
|
enemy1 = loader.load_enemy("goblin")
|
||||||
|
enemy2 = loader.load_enemy("goblin")
|
||||||
|
|
||||||
|
# Should be the same object (cached)
|
||||||
|
assert enemy1 is enemy2
|
||||||
|
|
||||||
|
# Clear cache
|
||||||
|
loader.clear_cache()
|
||||||
|
|
||||||
|
# Load again
|
||||||
|
enemy3 = loader.load_enemy("goblin")
|
||||||
|
|
||||||
|
# Should be a new object
|
||||||
|
assert enemy3 is not enemy1
|
||||||
|
assert enemy3.enemy_id == enemy1.enemy_id
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EnemyDifficulty Enum Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestEnemyDifficulty:
|
||||||
|
"""Tests for EnemyDifficulty enum."""
|
||||||
|
|
||||||
|
def test_difficulty_values(self):
|
||||||
|
"""Test difficulty enum values."""
|
||||||
|
assert EnemyDifficulty.EASY.value == "easy"
|
||||||
|
assert EnemyDifficulty.MEDIUM.value == "medium"
|
||||||
|
assert EnemyDifficulty.HARD.value == "hard"
|
||||||
|
assert EnemyDifficulty.BOSS.value == "boss"
|
||||||
|
|
||||||
|
def test_difficulty_from_string(self):
|
||||||
|
"""Test creating difficulty from string."""
|
||||||
|
assert EnemyDifficulty("easy") == EnemyDifficulty.EASY
|
||||||
|
assert EnemyDifficulty("hard") == EnemyDifficulty.HARD
|
||||||
462
api/tests/test_inventory_api.py
Normal file
462
api/tests/test_inventory_api.py
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for Inventory API endpoints.
|
||||||
|
|
||||||
|
Tests the REST API endpoints for inventory management functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
from flask import Flask
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from app.api.inventory import inventory_bp
|
||||||
|
from app.models.items import Item
|
||||||
|
from app.models.character import Character
|
||||||
|
from app.models.stats import Stats
|
||||||
|
from app.models.skills import PlayerClass
|
||||||
|
from app.models.origins import Origin
|
||||||
|
from app.models.enums import ItemType, ItemRarity, DamageType
|
||||||
|
from app.services.inventory_service import (
|
||||||
|
InventoryService,
|
||||||
|
ItemNotFoundError,
|
||||||
|
CannotEquipError,
|
||||||
|
InvalidSlotError,
|
||||||
|
CannotUseItemError,
|
||||||
|
InventoryFullError,
|
||||||
|
VALID_SLOTS,
|
||||||
|
)
|
||||||
|
from app.services.character_service import CharacterNotFound
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test Fixtures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
"""Create test Flask application."""
|
||||||
|
app = create_app('development')
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
"""Create test client."""
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_stats():
|
||||||
|
"""Sample stats for testing."""
|
||||||
|
return Stats(
|
||||||
|
strength=12,
|
||||||
|
dexterity=14,
|
||||||
|
constitution=10,
|
||||||
|
intelligence=10,
|
||||||
|
wisdom=10,
|
||||||
|
charisma=10,
|
||||||
|
luck=10
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_weapon():
|
||||||
|
"""Sample weapon item."""
|
||||||
|
return Item(
|
||||||
|
item_id="test_sword_001",
|
||||||
|
name="Iron Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.COMMON,
|
||||||
|
description="A sturdy iron sword",
|
||||||
|
value=50,
|
||||||
|
damage=8,
|
||||||
|
damage_type=DamageType.PHYSICAL,
|
||||||
|
crit_chance=0.05,
|
||||||
|
crit_multiplier=2.0,
|
||||||
|
required_level=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_armor():
|
||||||
|
"""Sample armor item."""
|
||||||
|
return Item(
|
||||||
|
item_id="test_helmet_001",
|
||||||
|
name="Iron Helmet",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
rarity=ItemRarity.COMMON,
|
||||||
|
description="A sturdy iron helmet",
|
||||||
|
value=30,
|
||||||
|
defense=5,
|
||||||
|
resistance=2,
|
||||||
|
required_level=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_consumable():
|
||||||
|
"""Sample consumable item."""
|
||||||
|
return Item(
|
||||||
|
item_id="health_potion_small",
|
||||||
|
name="Small Health Potion",
|
||||||
|
item_type=ItemType.CONSUMABLE,
|
||||||
|
rarity=ItemRarity.COMMON,
|
||||||
|
description="Restores a small amount of health",
|
||||||
|
value=25,
|
||||||
|
effects_on_use=[], # Simplified for testing
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_class():
|
||||||
|
"""Sample player class."""
|
||||||
|
return PlayerClass(
|
||||||
|
class_id="vanguard",
|
||||||
|
name="Vanguard",
|
||||||
|
description="A heavily armored warrior",
|
||||||
|
base_stats=Stats(
|
||||||
|
strength=14,
|
||||||
|
dexterity=10,
|
||||||
|
constitution=14,
|
||||||
|
intelligence=8,
|
||||||
|
wisdom=8,
|
||||||
|
charisma=10,
|
||||||
|
luck=10
|
||||||
|
),
|
||||||
|
skill_trees=[],
|
||||||
|
starting_equipment=[],
|
||||||
|
starting_abilities=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_origin():
|
||||||
|
"""Sample origin."""
|
||||||
|
return Origin(
|
||||||
|
id="soul_revenant",
|
||||||
|
name="Soul Revenant",
|
||||||
|
description="Returned from death",
|
||||||
|
starting_location={"area": "graveyard", "name": "Graveyard"},
|
||||||
|
narrative_hooks=[],
|
||||||
|
starting_bonus={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_character(sample_class, sample_origin, sample_weapon, sample_armor, sample_consumable):
|
||||||
|
"""Sample character with inventory."""
|
||||||
|
char = Character(
|
||||||
|
character_id="test_char_001",
|
||||||
|
user_id="test_user_001",
|
||||||
|
name="Test Hero",
|
||||||
|
player_class=sample_class,
|
||||||
|
origin=sample_origin,
|
||||||
|
level=5,
|
||||||
|
experience=0,
|
||||||
|
gold=100,
|
||||||
|
inventory=[sample_weapon, sample_armor, sample_consumable],
|
||||||
|
equipped={},
|
||||||
|
unlocked_skills=[],
|
||||||
|
)
|
||||||
|
return char
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GET Inventory Endpoint Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestGetInventoryEndpoint:
|
||||||
|
"""Tests for GET /api/v1/characters/<id>/inventory endpoint."""
|
||||||
|
|
||||||
|
def test_get_inventory_requires_auth(self, client):
|
||||||
|
"""Test that inventory endpoint requires authentication."""
|
||||||
|
response = client.get('/api/v1/characters/test_char_001/inventory')
|
||||||
|
|
||||||
|
# Should return 401 Unauthorized without valid session
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_get_inventory_character_not_found(self, client):
|
||||||
|
"""Test getting inventory for non-existent character returns 404 (after auth)."""
|
||||||
|
# Without auth, returns 401 regardless
|
||||||
|
response = client.get('/api/v1/characters/nonexistent_12345/inventory')
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# POST Equip Endpoint Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestEquipEndpoint:
|
||||||
|
"""Tests for POST /api/v1/characters/<id>/inventory/equip endpoint."""
|
||||||
|
|
||||||
|
def test_equip_requires_auth(self, client):
|
||||||
|
"""Test that equip endpoint requires authentication."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/characters/test_char_001/inventory/equip',
|
||||||
|
json={
|
||||||
|
'item_id': 'test_sword_001',
|
||||||
|
'slot': 'weapon'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return 401 Unauthorized without valid session
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_equip_missing_item_id(self, client):
|
||||||
|
"""Test equip without item_id still requires auth first."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/characters/test_char_001/inventory/equip',
|
||||||
|
json={'slot': 'weapon'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Without auth, returns 401 regardless of payload issues
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_equip_missing_slot(self, client):
|
||||||
|
"""Test equip without slot still requires auth first."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/characters/test_char_001/inventory/equip',
|
||||||
|
json={'item_id': 'test_sword_001'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Without auth, returns 401 regardless of payload issues
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_equip_missing_body(self, client):
|
||||||
|
"""Test equip without request body still requires auth first."""
|
||||||
|
response = client.post('/api/v1/characters/test_char_001/inventory/equip')
|
||||||
|
|
||||||
|
# Without auth, returns 401 regardless of payload issues
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# POST Unequip Endpoint Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestUnequipEndpoint:
|
||||||
|
"""Tests for POST /api/v1/characters/<id>/inventory/unequip endpoint."""
|
||||||
|
|
||||||
|
def test_unequip_requires_auth(self, client):
|
||||||
|
"""Test that unequip endpoint requires authentication."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/characters/test_char_001/inventory/unequip',
|
||||||
|
json={'slot': 'weapon'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return 401 Unauthorized without valid session
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_unequip_missing_slot(self, client):
|
||||||
|
"""Test unequip without slot still requires auth first."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/characters/test_char_001/inventory/unequip',
|
||||||
|
json={}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Without auth, returns 401 regardless of payload issues
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_unequip_missing_body(self, client):
|
||||||
|
"""Test unequip without request body still requires auth first."""
|
||||||
|
response = client.post('/api/v1/characters/test_char_001/inventory/unequip')
|
||||||
|
|
||||||
|
# Without auth, returns 401 regardless of payload issues
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# POST Use Item Endpoint Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestUseItemEndpoint:
|
||||||
|
"""Tests for POST /api/v1/characters/<id>/inventory/use endpoint."""
|
||||||
|
|
||||||
|
def test_use_requires_auth(self, client):
|
||||||
|
"""Test that use item endpoint requires authentication."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/characters/test_char_001/inventory/use',
|
||||||
|
json={'item_id': 'health_potion_small'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return 401 Unauthorized without valid session
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_use_missing_item_id(self, client):
|
||||||
|
"""Test use item without item_id still requires auth first."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/characters/test_char_001/inventory/use',
|
||||||
|
json={}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Without auth, returns 401 regardless of payload issues
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_use_missing_body(self, client):
|
||||||
|
"""Test use item without request body still requires auth first."""
|
||||||
|
response = client.post('/api/v1/characters/test_char_001/inventory/use')
|
||||||
|
|
||||||
|
# Without auth, returns 401 regardless of payload issues
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DELETE Drop Item Endpoint Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestDropItemEndpoint:
|
||||||
|
"""Tests for DELETE /api/v1/characters/<id>/inventory/<item_id> endpoint."""
|
||||||
|
|
||||||
|
def test_drop_requires_auth(self, client):
|
||||||
|
"""Test that drop item endpoint requires authentication."""
|
||||||
|
response = client.delete(
|
||||||
|
'/api/v1/characters/test_char_001/inventory/test_sword_001'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return 401 Unauthorized without valid session
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Valid Slot Tests (Unit level)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestValidSlots:
|
||||||
|
"""Tests to verify slot configuration."""
|
||||||
|
|
||||||
|
def test_valid_slots_defined(self):
|
||||||
|
"""Test that all expected slots are defined."""
|
||||||
|
expected_slots = {
|
||||||
|
'weapon', 'off_hand', 'helmet', 'chest',
|
||||||
|
'gloves', 'boots', 'accessory_1', 'accessory_2'
|
||||||
|
}
|
||||||
|
assert VALID_SLOTS == expected_slots
|
||||||
|
|
||||||
|
def test_valid_slots_count(self):
|
||||||
|
"""Test that we have exactly 8 equipment slots."""
|
||||||
|
assert len(VALID_SLOTS) == 8
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Endpoint URL Pattern Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestEndpointURLPatterns:
|
||||||
|
"""Tests to verify correct URL patterns."""
|
||||||
|
|
||||||
|
def test_get_inventory_url(self, client):
|
||||||
|
"""Test GET inventory URL pattern."""
|
||||||
|
response = client.get('/api/v1/characters/any_id/inventory')
|
||||||
|
# Should be 401 (auth required), not 404 (route not found)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_equip_url(self, client):
|
||||||
|
"""Test POST equip URL pattern."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/characters/any_id/inventory/equip',
|
||||||
|
json={'item_id': 'x', 'slot': 'weapon'}
|
||||||
|
)
|
||||||
|
# Should be 401 (auth required), not 404 (route not found)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_unequip_url(self, client):
|
||||||
|
"""Test POST unequip URL pattern."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/characters/any_id/inventory/unequip',
|
||||||
|
json={'slot': 'weapon'}
|
||||||
|
)
|
||||||
|
# Should be 401 (auth required), not 404 (route not found)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_use_url(self, client):
|
||||||
|
"""Test POST use URL pattern."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/characters/any_id/inventory/use',
|
||||||
|
json={'item_id': 'x'}
|
||||||
|
)
|
||||||
|
# Should be 401 (auth required), not 404 (route not found)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_drop_url(self, client):
|
||||||
|
"""Test DELETE drop URL pattern."""
|
||||||
|
response = client.delete('/api/v1/characters/any_id/inventory/item_123')
|
||||||
|
# Should be 401 (auth required), not 404 (route not found)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Response Format Tests (verifying blueprint registration)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestResponseFormats:
|
||||||
|
"""Tests to verify API response format consistency."""
|
||||||
|
|
||||||
|
def test_get_inventory_401_format(self, client):
|
||||||
|
"""Test that 401 response follows standard format."""
|
||||||
|
response = client.get('/api/v1/characters/test_char_001/inventory')
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
# Standard response format should include status
|
||||||
|
assert data is not None
|
||||||
|
assert 'status' in data
|
||||||
|
assert data['status'] == 401
|
||||||
|
|
||||||
|
def test_equip_401_format(self, client):
|
||||||
|
"""Test that equip 401 response follows standard format."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/characters/test_char_001/inventory/equip',
|
||||||
|
json={'item_id': 'test', 'slot': 'weapon'}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert data is not None
|
||||||
|
assert 'status' in data
|
||||||
|
assert data['status'] == 401
|
||||||
|
|
||||||
|
def test_unequip_401_format(self, client):
|
||||||
|
"""Test that unequip 401 response follows standard format."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/characters/test_char_001/inventory/unequip',
|
||||||
|
json={'slot': 'weapon'}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert data is not None
|
||||||
|
assert 'status' in data
|
||||||
|
assert data['status'] == 401
|
||||||
|
|
||||||
|
def test_use_401_format(self, client):
|
||||||
|
"""Test that use 401 response follows standard format."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v1/characters/test_char_001/inventory/use',
|
||||||
|
json={'item_id': 'test'}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert data is not None
|
||||||
|
assert 'status' in data
|
||||||
|
assert data['status'] == 401
|
||||||
|
|
||||||
|
def test_drop_401_format(self, client):
|
||||||
|
"""Test that drop 401 response follows standard format."""
|
||||||
|
response = client.delete(
|
||||||
|
'/api/v1/characters/test_char_001/inventory/test_item'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert data is not None
|
||||||
|
assert 'status' in data
|
||||||
|
assert data['status'] == 401
|
||||||
819
api/tests/test_inventory_service.py
Normal file
819
api/tests/test_inventory_service.py
Normal file
@@ -0,0 +1,819 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the InventoryService.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Adding and removing items
|
||||||
|
- Equipment slot validation
|
||||||
|
- Level and class requirement checks
|
||||||
|
- Consumable usage and effect application
|
||||||
|
- Bulk operations
|
||||||
|
- Error handling
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.models.character import Character
|
||||||
|
from app.models.items import Item
|
||||||
|
from app.models.effects import Effect
|
||||||
|
from app.models.stats import Stats
|
||||||
|
from app.models.enums import ItemType, ItemRarity, EffectType, StatType, DamageType
|
||||||
|
from app.models.skills import PlayerClass
|
||||||
|
from app.models.origins import Origin
|
||||||
|
from app.services.inventory_service import (
|
||||||
|
InventoryService,
|
||||||
|
ItemNotFoundError,
|
||||||
|
CannotEquipError,
|
||||||
|
InvalidSlotError,
|
||||||
|
CannotUseItemError,
|
||||||
|
InventoryFullError,
|
||||||
|
ConsumableResult,
|
||||||
|
VALID_SLOTS,
|
||||||
|
ITEM_TYPE_SLOTS,
|
||||||
|
MAX_INVENTORY_SIZE,
|
||||||
|
get_inventory_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test Fixtures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_character_service():
|
||||||
|
"""Create a mock CharacterService."""
|
||||||
|
service = Mock()
|
||||||
|
service.update_character = Mock()
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def inventory_service(mock_character_service):
|
||||||
|
"""Create InventoryService with mocked dependencies."""
|
||||||
|
return InventoryService(character_service=mock_character_service)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_origin():
|
||||||
|
"""Create a minimal Origin for testing."""
|
||||||
|
from app.models.origins import StartingLocation, StartingBonus
|
||||||
|
|
||||||
|
starting_location = StartingLocation(
|
||||||
|
id="test_location",
|
||||||
|
name="Test Village",
|
||||||
|
region="Test Region",
|
||||||
|
description="A test location"
|
||||||
|
)
|
||||||
|
|
||||||
|
return Origin(
|
||||||
|
id="test_origin",
|
||||||
|
name="Test Origin",
|
||||||
|
description="A test origin for testing purposes",
|
||||||
|
starting_location=starting_location,
|
||||||
|
narrative_hooks=["test hook"],
|
||||||
|
starting_bonus=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_player_class():
|
||||||
|
"""Create a minimal PlayerClass for testing."""
|
||||||
|
return PlayerClass(
|
||||||
|
class_id="warrior",
|
||||||
|
name="Warrior",
|
||||||
|
description="A mighty warrior",
|
||||||
|
base_stats=Stats(
|
||||||
|
strength=14,
|
||||||
|
dexterity=10,
|
||||||
|
constitution=12,
|
||||||
|
intelligence=8,
|
||||||
|
wisdom=10,
|
||||||
|
charisma=8,
|
||||||
|
luck=8,
|
||||||
|
),
|
||||||
|
skill_trees=[],
|
||||||
|
starting_abilities=["basic_attack"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_character(mock_player_class, mock_origin):
|
||||||
|
"""Create a test character."""
|
||||||
|
return Character(
|
||||||
|
character_id="char_test_123",
|
||||||
|
user_id="user_test_456",
|
||||||
|
name="Test Hero",
|
||||||
|
player_class=mock_player_class,
|
||||||
|
origin=mock_origin,
|
||||||
|
level=5,
|
||||||
|
experience=0,
|
||||||
|
base_stats=mock_player_class.base_stats.copy(),
|
||||||
|
inventory=[],
|
||||||
|
equipped={},
|
||||||
|
gold=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_weapon():
|
||||||
|
"""Create a test weapon item."""
|
||||||
|
return Item(
|
||||||
|
item_id="iron_sword",
|
||||||
|
name="Iron Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.COMMON,
|
||||||
|
description="A sturdy iron sword",
|
||||||
|
value=50,
|
||||||
|
damage=10,
|
||||||
|
damage_type=DamageType.PHYSICAL,
|
||||||
|
crit_chance=0.05,
|
||||||
|
crit_multiplier=2.0,
|
||||||
|
required_level=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_armor():
|
||||||
|
"""Create a test armor item."""
|
||||||
|
return Item(
|
||||||
|
item_id="leather_chest",
|
||||||
|
name="Leather Chestpiece",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
rarity=ItemRarity.COMMON,
|
||||||
|
description="Simple leather armor",
|
||||||
|
value=40,
|
||||||
|
defense=5,
|
||||||
|
resistance=2,
|
||||||
|
required_level=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_helmet():
|
||||||
|
"""Create a test helmet item."""
|
||||||
|
return Item(
|
||||||
|
item_id="iron_helm",
|
||||||
|
name="Iron Helmet",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
rarity=ItemRarity.UNCOMMON,
|
||||||
|
description="A protective iron helmet",
|
||||||
|
value=30,
|
||||||
|
defense=3,
|
||||||
|
resistance=1,
|
||||||
|
required_level=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_consumable():
|
||||||
|
"""Create a test consumable item (health potion)."""
|
||||||
|
return Item(
|
||||||
|
item_id="health_potion_small",
|
||||||
|
name="Small Health Potion",
|
||||||
|
item_type=ItemType.CONSUMABLE,
|
||||||
|
rarity=ItemRarity.COMMON,
|
||||||
|
description="Restores 25 HP",
|
||||||
|
value=10,
|
||||||
|
effects_on_use=[
|
||||||
|
Effect(
|
||||||
|
effect_id="heal_25",
|
||||||
|
name="Minor Healing",
|
||||||
|
effect_type=EffectType.HOT,
|
||||||
|
duration=1,
|
||||||
|
power=25,
|
||||||
|
stacks=1,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_buff_potion():
|
||||||
|
"""Create a test buff potion."""
|
||||||
|
return Item(
|
||||||
|
item_id="strength_potion",
|
||||||
|
name="Potion of Strength",
|
||||||
|
item_type=ItemType.CONSUMABLE,
|
||||||
|
rarity=ItemRarity.UNCOMMON,
|
||||||
|
description="Increases strength temporarily",
|
||||||
|
value=25,
|
||||||
|
effects_on_use=[
|
||||||
|
Effect(
|
||||||
|
effect_id="str_buff",
|
||||||
|
name="Strength Boost",
|
||||||
|
effect_type=EffectType.BUFF,
|
||||||
|
duration=3,
|
||||||
|
power=5,
|
||||||
|
stat_affected=StatType.STRENGTH,
|
||||||
|
stacks=1,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_quest_item():
|
||||||
|
"""Create a test quest item."""
|
||||||
|
return Item(
|
||||||
|
item_id="ancient_key",
|
||||||
|
name="Ancient Key",
|
||||||
|
item_type=ItemType.QUEST_ITEM,
|
||||||
|
rarity=ItemRarity.RARE,
|
||||||
|
description="An ornate key to the ancient tomb",
|
||||||
|
value=0,
|
||||||
|
is_tradeable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def high_level_weapon():
|
||||||
|
"""Create a weapon with high level requirement."""
|
||||||
|
return Item(
|
||||||
|
item_id="legendary_blade",
|
||||||
|
name="Blade of Ages",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.LEGENDARY,
|
||||||
|
description="A blade forged in ancient times",
|
||||||
|
value=5000,
|
||||||
|
damage=50,
|
||||||
|
damage_type=DamageType.PHYSICAL,
|
||||||
|
required_level=20, # Higher than test character's level 5
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Read Operation Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestGetInventory:
|
||||||
|
"""Tests for get_inventory() and related read operations."""
|
||||||
|
|
||||||
|
def test_get_empty_inventory(self, inventory_service, test_character):
|
||||||
|
"""Test getting inventory when it's empty."""
|
||||||
|
items = inventory_service.get_inventory(test_character)
|
||||||
|
assert items == []
|
||||||
|
|
||||||
|
def test_get_inventory_with_items(self, inventory_service, test_character, test_weapon, test_armor):
|
||||||
|
"""Test getting inventory with items."""
|
||||||
|
test_character.inventory = [test_weapon, test_armor]
|
||||||
|
|
||||||
|
items = inventory_service.get_inventory(test_character)
|
||||||
|
|
||||||
|
assert len(items) == 2
|
||||||
|
assert test_weapon in items
|
||||||
|
assert test_armor in items
|
||||||
|
|
||||||
|
def test_get_inventory_returns_copy(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test that get_inventory returns a new list (not the original)."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
items = inventory_service.get_inventory(test_character)
|
||||||
|
items.append(test_weapon) # Modify returned list
|
||||||
|
|
||||||
|
# Original inventory should be unchanged
|
||||||
|
assert len(test_character.inventory) == 1
|
||||||
|
|
||||||
|
def test_get_item_by_id_found(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test finding an item by ID."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
item = inventory_service.get_item_by_id(test_character, "iron_sword")
|
||||||
|
|
||||||
|
assert item is test_weapon
|
||||||
|
|
||||||
|
def test_get_item_by_id_not_found(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test item not found returns None."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
item = inventory_service.get_item_by_id(test_character, "nonexistent_item")
|
||||||
|
|
||||||
|
assert item is None
|
||||||
|
|
||||||
|
def test_get_equipped_items_empty(self, inventory_service, test_character):
|
||||||
|
"""Test getting equipped items when nothing equipped."""
|
||||||
|
equipped = inventory_service.get_equipped_items(test_character)
|
||||||
|
assert equipped == {}
|
||||||
|
|
||||||
|
def test_get_equipped_items_with_equipment(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test getting equipped items."""
|
||||||
|
test_character.equipped = {"weapon": test_weapon}
|
||||||
|
|
||||||
|
equipped = inventory_service.get_equipped_items(test_character)
|
||||||
|
|
||||||
|
assert "weapon" in equipped
|
||||||
|
assert equipped["weapon"] is test_weapon
|
||||||
|
|
||||||
|
def test_get_equipped_item_specific_slot(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test getting item from a specific slot."""
|
||||||
|
test_character.equipped = {"weapon": test_weapon}
|
||||||
|
|
||||||
|
item = inventory_service.get_equipped_item(test_character, "weapon")
|
||||||
|
|
||||||
|
assert item is test_weapon
|
||||||
|
|
||||||
|
def test_get_equipped_item_empty_slot(self, inventory_service, test_character):
|
||||||
|
"""Test getting item from empty slot returns None."""
|
||||||
|
item = inventory_service.get_equipped_item(test_character, "weapon")
|
||||||
|
assert item is None
|
||||||
|
|
||||||
|
def test_get_inventory_count(self, inventory_service, test_character, test_weapon, test_armor):
|
||||||
|
"""Test counting inventory items."""
|
||||||
|
test_character.inventory = [test_weapon, test_armor]
|
||||||
|
|
||||||
|
count = inventory_service.get_inventory_count(test_character)
|
||||||
|
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Add/Remove Item Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestAddItem:
|
||||||
|
"""Tests for add_item()."""
|
||||||
|
|
||||||
|
def test_add_item_success(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test successfully adding an item."""
|
||||||
|
inventory_service.add_item(test_character, test_weapon, "user_test_456")
|
||||||
|
|
||||||
|
assert test_weapon in test_character.inventory
|
||||||
|
mock_character_service.update_character.assert_called_once()
|
||||||
|
|
||||||
|
def test_add_item_without_save(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test adding item without persistence."""
|
||||||
|
inventory_service.add_item(test_character, test_weapon, "user_test_456", save=False)
|
||||||
|
|
||||||
|
assert test_weapon in test_character.inventory
|
||||||
|
mock_character_service.update_character.assert_not_called()
|
||||||
|
|
||||||
|
def test_add_multiple_items(self, inventory_service, test_character, test_weapon, test_armor, mock_character_service):
|
||||||
|
"""Test adding multiple items."""
|
||||||
|
inventory_service.add_item(test_character, test_weapon, "user_test_456")
|
||||||
|
inventory_service.add_item(test_character, test_armor, "user_test_456")
|
||||||
|
|
||||||
|
assert len(test_character.inventory) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveItem:
|
||||||
|
"""Tests for remove_item()."""
|
||||||
|
|
||||||
|
def test_remove_item_success(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test successfully removing an item."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
removed = inventory_service.remove_item(test_character, "iron_sword", "user_test_456")
|
||||||
|
|
||||||
|
assert removed is test_weapon
|
||||||
|
assert test_weapon not in test_character.inventory
|
||||||
|
mock_character_service.update_character.assert_called_once()
|
||||||
|
|
||||||
|
def test_remove_item_not_found(self, inventory_service, test_character):
|
||||||
|
"""Test removing non-existent item raises error."""
|
||||||
|
with pytest.raises(ItemNotFoundError) as exc:
|
||||||
|
inventory_service.remove_item(test_character, "nonexistent", "user_test_456")
|
||||||
|
|
||||||
|
assert "nonexistent" in str(exc.value)
|
||||||
|
|
||||||
|
def test_remove_item_from_multiple(self, inventory_service, test_character, test_weapon, test_armor):
|
||||||
|
"""Test removing one item from multiple."""
|
||||||
|
test_character.inventory = [test_weapon, test_armor]
|
||||||
|
|
||||||
|
inventory_service.remove_item(test_character, "iron_sword", "user_test_456")
|
||||||
|
|
||||||
|
assert test_weapon not in test_character.inventory
|
||||||
|
assert test_armor in test_character.inventory
|
||||||
|
|
||||||
|
def test_drop_item_alias(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test drop_item is an alias for remove_item."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
dropped = inventory_service.drop_item(test_character, "iron_sword", "user_test_456")
|
||||||
|
|
||||||
|
assert dropped is test_weapon
|
||||||
|
assert test_weapon not in test_character.inventory
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Equipment Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestEquipItem:
|
||||||
|
"""Tests for equip_item()."""
|
||||||
|
|
||||||
|
def test_equip_weapon_to_weapon_slot(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test equipping a weapon to weapon slot."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
previous = inventory_service.equip_item(
|
||||||
|
test_character, "iron_sword", "weapon", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert previous is None
|
||||||
|
assert test_character.equipped.get("weapon") is test_weapon
|
||||||
|
assert test_weapon not in test_character.inventory
|
||||||
|
mock_character_service.update_character.assert_called_once()
|
||||||
|
|
||||||
|
def test_equip_armor_to_chest_slot(self, inventory_service, test_character, test_armor, mock_character_service):
|
||||||
|
"""Test equipping armor to chest slot."""
|
||||||
|
test_character.inventory = [test_armor]
|
||||||
|
|
||||||
|
inventory_service.equip_item(test_character, "leather_chest", "chest", "user_test_456")
|
||||||
|
|
||||||
|
assert test_character.equipped.get("chest") is test_armor
|
||||||
|
|
||||||
|
def test_equip_returns_previous_item(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test that equipping returns the previously equipped item."""
|
||||||
|
old_weapon = Item(
|
||||||
|
item_id="old_sword",
|
||||||
|
name="Old Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
damage=5,
|
||||||
|
)
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
test_character.equipped = {"weapon": old_weapon}
|
||||||
|
|
||||||
|
previous = inventory_service.equip_item(
|
||||||
|
test_character, "iron_sword", "weapon", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert previous is old_weapon
|
||||||
|
assert old_weapon in test_character.inventory # Returned to inventory
|
||||||
|
|
||||||
|
def test_equip_to_invalid_slot_raises_error(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test equipping to invalid slot raises InvalidSlotError."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
with pytest.raises(InvalidSlotError) as exc:
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "iron_sword", "invalid_slot", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "invalid_slot" in str(exc.value)
|
||||||
|
assert "Valid slots" in str(exc.value)
|
||||||
|
|
||||||
|
def test_equip_weapon_to_armor_slot_raises_error(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test equipping weapon to armor slot raises CannotEquipError."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
with pytest.raises(CannotEquipError) as exc:
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "iron_sword", "chest", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "weapon" in str(exc.value).lower()
|
||||||
|
|
||||||
|
def test_equip_armor_to_weapon_slot_raises_error(self, inventory_service, test_character, test_armor):
|
||||||
|
"""Test equipping armor to weapon slot raises CannotEquipError."""
|
||||||
|
test_character.inventory = [test_armor]
|
||||||
|
|
||||||
|
with pytest.raises(CannotEquipError) as exc:
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "leather_chest", "weapon", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "armor" in str(exc.value).lower()
|
||||||
|
|
||||||
|
def test_equip_item_not_in_inventory(self, inventory_service, test_character):
|
||||||
|
"""Test equipping item not in inventory raises ItemNotFoundError."""
|
||||||
|
with pytest.raises(ItemNotFoundError):
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "nonexistent", "weapon", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_equip_item_level_requirement_not_met(self, inventory_service, test_character, high_level_weapon):
|
||||||
|
"""Test equipping item with unmet level requirement raises error."""
|
||||||
|
test_character.inventory = [high_level_weapon]
|
||||||
|
test_character.level = 5 # Item requires level 20
|
||||||
|
|
||||||
|
with pytest.raises(CannotEquipError) as exc:
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "legendary_blade", "weapon", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "level 20" in str(exc.value)
|
||||||
|
assert "level 5" in str(exc.value)
|
||||||
|
|
||||||
|
def test_equip_consumable_raises_error(self, inventory_service, test_character, test_consumable):
|
||||||
|
"""Test equipping consumable raises CannotEquipError."""
|
||||||
|
test_character.inventory = [test_consumable]
|
||||||
|
|
||||||
|
with pytest.raises(CannotEquipError) as exc:
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "health_potion_small", "weapon", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "consumable" in str(exc.value).lower()
|
||||||
|
|
||||||
|
def test_equip_quest_item_raises_error(self, inventory_service, test_character, test_quest_item):
|
||||||
|
"""Test equipping quest item raises CannotEquipError."""
|
||||||
|
test_character.inventory = [test_quest_item]
|
||||||
|
|
||||||
|
with pytest.raises(CannotEquipError) as exc:
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "ancient_key", "weapon", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_equip_weapon_to_off_hand(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test equipping weapon to off_hand slot."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "iron_sword", "off_hand", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert test_character.equipped.get("off_hand") is test_weapon
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnequipItem:
|
||||||
|
"""Tests for unequip_item()."""
|
||||||
|
|
||||||
|
def test_unequip_item_success(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test successfully unequipping an item."""
|
||||||
|
test_character.equipped = {"weapon": test_weapon}
|
||||||
|
|
||||||
|
unequipped = inventory_service.unequip_item(test_character, "weapon", "user_test_456")
|
||||||
|
|
||||||
|
assert unequipped is test_weapon
|
||||||
|
assert "weapon" not in test_character.equipped
|
||||||
|
assert test_weapon in test_character.inventory
|
||||||
|
mock_character_service.update_character.assert_called_once()
|
||||||
|
|
||||||
|
def test_unequip_empty_slot_returns_none(self, inventory_service, test_character, mock_character_service):
|
||||||
|
"""Test unequipping from empty slot returns None."""
|
||||||
|
unequipped = inventory_service.unequip_item(test_character, "weapon", "user_test_456")
|
||||||
|
|
||||||
|
assert unequipped is None
|
||||||
|
|
||||||
|
def test_unequip_invalid_slot_raises_error(self, inventory_service, test_character):
|
||||||
|
"""Test unequipping from invalid slot raises InvalidSlotError."""
|
||||||
|
with pytest.raises(InvalidSlotError):
|
||||||
|
inventory_service.unequip_item(test_character, "invalid_slot", "user_test_456")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Consumable Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestUseConsumable:
|
||||||
|
"""Tests for use_consumable()."""
|
||||||
|
|
||||||
|
def test_use_health_potion(self, inventory_service, test_character, test_consumable, mock_character_service):
|
||||||
|
"""Test using a health potion restores HP."""
|
||||||
|
test_character.inventory = [test_consumable]
|
||||||
|
|
||||||
|
result = inventory_service.use_consumable(
|
||||||
|
test_character, "health_potion_small", "user_test_456",
|
||||||
|
current_hp=50, max_hp=100
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, ConsumableResult)
|
||||||
|
assert result.hp_restored == 25 # Potion restores 25, capped at missing HP
|
||||||
|
assert result.item_name == "Small Health Potion"
|
||||||
|
assert test_consumable not in test_character.inventory
|
||||||
|
mock_character_service.update_character.assert_called_once()
|
||||||
|
|
||||||
|
def test_use_health_potion_capped_at_max(self, inventory_service, test_character, test_consumable):
|
||||||
|
"""Test HP restoration is capped at max HP."""
|
||||||
|
test_character.inventory = [test_consumable]
|
||||||
|
|
||||||
|
result = inventory_service.use_consumable(
|
||||||
|
test_character, "health_potion_small", "user_test_456",
|
||||||
|
current_hp=90, max_hp=100 # Only missing 10 HP
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.hp_restored == 10 # Only restores missing amount
|
||||||
|
|
||||||
|
def test_use_consumable_at_full_hp(self, inventory_service, test_character, test_consumable):
|
||||||
|
"""Test using potion at full HP restores 0."""
|
||||||
|
test_character.inventory = [test_consumable]
|
||||||
|
|
||||||
|
result = inventory_service.use_consumable(
|
||||||
|
test_character, "health_potion_small", "user_test_456",
|
||||||
|
current_hp=100, max_hp=100
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.hp_restored == 0
|
||||||
|
|
||||||
|
def test_use_buff_potion(self, inventory_service, test_character, test_buff_potion, mock_character_service):
|
||||||
|
"""Test using a buff potion."""
|
||||||
|
test_character.inventory = [test_buff_potion]
|
||||||
|
|
||||||
|
result = inventory_service.use_consumable(
|
||||||
|
test_character, "strength_potion", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.item_name == "Potion of Strength"
|
||||||
|
assert len(result.effects_applied) == 1
|
||||||
|
assert result.effects_applied[0]["effect_type"] == "buff"
|
||||||
|
assert result.effects_applied[0]["stat_affected"] == "strength"
|
||||||
|
|
||||||
|
def test_use_non_consumable_raises_error(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test using non-consumable item raises CannotUseItemError."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
with pytest.raises(CannotUseItemError) as exc:
|
||||||
|
inventory_service.use_consumable(
|
||||||
|
test_character, "iron_sword", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "not a consumable" in str(exc.value)
|
||||||
|
|
||||||
|
def test_use_item_not_in_inventory_raises_error(self, inventory_service, test_character):
|
||||||
|
"""Test using item not in inventory raises ItemNotFoundError."""
|
||||||
|
with pytest.raises(ItemNotFoundError):
|
||||||
|
inventory_service.use_consumable(
|
||||||
|
test_character, "nonexistent", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_consumable_result_to_dict(self, inventory_service, test_character, test_consumable):
|
||||||
|
"""Test ConsumableResult serialization."""
|
||||||
|
test_character.inventory = [test_consumable]
|
||||||
|
|
||||||
|
result = inventory_service.use_consumable(
|
||||||
|
test_character, "health_potion_small", "user_test_456",
|
||||||
|
current_hp=50, max_hp=100
|
||||||
|
)
|
||||||
|
|
||||||
|
result_dict = result.to_dict()
|
||||||
|
|
||||||
|
assert "item_name" in result_dict
|
||||||
|
assert "hp_restored" in result_dict
|
||||||
|
assert "effects_applied" in result_dict
|
||||||
|
assert "message" in result_dict
|
||||||
|
|
||||||
|
|
||||||
|
class TestUseConsumableInCombat:
|
||||||
|
"""Tests for use_consumable_in_combat()."""
|
||||||
|
|
||||||
|
def test_combat_consumable_returns_effects(self, inventory_service, test_character, test_buff_potion):
|
||||||
|
"""Test combat consumable returns duration effects."""
|
||||||
|
test_character.inventory = [test_buff_potion]
|
||||||
|
|
||||||
|
result, effects = inventory_service.use_consumable_in_combat(
|
||||||
|
test_character, "strength_potion", "user_test_456",
|
||||||
|
current_hp=50, max_hp=100
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, ConsumableResult)
|
||||||
|
assert len(effects) == 1
|
||||||
|
assert effects[0].effect_type == EffectType.BUFF
|
||||||
|
assert effects[0].duration == 3
|
||||||
|
|
||||||
|
def test_combat_instant_heal_potion(self, inventory_service, test_character, test_consumable):
|
||||||
|
"""Test instant heal in combat."""
|
||||||
|
test_character.inventory = [test_consumable]
|
||||||
|
|
||||||
|
result, effects = inventory_service.use_consumable_in_combat(
|
||||||
|
test_character, "health_potion_small", "user_test_456",
|
||||||
|
current_hp=50, max_hp=100
|
||||||
|
)
|
||||||
|
|
||||||
|
# HOT with duration 1 should be returned as duration effect for combat tracking
|
||||||
|
assert len(effects) >= 0 # Implementation may vary
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Bulk Operation Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestBulkOperations:
|
||||||
|
"""Tests for bulk inventory operations."""
|
||||||
|
|
||||||
|
def test_add_items_bulk(self, inventory_service, test_character, test_weapon, test_armor, mock_character_service):
|
||||||
|
"""Test adding multiple items at once."""
|
||||||
|
items = [test_weapon, test_armor]
|
||||||
|
|
||||||
|
count = inventory_service.add_items(test_character, items, "user_test_456")
|
||||||
|
|
||||||
|
assert count == 2
|
||||||
|
assert len(test_character.inventory) == 2
|
||||||
|
mock_character_service.update_character.assert_called_once()
|
||||||
|
|
||||||
|
def test_get_items_by_type(self, inventory_service, test_character, test_weapon, test_armor, test_consumable):
|
||||||
|
"""Test filtering items by type."""
|
||||||
|
test_character.inventory = [test_weapon, test_armor, test_consumable]
|
||||||
|
|
||||||
|
weapons = inventory_service.get_items_by_type(test_character, ItemType.WEAPON)
|
||||||
|
armor = inventory_service.get_items_by_type(test_character, ItemType.ARMOR)
|
||||||
|
consumables = inventory_service.get_items_by_type(test_character, ItemType.CONSUMABLE)
|
||||||
|
|
||||||
|
assert len(weapons) == 1
|
||||||
|
assert test_weapon in weapons
|
||||||
|
assert len(armor) == 1
|
||||||
|
assert test_armor in armor
|
||||||
|
assert len(consumables) == 1
|
||||||
|
|
||||||
|
def test_get_equippable_items(self, inventory_service, test_character, test_weapon, test_armor, test_consumable):
|
||||||
|
"""Test getting only equippable items."""
|
||||||
|
test_character.inventory = [test_weapon, test_armor, test_consumable]
|
||||||
|
|
||||||
|
equippable = inventory_service.get_equippable_items(test_character)
|
||||||
|
|
||||||
|
assert test_weapon in equippable
|
||||||
|
assert test_armor in equippable
|
||||||
|
assert test_consumable not in equippable
|
||||||
|
|
||||||
|
def test_get_equippable_items_for_slot(self, inventory_service, test_character, test_weapon, test_armor):
|
||||||
|
"""Test getting equippable items for a specific slot."""
|
||||||
|
test_character.inventory = [test_weapon, test_armor]
|
||||||
|
|
||||||
|
for_weapon = inventory_service.get_equippable_items(test_character, slot="weapon")
|
||||||
|
for_chest = inventory_service.get_equippable_items(test_character, slot="chest")
|
||||||
|
|
||||||
|
assert test_weapon in for_weapon
|
||||||
|
assert test_armor not in for_weapon
|
||||||
|
assert test_armor in for_chest
|
||||||
|
assert test_weapon not in for_chest
|
||||||
|
|
||||||
|
def test_get_equippable_items_excludes_high_level(self, inventory_service, test_character, test_weapon, high_level_weapon):
|
||||||
|
"""Test that items above character level are excluded."""
|
||||||
|
test_character.inventory = [test_weapon, high_level_weapon]
|
||||||
|
test_character.level = 5
|
||||||
|
|
||||||
|
equippable = inventory_service.get_equippable_items(test_character)
|
||||||
|
|
||||||
|
assert test_weapon in equippable
|
||||||
|
assert high_level_weapon not in equippable
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Edge Cases and Error Handling
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Tests for edge cases and error handling."""
|
||||||
|
|
||||||
|
def test_valid_slots_constant(self):
|
||||||
|
"""Test VALID_SLOTS contains expected slots."""
|
||||||
|
expected = {"weapon", "off_hand", "helmet", "chest", "gloves", "boots", "accessory_1", "accessory_2"}
|
||||||
|
assert VALID_SLOTS == expected
|
||||||
|
|
||||||
|
def test_item_type_slots_mapping(self):
|
||||||
|
"""Test ITEM_TYPE_SLOTS mapping is correct."""
|
||||||
|
assert ItemType.WEAPON in ITEM_TYPE_SLOTS
|
||||||
|
assert "weapon" in ITEM_TYPE_SLOTS[ItemType.WEAPON]
|
||||||
|
assert "off_hand" in ITEM_TYPE_SLOTS[ItemType.WEAPON]
|
||||||
|
assert ItemType.ARMOR in ITEM_TYPE_SLOTS
|
||||||
|
assert "chest" in ITEM_TYPE_SLOTS[ItemType.ARMOR]
|
||||||
|
assert "helmet" in ITEM_TYPE_SLOTS[ItemType.ARMOR]
|
||||||
|
|
||||||
|
def test_generated_item_with_unique_id(self, inventory_service, test_character, mock_character_service):
|
||||||
|
"""Test handling of generated items with unique IDs."""
|
||||||
|
generated_item = Item(
|
||||||
|
item_id="gen_abc123", # Generated item ID format
|
||||||
|
name="Dagger",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.RARE,
|
||||||
|
damage=15,
|
||||||
|
is_generated=True,
|
||||||
|
generated_name="Flaming Dagger of Strength",
|
||||||
|
base_template_id="dagger",
|
||||||
|
applied_affixes=["flaming", "of_strength"],
|
||||||
|
)
|
||||||
|
|
||||||
|
inventory_service.add_item(test_character, generated_item, "user_test_456")
|
||||||
|
|
||||||
|
assert generated_item in test_character.inventory
|
||||||
|
assert generated_item.get_display_name() == "Flaming Dagger of Strength"
|
||||||
|
|
||||||
|
def test_equip_generated_item(self, inventory_service, test_character, mock_character_service):
|
||||||
|
"""Test equipping a generated item."""
|
||||||
|
generated_item = Item(
|
||||||
|
item_id="gen_xyz789",
|
||||||
|
name="Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.EPIC,
|
||||||
|
damage=25,
|
||||||
|
is_generated=True,
|
||||||
|
generated_name="Blazing Sword of Power",
|
||||||
|
required_level=1,
|
||||||
|
)
|
||||||
|
test_character.inventory = [generated_item]
|
||||||
|
|
||||||
|
inventory_service.equip_item(test_character, "gen_xyz789", "weapon", "user_test_456")
|
||||||
|
|
||||||
|
assert test_character.equipped.get("weapon") is generated_item
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Global Instance Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestGlobalInstance:
|
||||||
|
"""Tests for the global singleton pattern."""
|
||||||
|
|
||||||
|
def test_get_inventory_service_returns_instance(self):
|
||||||
|
"""Test get_inventory_service returns InventoryService."""
|
||||||
|
with patch('app.services.inventory_service._service_instance', None):
|
||||||
|
with patch('app.services.inventory_service.get_character_service'):
|
||||||
|
service = get_inventory_service()
|
||||||
|
assert isinstance(service, InventoryService)
|
||||||
|
|
||||||
|
def test_get_inventory_service_returns_same_instance(self):
|
||||||
|
"""Test get_inventory_service returns singleton."""
|
||||||
|
with patch('app.services.inventory_service._service_instance', None):
|
||||||
|
with patch('app.services.inventory_service.get_character_service'):
|
||||||
|
service1 = get_inventory_service()
|
||||||
|
service2 = get_inventory_service()
|
||||||
|
assert service1 is service2
|
||||||
527
api/tests/test_item_generator.py
Normal file
527
api/tests/test_item_generator.py
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Item Generator and Affix System.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Affix loading from YAML
|
||||||
|
- Base item template loading
|
||||||
|
- Item generation with affixes
|
||||||
|
- Name generation
|
||||||
|
- Stat combination
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
from app.models.affixes import Affix, BaseItemTemplate
|
||||||
|
from app.models.enums import AffixType, AffixTier, ItemRarity, ItemType, DamageType
|
||||||
|
from app.services.affix_loader import AffixLoader, get_affix_loader
|
||||||
|
from app.services.base_item_loader import BaseItemLoader, get_base_item_loader
|
||||||
|
from app.services.item_generator import ItemGenerator, get_item_generator
|
||||||
|
|
||||||
|
|
||||||
|
class TestAffixModel:
|
||||||
|
"""Tests for the Affix dataclass."""
|
||||||
|
|
||||||
|
def test_affix_creation(self):
|
||||||
|
"""Test creating an Affix instance."""
|
||||||
|
affix = Affix(
|
||||||
|
affix_id="flaming",
|
||||||
|
name="Flaming",
|
||||||
|
affix_type=AffixType.PREFIX,
|
||||||
|
tier=AffixTier.MINOR,
|
||||||
|
description="Fire damage",
|
||||||
|
damage_type=DamageType.FIRE,
|
||||||
|
elemental_ratio=0.25,
|
||||||
|
damage_bonus=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert affix.affix_id == "flaming"
|
||||||
|
assert affix.name == "Flaming"
|
||||||
|
assert affix.affix_type == AffixType.PREFIX
|
||||||
|
assert affix.tier == AffixTier.MINOR
|
||||||
|
assert affix.applies_elemental_damage()
|
||||||
|
|
||||||
|
def test_affix_can_apply_to(self):
|
||||||
|
"""Test affix eligibility checking."""
|
||||||
|
# Weapon-only affix
|
||||||
|
weapon_affix = Affix(
|
||||||
|
affix_id="sharp",
|
||||||
|
name="Sharp",
|
||||||
|
affix_type=AffixType.PREFIX,
|
||||||
|
tier=AffixTier.MINOR,
|
||||||
|
allowed_item_types=["weapon"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert weapon_affix.can_apply_to("weapon", "rare")
|
||||||
|
assert not weapon_affix.can_apply_to("armor", "rare")
|
||||||
|
|
||||||
|
def test_affix_legendary_only(self):
|
||||||
|
"""Test legendary-only affix restriction."""
|
||||||
|
legendary_affix = Affix(
|
||||||
|
affix_id="vorpal",
|
||||||
|
name="Vorpal",
|
||||||
|
affix_type=AffixType.PREFIX,
|
||||||
|
tier=AffixTier.LEGENDARY,
|
||||||
|
required_rarity="legendary",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert legendary_affix.is_legendary_only()
|
||||||
|
assert legendary_affix.can_apply_to("weapon", "legendary")
|
||||||
|
assert not legendary_affix.can_apply_to("weapon", "epic")
|
||||||
|
|
||||||
|
def test_affix_serialization(self):
|
||||||
|
"""Test affix to_dict and from_dict."""
|
||||||
|
affix = Affix(
|
||||||
|
affix_id="of_strength",
|
||||||
|
name="of Strength",
|
||||||
|
affix_type=AffixType.SUFFIX,
|
||||||
|
tier=AffixTier.MINOR,
|
||||||
|
stat_bonuses={"strength": 2},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = affix.to_dict()
|
||||||
|
restored = Affix.from_dict(data)
|
||||||
|
|
||||||
|
assert restored.affix_id == affix.affix_id
|
||||||
|
assert restored.name == affix.name
|
||||||
|
assert restored.stat_bonuses == affix.stat_bonuses
|
||||||
|
|
||||||
|
|
||||||
|
class TestBaseItemTemplate:
|
||||||
|
"""Tests for the BaseItemTemplate dataclass."""
|
||||||
|
|
||||||
|
def test_template_creation(self):
|
||||||
|
"""Test creating a BaseItemTemplate instance."""
|
||||||
|
template = BaseItemTemplate(
|
||||||
|
template_id="dagger",
|
||||||
|
name="Dagger",
|
||||||
|
item_type="weapon",
|
||||||
|
base_damage=6,
|
||||||
|
base_value=15,
|
||||||
|
crit_chance=0.08,
|
||||||
|
required_level=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert template.template_id == "dagger"
|
||||||
|
assert template.base_damage == 6
|
||||||
|
assert template.crit_chance == 0.08
|
||||||
|
|
||||||
|
def test_template_rarity_eligibility(self):
|
||||||
|
"""Test template rarity checking."""
|
||||||
|
template = BaseItemTemplate(
|
||||||
|
template_id="plate_armor",
|
||||||
|
name="Plate Armor",
|
||||||
|
item_type="armor",
|
||||||
|
min_rarity="rare",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert template.can_generate_at_rarity("rare")
|
||||||
|
assert template.can_generate_at_rarity("epic")
|
||||||
|
assert template.can_generate_at_rarity("legendary")
|
||||||
|
assert not template.can_generate_at_rarity("common")
|
||||||
|
assert not template.can_generate_at_rarity("uncommon")
|
||||||
|
|
||||||
|
def test_template_level_eligibility(self):
|
||||||
|
"""Test template level checking."""
|
||||||
|
template = BaseItemTemplate(
|
||||||
|
template_id="greatsword",
|
||||||
|
name="Greatsword",
|
||||||
|
item_type="weapon",
|
||||||
|
required_level=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert template.can_drop_for_level(5)
|
||||||
|
assert template.can_drop_for_level(10)
|
||||||
|
assert not template.can_drop_for_level(4)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAffixLoader:
|
||||||
|
"""Tests for the AffixLoader service."""
|
||||||
|
|
||||||
|
def test_loader_initialization(self):
|
||||||
|
"""Test AffixLoader initializes correctly."""
|
||||||
|
loader = get_affix_loader()
|
||||||
|
assert loader is not None
|
||||||
|
|
||||||
|
def test_load_prefixes(self):
|
||||||
|
"""Test loading prefixes from YAML."""
|
||||||
|
loader = get_affix_loader()
|
||||||
|
loader.load_all()
|
||||||
|
|
||||||
|
prefixes = loader.get_all_prefixes()
|
||||||
|
assert len(prefixes) > 0
|
||||||
|
|
||||||
|
# Check for known prefix
|
||||||
|
flaming = loader.get_affix("flaming")
|
||||||
|
assert flaming is not None
|
||||||
|
assert flaming.affix_type == AffixType.PREFIX
|
||||||
|
assert flaming.name == "Flaming"
|
||||||
|
|
||||||
|
def test_load_suffixes(self):
|
||||||
|
"""Test loading suffixes from YAML."""
|
||||||
|
loader = get_affix_loader()
|
||||||
|
loader.load_all()
|
||||||
|
|
||||||
|
suffixes = loader.get_all_suffixes()
|
||||||
|
assert len(suffixes) > 0
|
||||||
|
|
||||||
|
# Check for known suffix
|
||||||
|
of_strength = loader.get_affix("of_strength")
|
||||||
|
assert of_strength is not None
|
||||||
|
assert of_strength.affix_type == AffixType.SUFFIX
|
||||||
|
assert of_strength.name == "of Strength"
|
||||||
|
|
||||||
|
def test_get_eligible_prefixes(self):
|
||||||
|
"""Test filtering eligible prefixes."""
|
||||||
|
loader = get_affix_loader()
|
||||||
|
|
||||||
|
# Get weapon prefixes for rare items
|
||||||
|
eligible = loader.get_eligible_prefixes("weapon", "rare")
|
||||||
|
assert len(eligible) > 0
|
||||||
|
|
||||||
|
# All should be applicable to weapons
|
||||||
|
for prefix in eligible:
|
||||||
|
assert prefix.can_apply_to("weapon", "rare")
|
||||||
|
|
||||||
|
def test_get_random_prefix(self):
|
||||||
|
"""Test random prefix selection."""
|
||||||
|
loader = get_affix_loader()
|
||||||
|
|
||||||
|
prefix = loader.get_random_prefix("weapon", "rare")
|
||||||
|
assert prefix is not None
|
||||||
|
assert prefix.affix_type == AffixType.PREFIX
|
||||||
|
|
||||||
|
|
||||||
|
class TestBaseItemLoader:
|
||||||
|
"""Tests for the BaseItemLoader service."""
|
||||||
|
|
||||||
|
def test_loader_initialization(self):
|
||||||
|
"""Test BaseItemLoader initializes correctly."""
|
||||||
|
loader = get_base_item_loader()
|
||||||
|
assert loader is not None
|
||||||
|
|
||||||
|
def test_load_weapons(self):
|
||||||
|
"""Test loading weapon templates from YAML."""
|
||||||
|
loader = get_base_item_loader()
|
||||||
|
loader.load_all()
|
||||||
|
|
||||||
|
weapons = loader.get_all_weapons()
|
||||||
|
assert len(weapons) > 0
|
||||||
|
|
||||||
|
# Check for known weapon
|
||||||
|
dagger = loader.get_template("dagger")
|
||||||
|
assert dagger is not None
|
||||||
|
assert dagger.item_type == "weapon"
|
||||||
|
assert dagger.base_damage > 0
|
||||||
|
|
||||||
|
def test_load_armor(self):
|
||||||
|
"""Test loading armor templates from YAML."""
|
||||||
|
loader = get_base_item_loader()
|
||||||
|
loader.load_all()
|
||||||
|
|
||||||
|
armor = loader.get_all_armor()
|
||||||
|
assert len(armor) > 0
|
||||||
|
|
||||||
|
# Check for known armor
|
||||||
|
chainmail = loader.get_template("chainmail")
|
||||||
|
assert chainmail is not None
|
||||||
|
assert chainmail.item_type == "armor"
|
||||||
|
assert chainmail.base_defense > 0
|
||||||
|
|
||||||
|
def test_get_eligible_templates(self):
|
||||||
|
"""Test filtering eligible templates."""
|
||||||
|
loader = get_base_item_loader()
|
||||||
|
|
||||||
|
# Get weapons for level 1, common rarity
|
||||||
|
eligible = loader.get_eligible_templates("weapon", "common", 1)
|
||||||
|
assert len(eligible) > 0
|
||||||
|
|
||||||
|
# All should be eligible
|
||||||
|
for template in eligible:
|
||||||
|
assert template.can_drop_for_level(1)
|
||||||
|
assert template.can_generate_at_rarity("common")
|
||||||
|
|
||||||
|
def test_get_random_template(self):
|
||||||
|
"""Test random template selection."""
|
||||||
|
loader = get_base_item_loader()
|
||||||
|
|
||||||
|
template = loader.get_random_template("weapon", "common", 1)
|
||||||
|
assert template is not None
|
||||||
|
assert template.item_type == "weapon"
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemGenerator:
|
||||||
|
"""Tests for the ItemGenerator service."""
|
||||||
|
|
||||||
|
def test_generator_initialization(self):
|
||||||
|
"""Test ItemGenerator initializes correctly."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
assert generator is not None
|
||||||
|
|
||||||
|
def test_generate_common_item(self):
|
||||||
|
"""Test generating a common item (no affixes)."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
item = generator.generate_item("weapon", ItemRarity.COMMON, 1)
|
||||||
|
assert item is not None
|
||||||
|
assert item.rarity == ItemRarity.COMMON
|
||||||
|
assert item.is_generated
|
||||||
|
assert len(item.applied_affixes) == 0
|
||||||
|
# Common items have no generated name
|
||||||
|
assert item.generated_name == item.name
|
||||||
|
|
||||||
|
def test_generate_rare_item(self):
|
||||||
|
"""Test generating a rare item (1 affix)."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
item = generator.generate_item("weapon", ItemRarity.RARE, 1)
|
||||||
|
assert item is not None
|
||||||
|
assert item.rarity == ItemRarity.RARE
|
||||||
|
assert item.is_generated
|
||||||
|
assert len(item.applied_affixes) == 1
|
||||||
|
assert item.generated_name != item.name
|
||||||
|
|
||||||
|
def test_generate_epic_item(self):
|
||||||
|
"""Test generating an epic item (2 affixes)."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
item = generator.generate_item("weapon", ItemRarity.EPIC, 1)
|
||||||
|
assert item is not None
|
||||||
|
assert item.rarity == ItemRarity.EPIC
|
||||||
|
assert item.is_generated
|
||||||
|
assert len(item.applied_affixes) == 2
|
||||||
|
|
||||||
|
def test_generate_legendary_item(self):
|
||||||
|
"""Test generating a legendary item (3 affixes)."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
item = generator.generate_item("weapon", ItemRarity.LEGENDARY, 5)
|
||||||
|
assert item is not None
|
||||||
|
assert item.rarity == ItemRarity.LEGENDARY
|
||||||
|
assert item.is_generated
|
||||||
|
assert len(item.applied_affixes) == 3
|
||||||
|
|
||||||
|
def test_generated_name_format(self):
|
||||||
|
"""Test that generated names follow the expected format."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
# Generate multiple items and check name patterns
|
||||||
|
for _ in range(10):
|
||||||
|
item = generator.generate_item("weapon", ItemRarity.EPIC, 1)
|
||||||
|
if item:
|
||||||
|
name = item.get_display_name()
|
||||||
|
# EPIC should have both prefix and suffix (typically)
|
||||||
|
# Name should contain the base item name
|
||||||
|
assert item.name in name or item.base_template_id in name.lower()
|
||||||
|
|
||||||
|
def test_stat_combination(self):
|
||||||
|
"""Test that affix stats are properly combined."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
# Generate items and verify stat bonuses are present
|
||||||
|
for _ in range(5):
|
||||||
|
item = generator.generate_item("weapon", ItemRarity.RARE, 1)
|
||||||
|
if item and item.applied_affixes:
|
||||||
|
# Item should have some stat modifications
|
||||||
|
# Either stat_bonuses, damage_bonus, or elemental properties
|
||||||
|
has_stats = (
|
||||||
|
bool(item.stat_bonuses) or
|
||||||
|
item.damage > 0 or
|
||||||
|
item.elemental_ratio > 0
|
||||||
|
)
|
||||||
|
assert has_stats
|
||||||
|
|
||||||
|
def test_generate_armor(self):
|
||||||
|
"""Test generating armor items."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
item = generator.generate_item("armor", ItemRarity.RARE, 1)
|
||||||
|
assert item is not None
|
||||||
|
assert item.item_type == ItemType.ARMOR
|
||||||
|
assert item.defense > 0 or item.resistance > 0
|
||||||
|
|
||||||
|
def test_generate_loot_drop(self):
|
||||||
|
"""Test random loot drop generation."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
# Generate multiple drops to test randomness
|
||||||
|
rarities_seen = set()
|
||||||
|
for _ in range(50):
|
||||||
|
item = generator.generate_loot_drop(5, luck_stat=8)
|
||||||
|
if item:
|
||||||
|
rarities_seen.add(item.rarity)
|
||||||
|
|
||||||
|
# Should see at least common and uncommon
|
||||||
|
assert ItemRarity.COMMON in rarities_seen or ItemRarity.UNCOMMON in rarities_seen
|
||||||
|
|
||||||
|
def test_luck_affects_rarity(self):
|
||||||
|
"""Test that higher luck increases rare drops."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
# This is a statistical test - higher luck should trend toward better rarity
|
||||||
|
low_luck_rares = 0
|
||||||
|
high_luck_rares = 0
|
||||||
|
|
||||||
|
for _ in range(100):
|
||||||
|
low_luck_item = generator.generate_loot_drop(5, luck_stat=1)
|
||||||
|
high_luck_item = generator.generate_loot_drop(5, luck_stat=20)
|
||||||
|
|
||||||
|
if low_luck_item and low_luck_item.rarity in [ItemRarity.RARE, ItemRarity.EPIC, ItemRarity.LEGENDARY]:
|
||||||
|
low_luck_rares += 1
|
||||||
|
if high_luck_item and high_luck_item.rarity in [ItemRarity.RARE, ItemRarity.EPIC, ItemRarity.LEGENDARY]:
|
||||||
|
high_luck_rares += 1
|
||||||
|
|
||||||
|
# High luck should generally produce more rare+ items
|
||||||
|
# (This may occasionally fail due to randomness, but should pass most of the time)
|
||||||
|
# We're just checking the trend, not a strict guarantee
|
||||||
|
# logger.info(f"Low luck rares: {low_luck_rares}, High luck rares: {high_luck_rares}")
|
||||||
|
|
||||||
|
|
||||||
|
class TestNameGeneration:
|
||||||
|
"""Tests specifically for item name generation."""
|
||||||
|
|
||||||
|
def test_prefix_only_name(self):
|
||||||
|
"""Test name with only a prefix."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
# Create mock affixes
|
||||||
|
prefix = Affix(
|
||||||
|
affix_id="flaming",
|
||||||
|
name="Flaming",
|
||||||
|
affix_type=AffixType.PREFIX,
|
||||||
|
tier=AffixTier.MINOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
name = generator._build_name("Dagger", [prefix], [])
|
||||||
|
assert name == "Flaming Dagger"
|
||||||
|
|
||||||
|
def test_suffix_only_name(self):
|
||||||
|
"""Test name with only a suffix."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
suffix = Affix(
|
||||||
|
affix_id="of_strength",
|
||||||
|
name="of Strength",
|
||||||
|
affix_type=AffixType.SUFFIX,
|
||||||
|
tier=AffixTier.MINOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
name = generator._build_name("Dagger", [], [suffix])
|
||||||
|
assert name == "Dagger of Strength"
|
||||||
|
|
||||||
|
def test_full_name(self):
|
||||||
|
"""Test name with prefix and suffix."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
prefix = Affix(
|
||||||
|
affix_id="flaming",
|
||||||
|
name="Flaming",
|
||||||
|
affix_type=AffixType.PREFIX,
|
||||||
|
tier=AffixTier.MINOR,
|
||||||
|
)
|
||||||
|
suffix = Affix(
|
||||||
|
affix_id="of_strength",
|
||||||
|
name="of Strength",
|
||||||
|
affix_type=AffixType.SUFFIX,
|
||||||
|
tier=AffixTier.MINOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
name = generator._build_name("Dagger", [prefix], [suffix])
|
||||||
|
assert name == "Flaming Dagger of Strength"
|
||||||
|
|
||||||
|
def test_multiple_prefixes(self):
|
||||||
|
"""Test name with multiple prefixes."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
prefix1 = Affix(
|
||||||
|
affix_id="flaming",
|
||||||
|
name="Flaming",
|
||||||
|
affix_type=AffixType.PREFIX,
|
||||||
|
tier=AffixTier.MINOR,
|
||||||
|
)
|
||||||
|
prefix2 = Affix(
|
||||||
|
affix_id="sharp",
|
||||||
|
name="Sharp",
|
||||||
|
affix_type=AffixType.PREFIX,
|
||||||
|
tier=AffixTier.MINOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
name = generator._build_name("Dagger", [prefix1, prefix2], [])
|
||||||
|
assert name == "Flaming Sharp Dagger"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatCombination:
|
||||||
|
"""Tests for combining affix stats."""
|
||||||
|
|
||||||
|
def test_combine_stat_bonuses(self):
|
||||||
|
"""Test combining stat bonuses from multiple affixes."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
affix1 = Affix(
|
||||||
|
affix_id="test1",
|
||||||
|
name="Test1",
|
||||||
|
affix_type=AffixType.PREFIX,
|
||||||
|
tier=AffixTier.MINOR,
|
||||||
|
stat_bonuses={"strength": 2, "constitution": 1},
|
||||||
|
)
|
||||||
|
affix2 = Affix(
|
||||||
|
affix_id="test2",
|
||||||
|
name="Test2",
|
||||||
|
affix_type=AffixType.SUFFIX,
|
||||||
|
tier=AffixTier.MINOR,
|
||||||
|
stat_bonuses={"strength": 3, "dexterity": 2},
|
||||||
|
)
|
||||||
|
|
||||||
|
combined = generator._combine_affix_stats([affix1, affix2])
|
||||||
|
|
||||||
|
assert combined["stat_bonuses"]["strength"] == 5
|
||||||
|
assert combined["stat_bonuses"]["constitution"] == 1
|
||||||
|
assert combined["stat_bonuses"]["dexterity"] == 2
|
||||||
|
|
||||||
|
def test_combine_damage_bonuses(self):
|
||||||
|
"""Test combining damage bonuses."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
affix1 = Affix(
|
||||||
|
affix_id="sharp",
|
||||||
|
name="Sharp",
|
||||||
|
affix_type=AffixType.PREFIX,
|
||||||
|
tier=AffixTier.MINOR,
|
||||||
|
damage_bonus=3,
|
||||||
|
)
|
||||||
|
affix2 = Affix(
|
||||||
|
affix_id="keen",
|
||||||
|
name="Keen",
|
||||||
|
affix_type=AffixType.PREFIX,
|
||||||
|
tier=AffixTier.MAJOR,
|
||||||
|
damage_bonus=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
combined = generator._combine_affix_stats([affix1, affix2])
|
||||||
|
|
||||||
|
assert combined["damage_bonus"] == 8
|
||||||
|
|
||||||
|
def test_combine_crit_bonuses(self):
|
||||||
|
"""Test combining crit chance and multiplier bonuses."""
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
affix1 = Affix(
|
||||||
|
affix_id="sharp",
|
||||||
|
name="Sharp",
|
||||||
|
affix_type=AffixType.PREFIX,
|
||||||
|
tier=AffixTier.MINOR,
|
||||||
|
crit_chance_bonus=0.02,
|
||||||
|
)
|
||||||
|
affix2 = Affix(
|
||||||
|
affix_id="keen",
|
||||||
|
name="Keen",
|
||||||
|
affix_type=AffixType.PREFIX,
|
||||||
|
tier=AffixTier.MAJOR,
|
||||||
|
crit_chance_bonus=0.04,
|
||||||
|
crit_multiplier_bonus=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
combined = generator._combine_affix_stats([affix1, affix2])
|
||||||
|
|
||||||
|
assert combined["crit_chance_bonus"] == pytest.approx(0.06)
|
||||||
|
assert combined["crit_multiplier_bonus"] == pytest.approx(0.5)
|
||||||
387
api/tests/test_items.py
Normal file
387
api/tests/test_items.py
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for Item dataclass and ItemRarity enum.
|
||||||
|
|
||||||
|
Tests item creation, rarity, type checking, and serialization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from app.models.items import Item
|
||||||
|
from app.models.enums import ItemType, ItemRarity, DamageType
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemRarityEnum:
|
||||||
|
"""Tests for ItemRarity enum."""
|
||||||
|
|
||||||
|
def test_rarity_values(self):
|
||||||
|
"""Test all rarity values exist and have correct string values."""
|
||||||
|
assert ItemRarity.COMMON.value == "common"
|
||||||
|
assert ItemRarity.UNCOMMON.value == "uncommon"
|
||||||
|
assert ItemRarity.RARE.value == "rare"
|
||||||
|
assert ItemRarity.EPIC.value == "epic"
|
||||||
|
assert ItemRarity.LEGENDARY.value == "legendary"
|
||||||
|
|
||||||
|
def test_rarity_from_string(self):
|
||||||
|
"""Test creating rarity from string value."""
|
||||||
|
assert ItemRarity("common") == ItemRarity.COMMON
|
||||||
|
assert ItemRarity("uncommon") == ItemRarity.UNCOMMON
|
||||||
|
assert ItemRarity("rare") == ItemRarity.RARE
|
||||||
|
assert ItemRarity("epic") == ItemRarity.EPIC
|
||||||
|
assert ItemRarity("legendary") == ItemRarity.LEGENDARY
|
||||||
|
|
||||||
|
def test_rarity_count(self):
|
||||||
|
"""Test that there are exactly 5 rarity tiers."""
|
||||||
|
assert len(ItemRarity) == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemCreation:
|
||||||
|
"""Tests for creating Item instances."""
|
||||||
|
|
||||||
|
def test_create_basic_item(self):
|
||||||
|
"""Test creating a basic item with minimal fields."""
|
||||||
|
item = Item(
|
||||||
|
item_id="test_item",
|
||||||
|
name="Test Item",
|
||||||
|
item_type=ItemType.QUEST_ITEM,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert item.item_id == "test_item"
|
||||||
|
assert item.name == "Test Item"
|
||||||
|
assert item.item_type == ItemType.QUEST_ITEM
|
||||||
|
assert item.rarity == ItemRarity.COMMON # Default
|
||||||
|
assert item.description == ""
|
||||||
|
assert item.value == 0
|
||||||
|
assert item.is_tradeable == True
|
||||||
|
|
||||||
|
def test_item_default_rarity_is_common(self):
|
||||||
|
"""Test that items default to COMMON rarity."""
|
||||||
|
item = Item(
|
||||||
|
item_id="sword_1",
|
||||||
|
name="Iron Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert item.rarity == ItemRarity.COMMON
|
||||||
|
|
||||||
|
def test_create_item_with_rarity(self):
|
||||||
|
"""Test creating items with different rarity levels."""
|
||||||
|
uncommon = Item(
|
||||||
|
item_id="sword_2",
|
||||||
|
name="Steel Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.UNCOMMON,
|
||||||
|
)
|
||||||
|
assert uncommon.rarity == ItemRarity.UNCOMMON
|
||||||
|
|
||||||
|
rare = Item(
|
||||||
|
item_id="sword_3",
|
||||||
|
name="Mithril Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.RARE,
|
||||||
|
)
|
||||||
|
assert rare.rarity == ItemRarity.RARE
|
||||||
|
|
||||||
|
epic = Item(
|
||||||
|
item_id="sword_4",
|
||||||
|
name="Dragon Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.EPIC,
|
||||||
|
)
|
||||||
|
assert epic.rarity == ItemRarity.EPIC
|
||||||
|
|
||||||
|
legendary = Item(
|
||||||
|
item_id="sword_5",
|
||||||
|
name="Excalibur",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.LEGENDARY,
|
||||||
|
)
|
||||||
|
assert legendary.rarity == ItemRarity.LEGENDARY
|
||||||
|
|
||||||
|
def test_create_weapon(self):
|
||||||
|
"""Test creating a weapon with all weapon-specific fields."""
|
||||||
|
weapon = Item(
|
||||||
|
item_id="fire_sword",
|
||||||
|
name="Flame Blade",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.RARE,
|
||||||
|
description="A sword wreathed in flames.",
|
||||||
|
value=500,
|
||||||
|
damage=25,
|
||||||
|
damage_type=DamageType.PHYSICAL,
|
||||||
|
crit_chance=0.15,
|
||||||
|
crit_multiplier=2.5,
|
||||||
|
elemental_damage_type=DamageType.FIRE,
|
||||||
|
physical_ratio=0.7,
|
||||||
|
elemental_ratio=0.3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert weapon.is_weapon() == True
|
||||||
|
assert weapon.is_elemental_weapon() == True
|
||||||
|
assert weapon.damage == 25
|
||||||
|
assert weapon.crit_chance == 0.15
|
||||||
|
assert weapon.crit_multiplier == 2.5
|
||||||
|
assert weapon.elemental_damage_type == DamageType.FIRE
|
||||||
|
assert weapon.physical_ratio == 0.7
|
||||||
|
assert weapon.elemental_ratio == 0.3
|
||||||
|
|
||||||
|
def test_create_armor(self):
|
||||||
|
"""Test creating armor with defense/resistance."""
|
||||||
|
armor = Item(
|
||||||
|
item_id="plate_armor",
|
||||||
|
name="Steel Plate Armor",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
rarity=ItemRarity.UNCOMMON,
|
||||||
|
description="Heavy steel armor.",
|
||||||
|
value=300,
|
||||||
|
defense=15,
|
||||||
|
resistance=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert armor.is_armor() == True
|
||||||
|
assert armor.defense == 15
|
||||||
|
assert armor.resistance == 5
|
||||||
|
|
||||||
|
def test_create_consumable(self):
|
||||||
|
"""Test creating a consumable item."""
|
||||||
|
potion = Item(
|
||||||
|
item_id="health_potion",
|
||||||
|
name="Health Potion",
|
||||||
|
item_type=ItemType.CONSUMABLE,
|
||||||
|
rarity=ItemRarity.COMMON,
|
||||||
|
description="Restores 50 HP.",
|
||||||
|
value=25,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert potion.is_consumable() == True
|
||||||
|
assert potion.is_tradeable == True
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemTypeMethods:
|
||||||
|
"""Tests for item type checking methods."""
|
||||||
|
|
||||||
|
def test_is_weapon(self):
|
||||||
|
"""Test is_weapon() method."""
|
||||||
|
weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON)
|
||||||
|
armor = Item(item_id="a", name="A", item_type=ItemType.ARMOR)
|
||||||
|
|
||||||
|
assert weapon.is_weapon() == True
|
||||||
|
assert armor.is_weapon() == False
|
||||||
|
|
||||||
|
def test_is_armor(self):
|
||||||
|
"""Test is_armor() method."""
|
||||||
|
weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON)
|
||||||
|
armor = Item(item_id="a", name="A", item_type=ItemType.ARMOR)
|
||||||
|
|
||||||
|
assert armor.is_armor() == True
|
||||||
|
assert weapon.is_armor() == False
|
||||||
|
|
||||||
|
def test_is_consumable(self):
|
||||||
|
"""Test is_consumable() method."""
|
||||||
|
consumable = Item(item_id="c", name="C", item_type=ItemType.CONSUMABLE)
|
||||||
|
weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON)
|
||||||
|
|
||||||
|
assert consumable.is_consumable() == True
|
||||||
|
assert weapon.is_consumable() == False
|
||||||
|
|
||||||
|
def test_is_quest_item(self):
|
||||||
|
"""Test is_quest_item() method."""
|
||||||
|
quest = Item(item_id="q", name="Q", item_type=ItemType.QUEST_ITEM)
|
||||||
|
weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON)
|
||||||
|
|
||||||
|
assert quest.is_quest_item() == True
|
||||||
|
assert weapon.is_quest_item() == False
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemSerialization:
|
||||||
|
"""Tests for Item serialization and deserialization."""
|
||||||
|
|
||||||
|
def test_to_dict_includes_rarity(self):
|
||||||
|
"""Test that to_dict() includes rarity as string."""
|
||||||
|
item = Item(
|
||||||
|
item_id="test",
|
||||||
|
name="Test",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.EPIC,
|
||||||
|
description="Test item",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = item.to_dict()
|
||||||
|
|
||||||
|
assert data["rarity"] == "epic"
|
||||||
|
assert data["item_type"] == "weapon"
|
||||||
|
|
||||||
|
def test_from_dict_parses_rarity(self):
|
||||||
|
"""Test that from_dict() parses rarity correctly."""
|
||||||
|
data = {
|
||||||
|
"item_id": "test",
|
||||||
|
"name": "Test",
|
||||||
|
"item_type": "weapon",
|
||||||
|
"rarity": "legendary",
|
||||||
|
"description": "Test item",
|
||||||
|
}
|
||||||
|
|
||||||
|
item = Item.from_dict(data)
|
||||||
|
|
||||||
|
assert item.rarity == ItemRarity.LEGENDARY
|
||||||
|
assert item.item_type == ItemType.WEAPON
|
||||||
|
|
||||||
|
def test_from_dict_defaults_to_common_rarity(self):
|
||||||
|
"""Test that from_dict() defaults to COMMON if rarity missing."""
|
||||||
|
data = {
|
||||||
|
"item_id": "test",
|
||||||
|
"name": "Test",
|
||||||
|
"item_type": "weapon",
|
||||||
|
"description": "Test item",
|
||||||
|
# No rarity field
|
||||||
|
}
|
||||||
|
|
||||||
|
item = Item.from_dict(data)
|
||||||
|
|
||||||
|
assert item.rarity == ItemRarity.COMMON
|
||||||
|
|
||||||
|
def test_round_trip_serialization(self):
|
||||||
|
"""Test serialization and deserialization preserve all data."""
|
||||||
|
original = Item(
|
||||||
|
item_id="flame_sword",
|
||||||
|
name="Flame Blade",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.RARE,
|
||||||
|
description="A fiery blade.",
|
||||||
|
value=500,
|
||||||
|
damage=25,
|
||||||
|
damage_type=DamageType.PHYSICAL,
|
||||||
|
crit_chance=0.12,
|
||||||
|
crit_multiplier=2.5,
|
||||||
|
elemental_damage_type=DamageType.FIRE,
|
||||||
|
physical_ratio=0.7,
|
||||||
|
elemental_ratio=0.3,
|
||||||
|
defense=0,
|
||||||
|
resistance=0,
|
||||||
|
required_level=5,
|
||||||
|
stat_bonuses={"strength": 3},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Serialize then deserialize
|
||||||
|
data = original.to_dict()
|
||||||
|
restored = Item.from_dict(data)
|
||||||
|
|
||||||
|
assert restored.item_id == original.item_id
|
||||||
|
assert restored.name == original.name
|
||||||
|
assert restored.item_type == original.item_type
|
||||||
|
assert restored.rarity == original.rarity
|
||||||
|
assert restored.description == original.description
|
||||||
|
assert restored.value == original.value
|
||||||
|
assert restored.damage == original.damage
|
||||||
|
assert restored.damage_type == original.damage_type
|
||||||
|
assert restored.crit_chance == original.crit_chance
|
||||||
|
assert restored.crit_multiplier == original.crit_multiplier
|
||||||
|
assert restored.elemental_damage_type == original.elemental_damage_type
|
||||||
|
assert restored.physical_ratio == original.physical_ratio
|
||||||
|
assert restored.elemental_ratio == original.elemental_ratio
|
||||||
|
assert restored.required_level == original.required_level
|
||||||
|
assert restored.stat_bonuses == original.stat_bonuses
|
||||||
|
|
||||||
|
def test_round_trip_all_rarities(self):
|
||||||
|
"""Test round-trip serialization for all rarity levels."""
|
||||||
|
for rarity in ItemRarity:
|
||||||
|
original = Item(
|
||||||
|
item_id=f"item_{rarity.value}",
|
||||||
|
name=f"{rarity.value.title()} Item",
|
||||||
|
item_type=ItemType.CONSUMABLE,
|
||||||
|
rarity=rarity,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = original.to_dict()
|
||||||
|
restored = Item.from_dict(data)
|
||||||
|
|
||||||
|
assert restored.rarity == rarity
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemEquippability:
|
||||||
|
"""Tests for item equip requirements."""
|
||||||
|
|
||||||
|
def test_can_equip_level_requirement(self):
|
||||||
|
"""Test level requirement checking."""
|
||||||
|
item = Item(
|
||||||
|
item_id="high_level_sword",
|
||||||
|
name="Epic Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
required_level=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert item.can_equip(character_level=5) == False
|
||||||
|
assert item.can_equip(character_level=10) == True
|
||||||
|
assert item.can_equip(character_level=15) == True
|
||||||
|
|
||||||
|
def test_can_equip_class_requirement(self):
|
||||||
|
"""Test class requirement checking."""
|
||||||
|
item = Item(
|
||||||
|
item_id="mage_staff",
|
||||||
|
name="Mage Staff",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
required_class="mage",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert item.can_equip(character_level=1, character_class="warrior") == False
|
||||||
|
assert item.can_equip(character_level=1, character_class="mage") == True
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemStatBonuses:
|
||||||
|
"""Tests for item stat bonus methods."""
|
||||||
|
|
||||||
|
def test_get_total_stat_bonus(self):
|
||||||
|
"""Test getting stat bonuses from items."""
|
||||||
|
item = Item(
|
||||||
|
item_id="ring_of_power",
|
||||||
|
name="Ring of Power",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
stat_bonuses={"strength": 5, "constitution": 3},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert item.get_total_stat_bonus("strength") == 5
|
||||||
|
assert item.get_total_stat_bonus("constitution") == 3
|
||||||
|
assert item.get_total_stat_bonus("dexterity") == 0 # Not in bonuses
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemRepr:
|
||||||
|
"""Tests for item string representation."""
|
||||||
|
|
||||||
|
def test_weapon_repr(self):
|
||||||
|
"""Test weapon __repr__ output."""
|
||||||
|
weapon = Item(
|
||||||
|
item_id="sword",
|
||||||
|
name="Iron Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
damage=10,
|
||||||
|
value=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
repr_str = repr(weapon)
|
||||||
|
assert "Iron Sword" in repr_str
|
||||||
|
assert "weapon" in repr_str
|
||||||
|
|
||||||
|
def test_armor_repr(self):
|
||||||
|
"""Test armor __repr__ output."""
|
||||||
|
armor = Item(
|
||||||
|
item_id="armor",
|
||||||
|
name="Leather Armor",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
defense=5,
|
||||||
|
value=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
repr_str = repr(armor)
|
||||||
|
assert "Leather Armor" in repr_str
|
||||||
|
assert "armor" in repr_str
|
||||||
|
|
||||||
|
def test_consumable_repr(self):
|
||||||
|
"""Test consumable __repr__ output."""
|
||||||
|
potion = Item(
|
||||||
|
item_id="potion",
|
||||||
|
name="Health Potion",
|
||||||
|
item_type=ItemType.CONSUMABLE,
|
||||||
|
value=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
repr_str = repr(potion)
|
||||||
|
assert "Health Potion" in repr_str
|
||||||
|
assert "consumable" in repr_str
|
||||||
224
api/tests/test_loot_entry.py
Normal file
224
api/tests/test_loot_entry.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
"""
|
||||||
|
Tests for LootEntry model with hybrid loot support.
|
||||||
|
|
||||||
|
Tests the extended LootEntry dataclass that supports both static
|
||||||
|
and procedural loot types with backward compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.enemy import LootEntry, LootType
|
||||||
|
|
||||||
|
|
||||||
|
class TestLootEntryBackwardCompatibility:
|
||||||
|
"""Test that existing YAML format still works."""
|
||||||
|
|
||||||
|
def test_from_dict_defaults_to_static(self):
|
||||||
|
"""Old-style entries without loot_type should default to STATIC."""
|
||||||
|
entry_data = {
|
||||||
|
"item_id": "rusty_dagger",
|
||||||
|
"drop_chance": 0.15,
|
||||||
|
}
|
||||||
|
entry = LootEntry.from_dict(entry_data)
|
||||||
|
|
||||||
|
assert entry.loot_type == LootType.STATIC
|
||||||
|
assert entry.item_id == "rusty_dagger"
|
||||||
|
assert entry.drop_chance == 0.15
|
||||||
|
assert entry.quantity_min == 1
|
||||||
|
assert entry.quantity_max == 1
|
||||||
|
|
||||||
|
def test_from_dict_with_all_old_fields(self):
|
||||||
|
"""Test entry with all old-style fields."""
|
||||||
|
entry_data = {
|
||||||
|
"item_id": "gold_coin",
|
||||||
|
"drop_chance": 0.50,
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 3,
|
||||||
|
}
|
||||||
|
entry = LootEntry.from_dict(entry_data)
|
||||||
|
|
||||||
|
assert entry.loot_type == LootType.STATIC
|
||||||
|
assert entry.item_id == "gold_coin"
|
||||||
|
assert entry.drop_chance == 0.50
|
||||||
|
assert entry.quantity_min == 1
|
||||||
|
assert entry.quantity_max == 3
|
||||||
|
|
||||||
|
def test_to_dict_includes_loot_type(self):
|
||||||
|
"""Serialization should include loot_type."""
|
||||||
|
entry = LootEntry(
|
||||||
|
loot_type=LootType.STATIC,
|
||||||
|
item_id="health_potion",
|
||||||
|
drop_chance=0.2
|
||||||
|
)
|
||||||
|
data = entry.to_dict()
|
||||||
|
|
||||||
|
assert data["loot_type"] == "static"
|
||||||
|
assert data["item_id"] == "health_potion"
|
||||||
|
assert data["drop_chance"] == 0.2
|
||||||
|
|
||||||
|
|
||||||
|
class TestLootEntryStaticType:
|
||||||
|
"""Test static loot entries."""
|
||||||
|
|
||||||
|
def test_static_entry_creation(self):
|
||||||
|
"""Test creating a static loot entry."""
|
||||||
|
entry = LootEntry(
|
||||||
|
loot_type=LootType.STATIC,
|
||||||
|
item_id="goblin_ear",
|
||||||
|
drop_chance=0.60,
|
||||||
|
quantity_min=1,
|
||||||
|
quantity_max=2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert entry.loot_type == LootType.STATIC
|
||||||
|
assert entry.item_id == "goblin_ear"
|
||||||
|
assert entry.item_type is None
|
||||||
|
assert entry.rarity_bonus == 0.0
|
||||||
|
|
||||||
|
def test_static_from_dict_explicit(self):
|
||||||
|
"""Test parsing explicit static entry."""
|
||||||
|
entry_data = {
|
||||||
|
"loot_type": "static",
|
||||||
|
"item_id": "health_potion_small",
|
||||||
|
"drop_chance": 0.10,
|
||||||
|
}
|
||||||
|
entry = LootEntry.from_dict(entry_data)
|
||||||
|
|
||||||
|
assert entry.loot_type == LootType.STATIC
|
||||||
|
assert entry.item_id == "health_potion_small"
|
||||||
|
|
||||||
|
def test_static_to_dict_omits_procedural_fields(self):
|
||||||
|
"""Static entries should omit procedural-only fields."""
|
||||||
|
entry = LootEntry(
|
||||||
|
loot_type=LootType.STATIC,
|
||||||
|
item_id="gold_coin",
|
||||||
|
drop_chance=0.5
|
||||||
|
)
|
||||||
|
data = entry.to_dict()
|
||||||
|
|
||||||
|
assert "item_id" in data
|
||||||
|
assert "item_type" not in data
|
||||||
|
assert "rarity_bonus" not in data
|
||||||
|
|
||||||
|
|
||||||
|
class TestLootEntryProceduralType:
|
||||||
|
"""Test procedural loot entries."""
|
||||||
|
|
||||||
|
def test_procedural_entry_creation(self):
|
||||||
|
"""Test creating a procedural loot entry."""
|
||||||
|
entry = LootEntry(
|
||||||
|
loot_type=LootType.PROCEDURAL,
|
||||||
|
item_type="weapon",
|
||||||
|
drop_chance=0.10,
|
||||||
|
rarity_bonus=0.15
|
||||||
|
)
|
||||||
|
|
||||||
|
assert entry.loot_type == LootType.PROCEDURAL
|
||||||
|
assert entry.item_type == "weapon"
|
||||||
|
assert entry.rarity_bonus == 0.15
|
||||||
|
assert entry.item_id is None
|
||||||
|
|
||||||
|
def test_procedural_from_dict(self):
|
||||||
|
"""Test parsing procedural entry from dict."""
|
||||||
|
entry_data = {
|
||||||
|
"loot_type": "procedural",
|
||||||
|
"item_type": "armor",
|
||||||
|
"drop_chance": 0.08,
|
||||||
|
"rarity_bonus": 0.05,
|
||||||
|
}
|
||||||
|
entry = LootEntry.from_dict(entry_data)
|
||||||
|
|
||||||
|
assert entry.loot_type == LootType.PROCEDURAL
|
||||||
|
assert entry.item_type == "armor"
|
||||||
|
assert entry.drop_chance == 0.08
|
||||||
|
assert entry.rarity_bonus == 0.05
|
||||||
|
|
||||||
|
def test_procedural_to_dict_includes_item_type(self):
|
||||||
|
"""Procedural entries should include item_type and rarity_bonus."""
|
||||||
|
entry = LootEntry(
|
||||||
|
loot_type=LootType.PROCEDURAL,
|
||||||
|
item_type="weapon",
|
||||||
|
drop_chance=0.15,
|
||||||
|
rarity_bonus=0.10
|
||||||
|
)
|
||||||
|
data = entry.to_dict()
|
||||||
|
|
||||||
|
assert data["loot_type"] == "procedural"
|
||||||
|
assert data["item_type"] == "weapon"
|
||||||
|
assert data["rarity_bonus"] == 0.10
|
||||||
|
assert "item_id" not in data
|
||||||
|
|
||||||
|
def test_procedural_default_rarity_bonus(self):
|
||||||
|
"""Procedural entries default to 0.0 rarity bonus."""
|
||||||
|
entry_data = {
|
||||||
|
"loot_type": "procedural",
|
||||||
|
"item_type": "weapon",
|
||||||
|
"drop_chance": 0.10,
|
||||||
|
}
|
||||||
|
entry = LootEntry.from_dict(entry_data)
|
||||||
|
|
||||||
|
assert entry.rarity_bonus == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestLootTypeEnum:
|
||||||
|
"""Test LootType enum values."""
|
||||||
|
|
||||||
|
def test_static_value(self):
|
||||||
|
"""Test STATIC enum value."""
|
||||||
|
assert LootType.STATIC.value == "static"
|
||||||
|
|
||||||
|
def test_procedural_value(self):
|
||||||
|
"""Test PROCEDURAL enum value."""
|
||||||
|
assert LootType.PROCEDURAL.value == "procedural"
|
||||||
|
|
||||||
|
def test_from_string(self):
|
||||||
|
"""Test creating enum from string."""
|
||||||
|
assert LootType("static") == LootType.STATIC
|
||||||
|
assert LootType("procedural") == LootType.PROCEDURAL
|
||||||
|
|
||||||
|
def test_invalid_string_raises(self):
|
||||||
|
"""Test that invalid string raises ValueError."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
LootType("invalid")
|
||||||
|
|
||||||
|
|
||||||
|
class TestLootEntryRoundTrip:
|
||||||
|
"""Test serialization/deserialization round trips."""
|
||||||
|
|
||||||
|
def test_static_round_trip(self):
|
||||||
|
"""Static entry should survive round trip."""
|
||||||
|
original = LootEntry(
|
||||||
|
loot_type=LootType.STATIC,
|
||||||
|
item_id="health_potion_small",
|
||||||
|
drop_chance=0.15,
|
||||||
|
quantity_min=1,
|
||||||
|
quantity_max=2
|
||||||
|
)
|
||||||
|
|
||||||
|
data = original.to_dict()
|
||||||
|
restored = LootEntry.from_dict(data)
|
||||||
|
|
||||||
|
assert restored.loot_type == original.loot_type
|
||||||
|
assert restored.item_id == original.item_id
|
||||||
|
assert restored.drop_chance == original.drop_chance
|
||||||
|
assert restored.quantity_min == original.quantity_min
|
||||||
|
assert restored.quantity_max == original.quantity_max
|
||||||
|
|
||||||
|
def test_procedural_round_trip(self):
|
||||||
|
"""Procedural entry should survive round trip."""
|
||||||
|
original = LootEntry(
|
||||||
|
loot_type=LootType.PROCEDURAL,
|
||||||
|
item_type="weapon",
|
||||||
|
drop_chance=0.25,
|
||||||
|
rarity_bonus=0.15,
|
||||||
|
quantity_min=1,
|
||||||
|
quantity_max=1
|
||||||
|
)
|
||||||
|
|
||||||
|
data = original.to_dict()
|
||||||
|
restored = LootEntry.from_dict(data)
|
||||||
|
|
||||||
|
assert restored.loot_type == original.loot_type
|
||||||
|
assert restored.item_type == original.item_type
|
||||||
|
assert restored.drop_chance == original.drop_chance
|
||||||
|
assert restored.rarity_bonus == original.rarity_bonus
|
||||||
@@ -18,8 +18,10 @@ from app.services.session_service import (
|
|||||||
SessionNotFound,
|
SessionNotFound,
|
||||||
SessionLimitExceeded,
|
SessionLimitExceeded,
|
||||||
SessionValidationError,
|
SessionValidationError,
|
||||||
MAX_ACTIVE_SESSIONS,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Session limits are now tier-based, using a test default
|
||||||
|
MAX_ACTIVE_SESSIONS_TEST = 3
|
||||||
from app.models.session import GameSession, GameState, ConversationEntry
|
from app.models.session import GameSession, GameState, ConversationEntry
|
||||||
from app.models.enums import SessionStatus, SessionType, LocationType
|
from app.models.enums import SessionStatus, SessionType, LocationType
|
||||||
from app.models.character import Character
|
from app.models.character import Character
|
||||||
@@ -116,7 +118,7 @@ class TestSessionServiceCreation:
|
|||||||
def test_create_solo_session_limit_exceeded(self, mock_db, mock_appwrite, mock_character_service, sample_character):
|
def test_create_solo_session_limit_exceeded(self, mock_db, mock_appwrite, mock_character_service, sample_character):
|
||||||
"""Test session creation fails when limit exceeded."""
|
"""Test session creation fails when limit exceeded."""
|
||||||
mock_character_service.get_character.return_value = sample_character
|
mock_character_service.get_character.return_value = sample_character
|
||||||
mock_db.count_documents.return_value = MAX_ACTIVE_SESSIONS
|
mock_db.count_documents.return_value = MAX_ACTIVE_SESSIONS_TEST
|
||||||
|
|
||||||
service = SessionService()
|
service = SessionService()
|
||||||
with pytest.raises(SessionLimitExceeded):
|
with pytest.raises(SessionLimitExceeded):
|
||||||
|
|||||||
194
api/tests/test_static_item_loader.py
Normal file
194
api/tests/test_static_item_loader.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"""
|
||||||
|
Tests for StaticItemLoader service.
|
||||||
|
|
||||||
|
Tests the service that loads predefined items (consumables, materials)
|
||||||
|
from YAML files for use in loot tables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.services.static_item_loader import StaticItemLoader, get_static_item_loader
|
||||||
|
from app.models.enums import ItemType, ItemRarity
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaticItemLoaderInitialization:
|
||||||
|
"""Test service initialization."""
|
||||||
|
|
||||||
|
def test_init_with_default_path(self):
|
||||||
|
"""Service should initialize with default data path."""
|
||||||
|
loader = StaticItemLoader()
|
||||||
|
assert loader.data_dir.exists() or not loader._loaded
|
||||||
|
|
||||||
|
def test_init_with_custom_path(self, tmp_path):
|
||||||
|
"""Service should accept custom data path."""
|
||||||
|
loader = StaticItemLoader(data_dir=str(tmp_path))
|
||||||
|
assert loader.data_dir == tmp_path
|
||||||
|
|
||||||
|
def test_singleton_returns_same_instance(self):
|
||||||
|
"""get_static_item_loader should return singleton."""
|
||||||
|
loader1 = get_static_item_loader()
|
||||||
|
loader2 = get_static_item_loader()
|
||||||
|
assert loader1 is loader2
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaticItemLoaderLoading:
|
||||||
|
"""Test YAML loading functionality."""
|
||||||
|
|
||||||
|
def test_loads_consumables(self):
|
||||||
|
"""Should load consumable items from YAML."""
|
||||||
|
loader = get_static_item_loader()
|
||||||
|
|
||||||
|
# Check that health potion exists
|
||||||
|
assert loader.has_item("health_potion_small")
|
||||||
|
assert loader.has_item("health_potion_medium")
|
||||||
|
|
||||||
|
def test_loads_materials(self):
|
||||||
|
"""Should load material items from YAML."""
|
||||||
|
loader = get_static_item_loader()
|
||||||
|
|
||||||
|
# Check that materials exist
|
||||||
|
assert loader.has_item("goblin_ear")
|
||||||
|
assert loader.has_item("wolf_pelt")
|
||||||
|
|
||||||
|
def test_get_all_item_ids_returns_list(self):
|
||||||
|
"""get_all_item_ids should return list of item IDs."""
|
||||||
|
loader = get_static_item_loader()
|
||||||
|
item_ids = loader.get_all_item_ids()
|
||||||
|
|
||||||
|
assert isinstance(item_ids, list)
|
||||||
|
assert len(item_ids) > 0
|
||||||
|
assert "health_potion_small" in item_ids
|
||||||
|
|
||||||
|
def test_has_item_returns_false_for_missing(self):
|
||||||
|
"""has_item should return False for non-existent items."""
|
||||||
|
loader = get_static_item_loader()
|
||||||
|
assert not loader.has_item("nonexistent_item_xyz")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaticItemLoaderGetItem:
|
||||||
|
"""Test item retrieval."""
|
||||||
|
|
||||||
|
def test_get_item_returns_item_object(self):
|
||||||
|
"""get_item should return an Item instance."""
|
||||||
|
loader = get_static_item_loader()
|
||||||
|
item = loader.get_item("health_potion_small")
|
||||||
|
|
||||||
|
assert item is not None
|
||||||
|
assert item.name == "Small Health Potion"
|
||||||
|
assert item.item_type == ItemType.CONSUMABLE
|
||||||
|
assert item.rarity == ItemRarity.COMMON
|
||||||
|
|
||||||
|
def test_get_item_has_unique_id(self):
|
||||||
|
"""Each call should create item with unique ID."""
|
||||||
|
loader = get_static_item_loader()
|
||||||
|
|
||||||
|
item1 = loader.get_item("health_potion_small")
|
||||||
|
item2 = loader.get_item("health_potion_small")
|
||||||
|
|
||||||
|
assert item1.item_id != item2.item_id
|
||||||
|
assert "health_potion_small" in item1.item_id
|
||||||
|
assert "health_potion_small" in item2.item_id
|
||||||
|
|
||||||
|
def test_get_item_returns_none_for_missing(self):
|
||||||
|
"""get_item should return None for non-existent items."""
|
||||||
|
loader = get_static_item_loader()
|
||||||
|
item = loader.get_item("nonexistent_item_xyz")
|
||||||
|
|
||||||
|
assert item is None
|
||||||
|
|
||||||
|
def test_get_item_consumable_has_effects(self):
|
||||||
|
"""Consumable items should have effects_on_use."""
|
||||||
|
loader = get_static_item_loader()
|
||||||
|
item = loader.get_item("health_potion_small")
|
||||||
|
|
||||||
|
assert len(item.effects_on_use) > 0
|
||||||
|
effect = item.effects_on_use[0]
|
||||||
|
assert effect.name == "Minor Healing"
|
||||||
|
assert effect.power > 0
|
||||||
|
|
||||||
|
def test_get_item_quest_item_type(self):
|
||||||
|
"""Quest items should have correct type."""
|
||||||
|
loader = get_static_item_loader()
|
||||||
|
item = loader.get_item("goblin_ear")
|
||||||
|
|
||||||
|
assert item is not None
|
||||||
|
assert item.item_type == ItemType.QUEST_ITEM
|
||||||
|
assert item.rarity == ItemRarity.COMMON
|
||||||
|
|
||||||
|
def test_get_item_has_value(self):
|
||||||
|
"""Items should have value set."""
|
||||||
|
loader = get_static_item_loader()
|
||||||
|
item = loader.get_item("health_potion_small")
|
||||||
|
|
||||||
|
assert item.value > 0
|
||||||
|
|
||||||
|
def test_get_item_is_tradeable(self):
|
||||||
|
"""Items should default to tradeable."""
|
||||||
|
loader = get_static_item_loader()
|
||||||
|
item = loader.get_item("goblin_ear")
|
||||||
|
|
||||||
|
assert item.is_tradeable is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaticItemLoaderVariousItems:
|
||||||
|
"""Test loading various item types."""
|
||||||
|
|
||||||
|
def test_medium_health_potion(self):
|
||||||
|
"""Test medium health potion properties."""
|
||||||
|
loader = get_static_item_loader()
|
||||||
|
item = loader.get_item("health_potion_medium")
|
||||||
|
|
||||||
|
assert item is not None
|
||||||
|
assert item.rarity == ItemRarity.UNCOMMON
|
||||||
|
assert item.value > 25 # More expensive than small
|
||||||
|
|
||||||
|
def test_large_health_potion(self):
|
||||||
|
"""Test large health potion properties."""
|
||||||
|
loader = get_static_item_loader()
|
||||||
|
item = loader.get_item("health_potion_large")
|
||||||
|
|
||||||
|
assert item is not None
|
||||||
|
assert item.rarity == ItemRarity.RARE
|
||||||
|
|
||||||
|
def test_chieftain_token_rarity(self):
|
||||||
|
"""Test that chieftain token is rare."""
|
||||||
|
loader = get_static_item_loader()
|
||||||
|
item = loader.get_item("goblin_chieftain_token")
|
||||||
|
|
||||||
|
assert item is not None
|
||||||
|
assert item.rarity == ItemRarity.RARE
|
||||||
|
|
||||||
|
def test_elixir_has_buff_effect(self):
|
||||||
|
"""Test that elixirs have buff effects."""
|
||||||
|
loader = get_static_item_loader()
|
||||||
|
item = loader.get_item("elixir_of_strength")
|
||||||
|
|
||||||
|
if item: # Only test if item exists
|
||||||
|
assert len(item.effects_on_use) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaticItemLoaderCache:
|
||||||
|
"""Test caching behavior."""
|
||||||
|
|
||||||
|
def test_clear_cache(self):
|
||||||
|
"""clear_cache should reset loaded state."""
|
||||||
|
loader = StaticItemLoader()
|
||||||
|
|
||||||
|
# Trigger loading
|
||||||
|
loader._ensure_loaded()
|
||||||
|
assert loader._loaded is True
|
||||||
|
|
||||||
|
# Clear cache
|
||||||
|
loader.clear_cache()
|
||||||
|
assert loader._loaded is False
|
||||||
|
assert len(loader._cache) == 0
|
||||||
|
|
||||||
|
def test_lazy_loading(self):
|
||||||
|
"""Items should be loaded lazily on first access."""
|
||||||
|
loader = StaticItemLoader()
|
||||||
|
assert loader._loaded is False
|
||||||
|
|
||||||
|
# Access triggers loading
|
||||||
|
_ = loader.has_item("health_potion_small")
|
||||||
|
assert loader._loaded is True
|
||||||
@@ -196,3 +196,186 @@ def test_stats_repr():
|
|||||||
assert "INT=10" in repr_str
|
assert "INT=10" in repr_str
|
||||||
assert "HP=" in repr_str
|
assert "HP=" in repr_str
|
||||||
assert "MP=" in repr_str
|
assert "MP=" in repr_str
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LUK Computed Properties (Combat System Integration)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def test_crit_bonus_calculation():
|
||||||
|
"""Test crit bonus calculation: luck * 0.5%."""
|
||||||
|
stats = Stats(luck=8)
|
||||||
|
assert stats.crit_bonus == pytest.approx(0.04, abs=0.001) # 4%
|
||||||
|
|
||||||
|
stats = Stats(luck=12)
|
||||||
|
assert stats.crit_bonus == pytest.approx(0.06, abs=0.001) # 6%
|
||||||
|
|
||||||
|
stats = Stats(luck=0)
|
||||||
|
assert stats.crit_bonus == pytest.approx(0.0, abs=0.001) # 0%
|
||||||
|
|
||||||
|
|
||||||
|
def test_hit_bonus_calculation():
|
||||||
|
"""Test hit bonus (miss reduction): luck * 0.5%."""
|
||||||
|
stats = Stats(luck=8)
|
||||||
|
assert stats.hit_bonus == pytest.approx(0.04, abs=0.001) # 4%
|
||||||
|
|
||||||
|
stats = Stats(luck=12)
|
||||||
|
assert stats.hit_bonus == pytest.approx(0.06, abs=0.001) # 6%
|
||||||
|
|
||||||
|
stats = Stats(luck=20)
|
||||||
|
assert stats.hit_bonus == pytest.approx(0.10, abs=0.001) # 10%
|
||||||
|
|
||||||
|
|
||||||
|
def test_lucky_roll_chance_calculation():
|
||||||
|
"""Test lucky roll chance: 5% + (luck * 0.25%)."""
|
||||||
|
stats = Stats(luck=8)
|
||||||
|
# 5% + (8 * 0.25%) = 5% + 2% = 7%
|
||||||
|
assert stats.lucky_roll_chance == pytest.approx(0.07, abs=0.001)
|
||||||
|
|
||||||
|
stats = Stats(luck=12)
|
||||||
|
# 5% + (12 * 0.25%) = 5% + 3% = 8%
|
||||||
|
assert stats.lucky_roll_chance == pytest.approx(0.08, abs=0.001)
|
||||||
|
|
||||||
|
stats = Stats(luck=0)
|
||||||
|
# 5% + (0 * 0.25%) = 5%
|
||||||
|
assert stats.lucky_roll_chance == pytest.approx(0.05, abs=0.001)
|
||||||
|
|
||||||
|
|
||||||
|
def test_repr_includes_combat_bonuses():
|
||||||
|
"""Test that repr includes LUK-based combat bonuses."""
|
||||||
|
stats = Stats(luck=10)
|
||||||
|
repr_str = repr(stats)
|
||||||
|
|
||||||
|
assert "CRIT_BONUS=" in repr_str
|
||||||
|
assert "HIT_BONUS=" in repr_str
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Equipment Bonus Fields (Task 2.5)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def test_bonus_fields_default_to_zero():
|
||||||
|
"""Test that equipment bonus fields default to zero."""
|
||||||
|
stats = Stats()
|
||||||
|
|
||||||
|
assert stats.damage_bonus == 0
|
||||||
|
assert stats.defense_bonus == 0
|
||||||
|
assert stats.resistance_bonus == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_damage_property_with_no_bonus():
|
||||||
|
"""Test damage calculation: int(strength * 0.75) + damage_bonus with no bonus."""
|
||||||
|
stats = Stats(strength=10)
|
||||||
|
# int(10 * 0.75) = 7, no bonus
|
||||||
|
assert stats.damage == 7
|
||||||
|
|
||||||
|
stats = Stats(strength=14)
|
||||||
|
# int(14 * 0.75) = 10, no bonus
|
||||||
|
assert stats.damage == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_damage_property_with_bonus():
|
||||||
|
"""Test damage calculation includes damage_bonus from weapons."""
|
||||||
|
stats = Stats(strength=10, damage_bonus=15)
|
||||||
|
# int(10 * 0.75) + 15 = 7 + 15 = 22
|
||||||
|
assert stats.damage == 22
|
||||||
|
|
||||||
|
stats = Stats(strength=14, damage_bonus=8)
|
||||||
|
# int(14 * 0.75) + 8 = 10 + 8 = 18
|
||||||
|
assert stats.damage == 18
|
||||||
|
|
||||||
|
|
||||||
|
def test_defense_property_with_bonus():
|
||||||
|
"""Test defense calculation includes defense_bonus from armor."""
|
||||||
|
stats = Stats(constitution=10, defense_bonus=10)
|
||||||
|
# (10 // 2) + 10 = 5 + 10 = 15
|
||||||
|
assert stats.defense == 15
|
||||||
|
|
||||||
|
stats = Stats(constitution=20, defense_bonus=5)
|
||||||
|
# (20 // 2) + 5 = 10 + 5 = 15
|
||||||
|
assert stats.defense == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_resistance_property_with_bonus():
|
||||||
|
"""Test resistance calculation includes resistance_bonus from armor."""
|
||||||
|
stats = Stats(wisdom=10, resistance_bonus=8)
|
||||||
|
# (10 // 2) + 8 = 5 + 8 = 13
|
||||||
|
assert stats.resistance == 13
|
||||||
|
|
||||||
|
stats = Stats(wisdom=14, resistance_bonus=3)
|
||||||
|
# (14 // 2) + 3 = 7 + 3 = 10
|
||||||
|
assert stats.resistance == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonus_fields_serialization():
|
||||||
|
"""Test that bonus fields are included in to_dict()."""
|
||||||
|
stats = Stats(
|
||||||
|
strength=15,
|
||||||
|
damage_bonus=12,
|
||||||
|
defense_bonus=8,
|
||||||
|
resistance_bonus=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = stats.to_dict()
|
||||||
|
|
||||||
|
assert data["damage_bonus"] == 12
|
||||||
|
assert data["defense_bonus"] == 8
|
||||||
|
assert data["resistance_bonus"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonus_fields_deserialization():
|
||||||
|
"""Test that bonus fields are restored from from_dict()."""
|
||||||
|
data = {
|
||||||
|
"strength": 15,
|
||||||
|
"damage_bonus": 12,
|
||||||
|
"defense_bonus": 8,
|
||||||
|
"resistance_bonus": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
stats = Stats.from_dict(data)
|
||||||
|
|
||||||
|
assert stats.damage_bonus == 12
|
||||||
|
assert stats.defense_bonus == 8
|
||||||
|
assert stats.resistance_bonus == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_bonus_fields_deserialization_defaults():
|
||||||
|
"""Test that missing bonus fields default to zero on deserialization."""
|
||||||
|
data = {
|
||||||
|
"strength": 15,
|
||||||
|
# No bonus fields
|
||||||
|
}
|
||||||
|
|
||||||
|
stats = Stats.from_dict(data)
|
||||||
|
|
||||||
|
assert stats.damage_bonus == 0
|
||||||
|
assert stats.defense_bonus == 0
|
||||||
|
assert stats.resistance_bonus == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_includes_bonus_fields():
|
||||||
|
"""Test that copy() preserves bonus fields."""
|
||||||
|
original = Stats(
|
||||||
|
strength=15,
|
||||||
|
damage_bonus=10,
|
||||||
|
defense_bonus=8,
|
||||||
|
resistance_bonus=5,
|
||||||
|
)
|
||||||
|
copy = original.copy()
|
||||||
|
|
||||||
|
assert copy.damage_bonus == 10
|
||||||
|
assert copy.defense_bonus == 8
|
||||||
|
assert copy.resistance_bonus == 5
|
||||||
|
|
||||||
|
# Verify independence
|
||||||
|
copy.damage_bonus = 20
|
||||||
|
assert original.damage_bonus == 10
|
||||||
|
assert copy.damage_bonus == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_repr_includes_damage():
|
||||||
|
"""Test that repr includes the damage computed property."""
|
||||||
|
stats = Stats(strength=10, damage_bonus=15)
|
||||||
|
repr_str = repr(stats)
|
||||||
|
|
||||||
|
assert "DMG=" in repr_str
|
||||||
|
|||||||
864
docs/PHASE4_COMBAT_IMPLEMENTATION.md
Normal file
864
docs/PHASE4_COMBAT_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,864 @@
|
|||||||
|
# Phase 4: Combat & Progression Systems - Implementation Plan
|
||||||
|
|
||||||
|
**Status:** In Progress - Week 2 Complete, Week 3 Next
|
||||||
|
**Timeline:** 4-5 weeks
|
||||||
|
**Last Updated:** November 27, 2025
|
||||||
|
**Document Version:** 1.3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completion Summary
|
||||||
|
|
||||||
|
### Week 1: Combat Backend - COMPLETE
|
||||||
|
|
||||||
|
| Task | Description | Status | Tests |
|
||||||
|
|------|-------------|--------|-------|
|
||||||
|
| 1.1 | Verify Combat Data Models | ✅ Complete | - |
|
||||||
|
| 1.2 | Implement Combat Service | ✅ Complete | 25 tests |
|
||||||
|
| 1.3 | Implement Damage Calculator | ✅ Complete | 39 tests |
|
||||||
|
| 1.4 | Implement Effect Processor | ✅ Complete | - |
|
||||||
|
| 1.5 | Implement Combat Actions | ✅ Complete | - |
|
||||||
|
| 1.6 | Combat API Endpoints | ✅ Complete | 19 tests |
|
||||||
|
| 1.7 | Manual API Testing | ⏭️ Skipped | - |
|
||||||
|
|
||||||
|
**Files Created:**
|
||||||
|
- `/api/app/models/enemy.py` - EnemyTemplate, LootEntry dataclasses
|
||||||
|
- `/api/app/services/enemy_loader.py` - YAML-based enemy loading
|
||||||
|
- `/api/app/services/combat_service.py` - Combat orchestration service
|
||||||
|
- `/api/app/services/damage_calculator.py` - Damage formula calculations
|
||||||
|
- `/api/app/api/combat.py` - REST API endpoints
|
||||||
|
- `/api/app/data/enemies/*.yaml` - 6 sample enemy definitions
|
||||||
|
- `/api/tests/test_damage_calculator.py` - 39 tests
|
||||||
|
- `/api/tests/test_enemy_loader.py` - 25 tests
|
||||||
|
- `/api/tests/test_combat_service.py` - 25 tests
|
||||||
|
- `/api/tests/test_combat_api.py` - 19 tests
|
||||||
|
|
||||||
|
**Total Tests:** 108 passing
|
||||||
|
|
||||||
|
### Week 2: Inventory & Equipment - COMPLETE
|
||||||
|
|
||||||
|
| Task | Description | Status | Tests |
|
||||||
|
|------|-------------|--------|-------|
|
||||||
|
| 2.1 | Item Data Models (Affixes) | ✅ Complete | 24 tests |
|
||||||
|
| 2.2 | Item Data Files (YAML) | ✅ Complete | - |
|
||||||
|
| 2.2.1 | Item Generator Service | ✅ Complete | 35 tests |
|
||||||
|
| 2.3 | Inventory Service | ✅ Complete | 24 tests |
|
||||||
|
| 2.4 | Inventory API Endpoints | ✅ Complete | 25 tests |
|
||||||
|
| 2.5 | Character Stats Calculation | ✅ Complete | 17 tests |
|
||||||
|
| 2.6 | Equipment-Combat Integration | ✅ Complete | 140 tests |
|
||||||
|
| 2.7 | Combat Loot Integration | ✅ Complete | 59 tests |
|
||||||
|
|
||||||
|
**Files Created/Modified:**
|
||||||
|
- `/api/app/models/items.py` - Item with affix support, spell_power field
|
||||||
|
- `/api/app/models/affixes.py` - Affix, BaseItemTemplate dataclasses
|
||||||
|
- `/api/app/models/stats.py` - spell_power_bonus, updated damage formula
|
||||||
|
- `/api/app/models/combat.py` - Combatant weapon properties
|
||||||
|
- `/api/app/services/item_generator.py` - Procedural item generation
|
||||||
|
- `/api/app/services/inventory_service.py` - Equipment management
|
||||||
|
- `/api/app/services/damage_calculator.py` - Refactored to use stats properties
|
||||||
|
- `/api/app/services/combat_service.py` - Equipment integration
|
||||||
|
- `/api/app/api/inventory.py` - REST API endpoints
|
||||||
|
|
||||||
|
**Total Tests (Week 2):** 324+ passing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This phase implements the core combat and progression systems for Code of Conquest, enabling turn-based tactical combat, inventory management, equipment, skill trees, and the NPC shop. This is a prerequisite for the story progression and quest systems.
|
||||||
|
|
||||||
|
**Key Deliverables:**
|
||||||
|
- Turn-based combat system (API + UI)
|
||||||
|
- Inventory & equipment management
|
||||||
|
- Skill tree visualization and unlocking
|
||||||
|
- XP and leveling system
|
||||||
|
- NPC shop
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Structure
|
||||||
|
|
||||||
|
| Sub-Phase | Duration | Focus |
|
||||||
|
|-----------|----------|-------|
|
||||||
|
| **Phase 4A** | 2-3 weeks | Combat Foundation |
|
||||||
|
| **Phase 4B** | 1-2 weeks | Skill Trees & Leveling | See [`/PHASE4b.md`](/PHASE4b.md)
|
||||||
|
| **Phase 4C** | 3-4 days | NPC Shop | [`/PHASE4c.md`](/PHASE4c.md)
|
||||||
|
|
||||||
|
**Total Estimated Time:** 4-5 weeks (~140-175 hours)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4A: Combat Foundation (Weeks 1-3)
|
||||||
|
|
||||||
|
### Week 1: Combat Backend & Data Models ✅ COMPLETE
|
||||||
|
|
||||||
|
#### Task 1.1: Verify Combat Data Models ✅ COMPLETE
|
||||||
|
|
||||||
|
**Files:** `/api/app/models/combat.py`, `effects.py`, `abilities.py`, `stats.py`
|
||||||
|
|
||||||
|
Verified: Combatant, CombatEncounter dataclasses, effect types (BUFF, DEBUFF, DOT, HOT, STUN, SHIELD), stacking logic, YAML ability loading, serialization methods.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 1.2: Implement Combat Service ✅ COMPLETE
|
||||||
|
|
||||||
|
**File:** `/api/app/services/combat_service.py`
|
||||||
|
|
||||||
|
Implemented: `CombatService` class with `initiate_combat()`, `process_action()`, initiative rolling, turn management, death checking, combat end detection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 1.3: Implement Damage Calculator ✅ COMPLETE
|
||||||
|
|
||||||
|
**File:** `/api/app/services/damage_calculator.py`
|
||||||
|
|
||||||
|
Implemented: `calculate_physical_damage()`, `calculate_magical_damage()`, `apply_damage()` with shield absorption. Physical formula: `weapon.damage + (STR/2) - defense`. 39 unit tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 1.4: Implement Effect Processor ✅ COMPLETE
|
||||||
|
|
||||||
|
**File:** `/api/app/models/effects.py`
|
||||||
|
|
||||||
|
Implemented: `tick()` method for DOT/HOT damage/healing, duration tracking, stat modifiers via `get_effective_stats()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 1.5: Implement Combat Actions ✅ COMPLETE
|
||||||
|
|
||||||
|
**File:** `/api/app/services/combat_service.py`
|
||||||
|
|
||||||
|
Implemented: `_execute_attack()`, `_execute_spell()`, `_execute_item()`, `_execute_defend()` with mana costs, cooldowns, effect application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 1.6: Combat API Endpoints ✅ COMPLETE
|
||||||
|
|
||||||
|
**File:** `/api/app/api/combat.py`
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
- `POST /api/v1/combat/start` - Initiate combat
|
||||||
|
- `POST /api/v1/combat/<combat_id>/action` - Take action
|
||||||
|
- `GET /api/v1/combat/<combat_id>/state` - Get state
|
||||||
|
- `POST /api/v1/combat/<combat_id>/flee` - Attempt flee
|
||||||
|
- `POST /api/v1/combat/<combat_id>/enemy-turn` - Enemy AI
|
||||||
|
- `GET /api/v1/combat/enemies` - List templates (public)
|
||||||
|
- `GET /api/v1/combat/enemies/<id>` - Enemy details (public)
|
||||||
|
|
||||||
|
19 integration tests passing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 1.7: Manual API Testing ⏭️ SKIPPED
|
||||||
|
|
||||||
|
Covered by 108 comprehensive automated tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Week 2: Inventory & Equipment System ✅ COMPLETE
|
||||||
|
|
||||||
|
#### Task 2.1: Item Data Models ✅ COMPLETE
|
||||||
|
|
||||||
|
**Files:** `/api/app/models/items.py`, `affixes.py`, `enums.py`
|
||||||
|
|
||||||
|
Implemented: `Item` dataclass with affix support (`applied_affixes`, `base_template_id`, `generated_name`, `is_generated`), `Affix` model (PREFIX/SUFFIX types, MINOR/MAJOR/LEGENDARY tiers), `BaseItemTemplate` for procedural generation. 24 tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 2.2: Item Data Files ✅ COMPLETE
|
||||||
|
|
||||||
|
**Directory:** `/api/app/data/`
|
||||||
|
|
||||||
|
Created:
|
||||||
|
- `base_items/weapons.yaml` - 13 weapon templates
|
||||||
|
- `base_items/armor.yaml` - 12 armor templates (cloth/leather/chain/plate)
|
||||||
|
- `affixes/prefixes.yaml` - 18 prefixes (elemental, material, quality, legendary)
|
||||||
|
- `affixes/suffixes.yaml` - 11 suffixes (stat bonuses, animal totems, legendary)
|
||||||
|
- `items/consumables/potions.yaml` - Health/mana potions (small/medium/large)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 2.2.1: Item Generator Service ✅ COMPLETE
|
||||||
|
|
||||||
|
**Files:** `/api/app/services/item_generator.py`, `affix_loader.py`, `base_item_loader.py`
|
||||||
|
|
||||||
|
Implemented Diablo-style procedural generation:
|
||||||
|
- Affix distribution: COMMON/UNCOMMON (0), RARE (1), EPIC (2), LEGENDARY (3)
|
||||||
|
- Name generation: "Flaming Dagger of Strength"
|
||||||
|
- Tier weights by rarity (RARE: 80% MINOR, EPIC: 70% MAJOR, LEGENDARY: 50% LEGENDARY)
|
||||||
|
- Luck-influenced rarity rolling
|
||||||
|
|
||||||
|
35 tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 2.3: Implement Inventory Service ✅ COMPLETE
|
||||||
|
|
||||||
|
**File:** `/api/app/services/inventory_service.py`
|
||||||
|
|
||||||
|
Implemented: `add_item()`, `remove_item()`, `equip_item()`, `unequip_item()`, `use_consumable()`, `use_consumable_in_combat()`. Full object storage for generated items. Validation for slots, levels, item types. 24 tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 2.4: Inventory API Endpoints ✅ COMPLETE
|
||||||
|
|
||||||
|
**File:** `/api/app/api/inventory.py`
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
- `GET /api/v1/characters/<id>/inventory` - Get inventory + equipped
|
||||||
|
- `POST /api/v1/characters/<id>/inventory/equip` - Equip item
|
||||||
|
- `POST /api/v1/characters/<id>/inventory/unequip` - Unequip item
|
||||||
|
- `POST /api/v1/characters/<id>/inventory/use` - Use consumable
|
||||||
|
- `DELETE /api/v1/characters/<id>/inventory/<item_id>` - Drop item
|
||||||
|
|
||||||
|
25 tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 2.5: Update Character Stats Calculation ✅ COMPLETE
|
||||||
|
|
||||||
|
**Files:** `/api/app/models/stats.py`, `character.py`
|
||||||
|
|
||||||
|
Added `damage_bonus`, `defense_bonus`, `resistance_bonus` fields to Stats. Updated `get_effective_stats()` to populate from equipped weapon/armor. 17 tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 2.6: Equipment-Combat Integration ✅ COMPLETE
|
||||||
|
|
||||||
|
**Files:** `stats.py`, `items.py`, `character.py`, `combat.py`, `combat_service.py`, `damage_calculator.py`
|
||||||
|
|
||||||
|
Key changes:
|
||||||
|
- Damage scaling: `int(STR * 0.75) + damage_bonus` (was `STR // 2`)
|
||||||
|
- Added `spell_power` system for magical weapons
|
||||||
|
- Combatant weapon properties (crit_chance, crit_multiplier, elemental support)
|
||||||
|
- DamageCalculator uses `stats.damage` directly (removed `weapon_damage` param)
|
||||||
|
|
||||||
|
140 tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 2.7: Combat Loot Integration ✅ COMPLETE
|
||||||
|
|
||||||
|
**Files:** `combat_loot_service.py`, `static_item_loader.py`, `app/models/enemy.py`
|
||||||
|
|
||||||
|
Implemented hybrid loot system:
|
||||||
|
- Static drops (consumables, materials) via `StaticItemLoader`
|
||||||
|
- Procedural drops (equipment) via `ItemGenerator`
|
||||||
|
- Difficulty bonuses: EASY +0%, MEDIUM +5%, HARD +15%, BOSS +30%
|
||||||
|
- Enemy variants: goblin_scout, goblin_warrior, goblin_chieftain
|
||||||
|
|
||||||
|
59 tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Week 3: Combat UI
|
||||||
|
|
||||||
|
#### Task 3.1: Create Combat Template ✅ COMPLETE
|
||||||
|
|
||||||
|
**Objective:** Build HTMX-powered combat interface
|
||||||
|
|
||||||
|
**File:** `/public_web/templates/game/combat.html`
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ COMBAT ENCOUNTER │
|
||||||
|
├───────────────┬─────────────────────────┬───────────────────┤
|
||||||
|
│ │ │ │
|
||||||
|
│ YOUR │ COMBAT LOG │ TURN ORDER │
|
||||||
|
│ CHARACTER │ │ ─────────── │
|
||||||
|
│ ───────── │ Goblin attacks you │ 1. Aragorn ✓ │
|
||||||
|
│ HP: ████ 80 │ for 12 damage! │ 2. Goblin │
|
||||||
|
│ MP: ███ 60 │ │ 3. Orc │
|
||||||
|
│ │ You attack Goblin │ │
|
||||||
|
│ ENEMY │ for 18 damage! │ ACTIVE EFFECTS │
|
||||||
|
│ ───────── │ CRITICAL HIT! │ ─────────── │
|
||||||
|
│ Goblin │ │ 🛡️ Defending │
|
||||||
|
│ HP: ██ 12 │ Goblin is stunned! │ (1 turn) │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ───────────────── │ │
|
||||||
|
│ │ ACTION BUTTONS │ │
|
||||||
|
│ │ ───────────────── │ │
|
||||||
|
│ │ [Attack] [Spell] │ │
|
||||||
|
│ │ [Item] [Defend] │ │
|
||||||
|
│ │ │ │
|
||||||
|
└───────────────┴─────────────────────────┴───────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Combat - Code of Conquest{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/combat.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="combat-container">
|
||||||
|
<h1 class="combat-title">⚔️ COMBAT ENCOUNTER</h1>
|
||||||
|
|
||||||
|
<div class="combat-grid">
|
||||||
|
{# Left Panel - Combatants #}
|
||||||
|
<aside class="combat-panel combat-combatants">
|
||||||
|
<div class="combatant-card player-card">
|
||||||
|
<h3>{{ character.name }}</h3>
|
||||||
|
<div class="hp-bar">
|
||||||
|
<div class="hp-fill" style="width: {{ (character.current_hp / character.stats.max_hp * 100)|int }}%"></div>
|
||||||
|
<span class="hp-text">HP: {{ character.current_hp }} / {{ character.stats.max_hp }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-bar">
|
||||||
|
<div class="mp-fill" style="width: {{ (character.current_mp / character.stats.max_mp * 100)|int }}%"></div>
|
||||||
|
<span class="mp-text">MP: {{ character.current_mp }} / {{ character.stats.max_mp }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vs-divider">VS</div>
|
||||||
|
|
||||||
|
{% for enemy in enemies %}
|
||||||
|
<div class="combatant-card enemy-card" id="enemy-{{ loop.index0 }}">
|
||||||
|
<h3>{{ enemy.name }}</h3>
|
||||||
|
<div class="hp-bar">
|
||||||
|
<div class="hp-fill enemy" style="width: {{ (enemy.current_hp / enemy.stats.max_hp * 100)|int }}%"></div>
|
||||||
|
<span class="hp-text">HP: {{ enemy.current_hp }} / {{ enemy.stats.max_hp }}</span>
|
||||||
|
</div>
|
||||||
|
{% if enemy.current_hp > 0 %}
|
||||||
|
<button class="btn btn-target" onclick="selectTarget('{{ enemy.combatant_id }}')">
|
||||||
|
Target
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="defeated-badge">DEFEATED</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{# Middle Panel - Combat Log & Actions #}
|
||||||
|
<section class="combat-panel combat-main">
|
||||||
|
<div class="combat-log" id="combat-log">
|
||||||
|
<h3>Combat Log</h3>
|
||||||
|
<div class="log-entries">
|
||||||
|
{% for entry in combat_log[-10:] %}
|
||||||
|
<div class="log-entry">{{ entry }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="combat-actions" id="combat-actions">
|
||||||
|
<h3>Your Turn</h3>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button class="btn btn-action btn-attack"
|
||||||
|
hx-post="/combat/{{ combat_id }}/action"
|
||||||
|
hx-vals='{"action_type": "attack", "ability_id": "basic_attack", "target_id": ""}'
|
||||||
|
hx-target="#combat-container"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
⚔️ Attack
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-action btn-spell"
|
||||||
|
onclick="openSpellMenu()">
|
||||||
|
✨ Cast Spell
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-action btn-item"
|
||||||
|
onclick="openItemMenu()">
|
||||||
|
🎒 Use Item
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-action btn-defend"
|
||||||
|
hx-post="/combat/{{ combat_id }}/action"
|
||||||
|
hx-vals='{"action_type": "defend"}'
|
||||||
|
hx-target="#combat-container"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
🛡️ Defend
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# Right Panel - Turn Order & Effects #}
|
||||||
|
<aside class="combat-panel combat-sidebar">
|
||||||
|
<div class="turn-order">
|
||||||
|
<h3>Turn Order</h3>
|
||||||
|
<ol>
|
||||||
|
{% for combatant_id in turn_order %}
|
||||||
|
<li class="{% if loop.index0 == current_turn_index %}active-turn{% endif %}">
|
||||||
|
{{ get_combatant_name(combatant_id) }}
|
||||||
|
{% if loop.index0 == current_turn_index %}✓{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="active-effects">
|
||||||
|
<h3>Active Effects</h3>
|
||||||
|
{% for effect in character.active_effects %}
|
||||||
|
<div class="effect-badge {{ effect.effect_type }}">
|
||||||
|
{{ effect.name }} ({{ effect.duration }})
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Modal Container #}
|
||||||
|
<div id="modal-container"></div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let selectedTargetId = null;
|
||||||
|
|
||||||
|
function selectTarget(targetId) {
|
||||||
|
selectedTargetId = targetId;
|
||||||
|
|
||||||
|
// Update UI to show selected target
|
||||||
|
document.querySelectorAll('.btn-target').forEach(btn => {
|
||||||
|
btn.classList.remove('selected');
|
||||||
|
});
|
||||||
|
event.target.classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSpellMenu() {
|
||||||
|
// TODO: Open modal with spell selection
|
||||||
|
}
|
||||||
|
|
||||||
|
function openItemMenu() {
|
||||||
|
// TODO: Open modal with item selection
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll combat log to bottom
|
||||||
|
const logDiv = document.querySelector('.log-entries');
|
||||||
|
if (logDiv) {
|
||||||
|
logDiv.scrollTop = logDiv.scrollHeight;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Also create `/public_web/static/css/combat.css`**
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- 3-column layout works
|
||||||
|
- Combat log displays messages
|
||||||
|
- HP/MP bars update dynamically
|
||||||
|
- Action buttons trigger HTMX requests
|
||||||
|
- Turn order displays correctly
|
||||||
|
- Active effects shown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 3.2: Combat HTMX Integration ✅ COMPLETE
|
||||||
|
|
||||||
|
**Objective:** Wire combat UI to API via HTMX
|
||||||
|
|
||||||
|
**File:** `/public_web/app/views/game_views.py`
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
Combat Views
|
||||||
|
|
||||||
|
Routes for combat UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, g, redirect, url_for
|
||||||
|
|
||||||
|
from app.services.api_client import APIClient, APIError
|
||||||
|
from app.utils.auth import require_auth
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
combat_bp = Blueprint('combat', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@combat_bp.route('/<combat_id>')
|
||||||
|
@require_auth
|
||||||
|
def combat_view(combat_id: str):
|
||||||
|
"""Display combat interface."""
|
||||||
|
api_client = APIClient()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get combat state
|
||||||
|
response = api_client.get(f'/combat/{combat_id}/state')
|
||||||
|
combat_state = response['result']
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'game/combat.html',
|
||||||
|
combat_id=combat_id,
|
||||||
|
combat_state=combat_state,
|
||||||
|
turn_order=combat_state['turn_order'],
|
||||||
|
current_turn_index=combat_state['current_turn_index'],
|
||||||
|
combat_log=combat_state['combat_log'],
|
||||||
|
character=combat_state['combatants'][0], # Player is first
|
||||||
|
enemies=combat_state['combatants'][1:] # Rest are enemies
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error(f"Failed to load combat {combat_id}: {e}")
|
||||||
|
return redirect(url_for('game.play'))
|
||||||
|
|
||||||
|
|
||||||
|
@combat_bp.route('/<combat_id>/action', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def combat_action(combat_id: str):
|
||||||
|
"""Process combat action (HTMX endpoint)."""
|
||||||
|
api_client = APIClient()
|
||||||
|
|
||||||
|
action_data = {
|
||||||
|
'action_type': request.form.get('action_type'),
|
||||||
|
'ability_id': request.form.get('ability_id'),
|
||||||
|
'target_id': request.form.get('target_id'),
|
||||||
|
'item_id': request.form.get('item_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Submit action to API
|
||||||
|
response = api_client.post(f'/combat/{combat_id}/action', json=action_data)
|
||||||
|
result = response['result']
|
||||||
|
|
||||||
|
# Check if combat ended
|
||||||
|
if result['combat_state']['status'] in ['victory', 'defeat']:
|
||||||
|
return redirect(url_for('combat.combat_results', combat_id=combat_id))
|
||||||
|
|
||||||
|
# Re-render combat view with updated state
|
||||||
|
return render_template(
|
||||||
|
'game/combat.html',
|
||||||
|
combat_id=combat_id,
|
||||||
|
combat_state=result['combat_state'],
|
||||||
|
turn_order=result['combat_state']['turn_order'],
|
||||||
|
current_turn_index=result['combat_state']['current_turn_index'],
|
||||||
|
combat_log=result['combat_state']['combat_log'],
|
||||||
|
character=result['combat_state']['combatants'][0],
|
||||||
|
enemies=result['combat_state']['combatants'][1:]
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error(f"Combat action failed: {e}")
|
||||||
|
return render_template('partials/error.html', error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@combat_bp.route('/<combat_id>/results')
|
||||||
|
@require_auth
|
||||||
|
def combat_results(combat_id: str):
|
||||||
|
"""Display combat results (victory/defeat)."""
|
||||||
|
api_client = APIClient()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = api_client.get(f'/combat/{combat_id}/results')
|
||||||
|
results = response['result']
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'game/combat_results.html',
|
||||||
|
victory=results['victory'],
|
||||||
|
xp_gained=results['xp_gained'],
|
||||||
|
gold_gained=results['gold_gained'],
|
||||||
|
loot=results['loot']
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error(f"Failed to load combat results: {e}")
|
||||||
|
return redirect(url_for('game.play'))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Register blueprint in `/public_web/app/__init__.py`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.views.combat import combat_bp
|
||||||
|
app.register_blueprint(combat_bp, url_prefix='/combat')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Combat view loads from API
|
||||||
|
- Action buttons submit to API
|
||||||
|
- Combat state updates dynamically
|
||||||
|
- Combat results shown at end
|
||||||
|
- Errors handled gracefully
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 3.3: Inventory UI ✅ COMPLETE
|
||||||
|
|
||||||
|
**Objective:** Add inventory accordion to character panel
|
||||||
|
|
||||||
|
**File:** `/public_web/templates/game/partials/character_panel.html`
|
||||||
|
|
||||||
|
**Add Inventory Section:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
{# Existing character panel code #}
|
||||||
|
|
||||||
|
{# Add Inventory Accordion #}
|
||||||
|
<div class="panel-accordion" data-accordion="inventory">
|
||||||
|
<button class="panel-accordion-header" onclick="togglePanelAccordion(this)">
|
||||||
|
<span>Inventory <span class="count">({{ character.inventory|length }}/{{ inventory_max }})</span></span>
|
||||||
|
<span class="accordion-icon">▼</span>
|
||||||
|
</button>
|
||||||
|
<div class="panel-accordion-content">
|
||||||
|
<div class="inventory-grid">
|
||||||
|
{% for item in inventory %}
|
||||||
|
<div class="inventory-item {{ item.rarity }}"
|
||||||
|
hx-get="/inventory/{{ character.character_id }}/item/{{ item.item_id }}"
|
||||||
|
hx-target="#modal-container"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<img src="{{ item.icon_url or '/static/img/items/default.png' }}" alt="{{ item.name }}">
|
||||||
|
<span class="item-name">{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Equipment Section #}
|
||||||
|
<div class="panel-accordion" data-accordion="equipment">
|
||||||
|
<button class="panel-accordion-header" onclick="togglePanelAccordion(this)">
|
||||||
|
<span>Equipment</span>
|
||||||
|
<span class="accordion-icon">▼</span>
|
||||||
|
</button>
|
||||||
|
<div class="panel-accordion-content">
|
||||||
|
<div class="equipment-slots">
|
||||||
|
<div class="equipment-slot">
|
||||||
|
<label>Weapon:</label>
|
||||||
|
{% if character.equipped.weapon %}
|
||||||
|
<span class="equipped-item">{{ get_item_name(character.equipped.weapon) }}</span>
|
||||||
|
<button class="btn-small"
|
||||||
|
hx-post="/inventory/{{ character.character_id }}/unequip"
|
||||||
|
hx-vals='{"slot": "weapon"}'
|
||||||
|
hx-target="#character-panel"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Unequip
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="empty-slot">Empty</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="equipment-slot">
|
||||||
|
<label>Helmet:</label>
|
||||||
|
{# Similar for helmet, chest, boots, etc. #}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create `/public_web/templates/game/partials/item_modal.html`:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="modal-overlay" onclick="closeModal()">
|
||||||
|
<div class="modal-content" onclick="event.stopPropagation()">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="item-name {{ item.rarity }}">{{ item.name }}</h2>
|
||||||
|
<button class="modal-close" onclick="closeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="item-description">{{ item.description }}</p>
|
||||||
|
|
||||||
|
<div class="item-stats">
|
||||||
|
{% if item.item_type == 'weapon' %}
|
||||||
|
<p><strong>Damage:</strong> {{ item.damage }}</p>
|
||||||
|
<p><strong>Crit Chance:</strong> {{ (item.crit_chance * 100)|int }}%</p>
|
||||||
|
{% elif item.item_type == 'armor' %}
|
||||||
|
<p><strong>Defense:</strong> {{ item.defense }}</p>
|
||||||
|
<p><strong>Resistance:</strong> {{ item.resistance }}</p>
|
||||||
|
{% elif item.item_type == 'consumable' %}
|
||||||
|
<p><strong>HP Restore:</strong> {{ item.hp_restore }}</p>
|
||||||
|
<p><strong>MP Restore:</strong> {{ item.mp_restore }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="item-value">Value: {{ item.value }} gold</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
{% if item.item_type == 'weapon' %}
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
hx-post="/inventory/{{ character_id }}/equip"
|
||||||
|
hx-vals='{"item_id": "{{ item.item_id }}", "slot": "weapon"}'
|
||||||
|
hx-target="#character-panel"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Equip Weapon
|
||||||
|
</button>
|
||||||
|
{% elif item.item_type == 'consumable' %}
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
hx-post="/inventory/{{ character_id }}/use"
|
||||||
|
hx-vals='{"item_id": "{{ item.item_id }}"}'
|
||||||
|
hx-target="#character-panel"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Use Item
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Inventory displays in character panel
|
||||||
|
- Click item shows modal with details
|
||||||
|
- Equip/unequip works via HTMX
|
||||||
|
- Use consumable works
|
||||||
|
- Equipment slots show equipped items
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 3.4: Combat Testing & Polish ✅ COMPLETE
|
||||||
|
|
||||||
|
**Objective:** Playtest combat and fix bugs
|
||||||
|
|
||||||
|
**Testing Checklist:**
|
||||||
|
- ✅ Start combat from story session
|
||||||
|
- ✅ Turn order correct
|
||||||
|
- ✅ Attack deals damage
|
||||||
|
- ✅ Critical hits work
|
||||||
|
- [ ] Spells consume mana - unable to test
|
||||||
|
- ✅ Effects apply and tick correctly
|
||||||
|
- [ ] Items can be used in combat - unable to test
|
||||||
|
- ✅ Defend action works
|
||||||
|
- ✅ Victory awards XP/gold/loot
|
||||||
|
- ✅ Defeat handling works
|
||||||
|
- ✅ Combat log readable
|
||||||
|
- ✅ HP/MP bars update
|
||||||
|
- ✅ Multiple enemies work - would like to update to allow the player to select which enemy to attack
|
||||||
|
- ✅ Combat state persists (refresh page)
|
||||||
|
|
||||||
|
**Bug Fixes & Polish:**
|
||||||
|
- Fix any calculation errors
|
||||||
|
- Improve combat log messages
|
||||||
|
- Add visual feedback (animations, highlights)
|
||||||
|
- Improve mobile responsiveness
|
||||||
|
- Add loading states
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Combat flows smoothly start to finish
|
||||||
|
- No critical bugs
|
||||||
|
- UX feels responsive and clear
|
||||||
|
- Ready for real gameplay
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Phase 4B: Skill Trees & Leveling (Week 4)
|
||||||
|
See [`/PHASE4b.md`](/PHASE4b.md)
|
||||||
|
|
||||||
|
## Phase 4C: NPC Shop (Days 15-18)
|
||||||
|
See [`/PHASE4c.md`](/PHASE4c.md)
|
||||||
|
|
||||||
|
|
||||||
|
## Success Criteria - Phase 4 Complete
|
||||||
|
|
||||||
|
### Combat System
|
||||||
|
- [ ] Turn-based combat works end-to-end
|
||||||
|
- [ ] Damage calculations correct (physical, magical, critical)
|
||||||
|
- [ ] Effects process correctly (DOT, HOT, buffs, debuffs, shields, stun)
|
||||||
|
- [ ] Combat UI functional and responsive
|
||||||
|
- [ ] Victory awards XP, gold, loot
|
||||||
|
- [ ] Combat state persists
|
||||||
|
|
||||||
|
### Inventory System
|
||||||
|
- [ ] Inventory displays in UI
|
||||||
|
- [ ] Equip/unequip items works
|
||||||
|
- [ ] Consumables can be used
|
||||||
|
- [ ] Equipment affects character stats
|
||||||
|
- [ ] Item YAML data loaded correctly
|
||||||
|
|
||||||
|
### Skill Trees
|
||||||
|
- [ ] Visual skill tree UI works
|
||||||
|
- [ ] Prerequisites enforced
|
||||||
|
- [ ] Unlock skills with skill points
|
||||||
|
- [ ] Respec functionality works
|
||||||
|
- [ ] Stat bonuses apply immediately
|
||||||
|
|
||||||
|
### Leveling
|
||||||
|
- [ ] XP awarded after combat
|
||||||
|
- [ ] Level up triggers at threshold
|
||||||
|
- [ ] Skill points granted on level up
|
||||||
|
- [ ] Level up modal shown
|
||||||
|
- [ ] Character stats increase
|
||||||
|
|
||||||
|
### NPC Shop
|
||||||
|
- [ ] Shop inventory displays
|
||||||
|
- [ ] Purchase validation works
|
||||||
|
- [ ] Items added to inventory
|
||||||
|
- [ ] Gold deducted correctly
|
||||||
|
- [ ] Transactions logged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps After Phase 4
|
||||||
|
|
||||||
|
Once Phase 4 is complete, you'll have a fully playable combat game with progression. The next logical phases are:
|
||||||
|
|
||||||
|
**Phase 5: Story Progression & Quests** (Original Phase 4 from roadmap)
|
||||||
|
- AI-driven story progression
|
||||||
|
- Action prompts (button-based gameplay)
|
||||||
|
- Quest system (YAML-driven, context-aware)
|
||||||
|
- Full gameplay loop: Explore → Combat → Quests → Level Up
|
||||||
|
|
||||||
|
**Phase 6: Multiplayer Sessions**
|
||||||
|
- Invite-based co-op
|
||||||
|
- Time-limited sessions
|
||||||
|
- AI-generated campaigns
|
||||||
|
|
||||||
|
**Phase 7: Marketplace & Economy**
|
||||||
|
- Player-to-player trading
|
||||||
|
- Auction system
|
||||||
|
- Economy balancing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Testing Strategy
|
||||||
|
|
||||||
|
### Manual Testing Checklist
|
||||||
|
|
||||||
|
**Combat:**
|
||||||
|
- [ ] Start combat from story
|
||||||
|
- [ ] Turn order correct
|
||||||
|
- [ ] Attack deals damage
|
||||||
|
- [ ] Spells work
|
||||||
|
- [ ] Items usable in combat
|
||||||
|
- [ ] Defend action
|
||||||
|
- [ ] Victory conditions
|
||||||
|
- [ ] Defeat handling
|
||||||
|
|
||||||
|
**Inventory:**
|
||||||
|
- [ ] Add items
|
||||||
|
- [ ] Remove items
|
||||||
|
- [ ] Equip weapons
|
||||||
|
- [ ] Equip armor
|
||||||
|
- [ ] Use consumables
|
||||||
|
- [ ] Inventory UI updates
|
||||||
|
|
||||||
|
**Skills:**
|
||||||
|
- [ ] View skill trees
|
||||||
|
- [ ] Unlock skills
|
||||||
|
- [ ] Prerequisites enforced
|
||||||
|
- [ ] Stat bonuses apply
|
||||||
|
- [ ] Respec works
|
||||||
|
|
||||||
|
**Shop:**
|
||||||
|
- [ ] Browse inventory
|
||||||
|
- [ ] Purchase items
|
||||||
|
- [ ] Insufficient gold handling
|
||||||
|
- [ ] Transaction logging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Document Maintenance
|
||||||
|
|
||||||
|
**Update this document as you complete tasks:**
|
||||||
|
- Mark tasks complete with ✅
|
||||||
|
- Add notes about implementation decisions
|
||||||
|
- Update time estimates based on actual progress
|
||||||
|
- Document any blockers or challenges
|
||||||
|
|
||||||
|
**Good luck with Phase 4 implementation!** 🚀
|
||||||
467
docs/PHASE4b.md
Normal file
467
docs/PHASE4b.md
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
|
||||||
|
## Phase 4B: Skill Trees & Leveling (Week 4)
|
||||||
|
|
||||||
|
### Task 4.1: Verify Skill Tree Data (2 hours)
|
||||||
|
|
||||||
|
**Objective:** Review skill system
|
||||||
|
|
||||||
|
**Files to Review:**
|
||||||
|
- `/api/app/models/skills.py` - SkillNode, SkillTree, PlayerClass
|
||||||
|
- `/api/app/data/skills/` - Skill YAML files for all 8 classes
|
||||||
|
|
||||||
|
**Verification Checklist:**
|
||||||
|
- [ ] Skill trees loaded from YAML
|
||||||
|
- [ ] Each class has 2 skill trees
|
||||||
|
- [ ] Each tree has 5 tiers
|
||||||
|
- [ ] Prerequisites work correctly
|
||||||
|
- [ ] Stat bonuses apply correctly
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- All 8 classes have complete skill trees
|
||||||
|
- Unlock logic works
|
||||||
|
- Respec logic implemented
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4.2: Create Skill Tree Template (2 days / 16 hours)
|
||||||
|
|
||||||
|
**Objective:** Visual skill tree UI
|
||||||
|
|
||||||
|
**File:** `/public_web/templates/character/skills.html`
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ CHARACTER SKILL TREES │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Skill Points Available: 5 [Respec] ($$$)│
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────┐ ┌────────────────────────┐ │
|
||||||
|
│ │ TREE 1: Combat │ │ TREE 2: Utility │ │
|
||||||
|
│ ├────────────────────────┤ ├────────────────────────┤ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ Tier 5: [⬢] [⬢] │ │ Tier 5: [⬢] [⬢] │ │
|
||||||
|
│ │ │ │ │ │ │ │ │ │
|
||||||
|
│ │ Tier 4: [⬢] [⬢] │ │ Tier 4: [⬢] [⬢] │ │
|
||||||
|
│ │ │ │ │ │ │ │ │ │
|
||||||
|
│ │ Tier 3: [⬢] [⬢] │ │ Tier 3: [⬢] [⬢] │ │
|
||||||
|
│ │ │ │ │ │ │ │ │ │
|
||||||
|
│ │ Tier 2: [✓] [⬢] │ │ Tier 2: [⬢] [✓] │ │
|
||||||
|
│ │ │ │ │ │ │ │ │ │
|
||||||
|
│ │ Tier 1: [✓] [✓] │ │ Tier 1: [✓] [✓] │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ └────────────────────────┘ └────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Legend: [✓] Unlocked [⬡] Available [⬢] Locked │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Skill Trees - {{ character.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="skills-container">
|
||||||
|
<div class="skills-header">
|
||||||
|
<h1>{{ character.name }}'s Skill Trees</h1>
|
||||||
|
<div class="skills-info">
|
||||||
|
<span class="skill-points">Skill Points: <strong>{{ character.skill_points }}</strong></span>
|
||||||
|
<button class="btn btn-warning btn-respec"
|
||||||
|
hx-post="/characters/{{ character.character_id }}/skills/respec"
|
||||||
|
hx-confirm="Respec costs {{ respec_cost }} gold. Continue?"
|
||||||
|
hx-target=".skills-container"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Respec ({{ respec_cost }} gold)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="skill-trees-grid">
|
||||||
|
{% for tree in character.skill_trees %}
|
||||||
|
<div class="skill-tree">
|
||||||
|
<h2 class="tree-name">{{ tree.name }}</h2>
|
||||||
|
<p class="tree-description">{{ tree.description }}</p>
|
||||||
|
|
||||||
|
<div class="tree-diagram">
|
||||||
|
{% for tier in range(5, 0, -1) %}
|
||||||
|
<div class="skill-tier" data-tier="{{ tier }}">
|
||||||
|
<span class="tier-label">Tier {{ tier }}</span>
|
||||||
|
<div class="skill-nodes">
|
||||||
|
{% for node in tree.get_nodes_by_tier(tier) %}
|
||||||
|
<div class="skill-node {{ get_node_status(node, character) }}"
|
||||||
|
data-skill-id="{{ node.skill_id }}"
|
||||||
|
hx-get="/skills/{{ node.skill_id }}/tooltip"
|
||||||
|
hx-target="#skill-tooltip"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-trigger="mouseenter">
|
||||||
|
|
||||||
|
<div class="node-icon">
|
||||||
|
{% if node.skill_id in character.unlocked_skills %}
|
||||||
|
✓
|
||||||
|
{% elif character.can_unlock(node.skill_id) %}
|
||||||
|
⬡
|
||||||
|
{% else %}
|
||||||
|
⬢
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="node-name">{{ node.name }}</span>
|
||||||
|
|
||||||
|
{% if character.can_unlock(node.skill_id) and character.skill_points > 0 %}
|
||||||
|
<button class="btn-unlock"
|
||||||
|
hx-post="/characters/{{ character.character_id }}/skills/unlock"
|
||||||
|
hx-vals='{"skill_id": "{{ node.skill_id }}"}'
|
||||||
|
hx-target=".skills-container"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Unlock
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Draw prerequisite lines #}
|
||||||
|
{% if node.prerequisite_skill_id %}
|
||||||
|
<div class="prerequisite-line"></div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Skill Tooltip (populated via HTMX) #}
|
||||||
|
<div id="skill-tooltip" class="skill-tooltip"></div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Also create `/public_web/templates/character/partials/skill_tooltip.html`:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="tooltip-content">
|
||||||
|
<h3 class="skill-name">{{ skill.name }}</h3>
|
||||||
|
<p class="skill-description">{{ skill.description }}</p>
|
||||||
|
|
||||||
|
<div class="skill-bonuses">
|
||||||
|
<strong>Bonuses:</strong>
|
||||||
|
<ul>
|
||||||
|
{% for stat, bonus in skill.stat_bonuses.items() %}
|
||||||
|
<li>+{{ bonus }} {{ stat|title }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if skill.prerequisite_skill_id %}
|
||||||
|
<p class="prerequisite">
|
||||||
|
<strong>Requires:</strong> {{ get_skill_name(skill.prerequisite_skill_id) }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Dual skill tree layout works
|
||||||
|
- 5 tiers × 2 nodes per tree displayed
|
||||||
|
- Locked/available/unlocked states visual
|
||||||
|
- Prerequisite lines drawn
|
||||||
|
- Hover shows tooltip
|
||||||
|
- Mobile responsive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4.3: Skill Unlock HTMX (4 hours)
|
||||||
|
|
||||||
|
**Objective:** Click to unlock skills
|
||||||
|
|
||||||
|
**File:** `/public_web/app/views/skills.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
Skill Views
|
||||||
|
|
||||||
|
Routes for skill tree UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, g
|
||||||
|
|
||||||
|
from app.services.api_client import APIClient, APIError
|
||||||
|
from app.utils.auth import require_auth
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
skills_bp = Blueprint('skills', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@skills_bp.route('/<skill_id>/tooltip', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def skill_tooltip(skill_id: str):
|
||||||
|
"""Get skill tooltip (HTMX partial)."""
|
||||||
|
# Load skill data
|
||||||
|
# Return rendered tooltip
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@skills_bp.route('/characters/<character_id>/skills', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def character_skills(character_id: str):
|
||||||
|
"""Display character skill trees."""
|
||||||
|
api_client = APIClient()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get character
|
||||||
|
response = api_client.get(f'/characters/{character_id}')
|
||||||
|
character = response['result']
|
||||||
|
|
||||||
|
# Calculate respec cost
|
||||||
|
respec_cost = character['level'] * 100
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'character/skills.html',
|
||||||
|
character=character,
|
||||||
|
respec_cost=respec_cost
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error(f"Failed to load skills: {e}")
|
||||||
|
return render_template('partials/error.html', error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@skills_bp.route('/characters/<character_id>/skills/unlock', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def unlock_skill(character_id: str):
|
||||||
|
"""Unlock skill (HTMX endpoint)."""
|
||||||
|
api_client = APIClient()
|
||||||
|
skill_id = request.form.get('skill_id')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Unlock skill via API
|
||||||
|
response = api_client.post(
|
||||||
|
f'/characters/{character_id}/skills/unlock',
|
||||||
|
json={'skill_id': skill_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-render skill trees
|
||||||
|
character = response['result']['character']
|
||||||
|
respec_cost = character['level'] * 100
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'character/skills.html',
|
||||||
|
character=character,
|
||||||
|
respec_cost=respec_cost
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error(f"Failed to unlock skill: {e}")
|
||||||
|
return render_template('partials/error.html', error=str(e))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Click available node unlocks skill
|
||||||
|
- Skill points decrease
|
||||||
|
- Stat bonuses apply immediately
|
||||||
|
- Prerequisites enforced
|
||||||
|
- UI updates without page reload
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4.4: Respec Functionality (4 hours)
|
||||||
|
|
||||||
|
**Objective:** Respec button with confirmation
|
||||||
|
|
||||||
|
**Implementation:** (in `skills_bp`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@skills_bp.route('/characters/<character_id>/skills/respec', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def respec_skills(character_id: str):
|
||||||
|
"""Respec all skills."""
|
||||||
|
api_client = APIClient()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = api_client.post(f'/characters/{character_id}/skills/respec')
|
||||||
|
character = response['result']['character']
|
||||||
|
respec_cost = character['level'] * 100
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'character/skills.html',
|
||||||
|
character=character,
|
||||||
|
respec_cost=respec_cost,
|
||||||
|
message="Skills reset! All skill points refunded."
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error(f"Failed to respec: {e}")
|
||||||
|
return render_template('partials/error.html', error=str(e))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Respec button costs gold
|
||||||
|
- Confirmation modal shown
|
||||||
|
- All skills reset
|
||||||
|
- Skill points refunded
|
||||||
|
- Gold deducted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4.5: XP & Leveling System (1 day / 8 hours)
|
||||||
|
|
||||||
|
**Objective:** Award XP after combat, level up grants skill points
|
||||||
|
|
||||||
|
**File:** `/api/app/services/leveling_service.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
Leveling Service
|
||||||
|
|
||||||
|
Manages XP gain and level ups.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models.character import Character
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
class LevelingService:
|
||||||
|
"""Service for XP and leveling."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def xp_required_for_level(level: int) -> int:
|
||||||
|
"""
|
||||||
|
Calculate XP required for a given level.
|
||||||
|
|
||||||
|
Formula: 100 * (level ^ 2)
|
||||||
|
"""
|
||||||
|
return 100 * (level ** 2)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def award_xp(character: Character, xp_amount: int) -> dict:
|
||||||
|
"""
|
||||||
|
Award XP to character and check for level up.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
xp_amount: XP to award
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with leveled_up, new_level, skill_points_gained
|
||||||
|
"""
|
||||||
|
character.experience += xp_amount
|
||||||
|
|
||||||
|
leveled_up = False
|
||||||
|
levels_gained = 0
|
||||||
|
|
||||||
|
# Check for level ups (can level multiple times)
|
||||||
|
while character.experience >= LevelingService.xp_required_for_level(character.level + 1):
|
||||||
|
character.level += 1
|
||||||
|
character.skill_points += 1
|
||||||
|
levels_gained += 1
|
||||||
|
leveled_up = True
|
||||||
|
|
||||||
|
logger.info(f"Character {character.character_id} leveled up to {character.level}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'leveled_up': leveled_up,
|
||||||
|
'new_level': character.level if leveled_up else None,
|
||||||
|
'skill_points_gained': levels_gained,
|
||||||
|
'xp_gained': xp_amount
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update Combat Results Endpoint:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In /api/app/api/combat.py
|
||||||
|
|
||||||
|
@combat_bp.route('/<combat_id>/results', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_combat_results(combat_id: str):
|
||||||
|
"""Get combat results with XP/loot."""
|
||||||
|
combat_service = CombatService(get_appwrite_service())
|
||||||
|
encounter = combat_service.get_encounter(combat_id)
|
||||||
|
|
||||||
|
if encounter.status != CombatStatus.VICTORY:
|
||||||
|
return error_response("Combat not won", 400)
|
||||||
|
|
||||||
|
# Calculate XP (based on enemy difficulty)
|
||||||
|
xp_gained = sum(enemy.level * 50 for enemy in encounter.combatants if not enemy.is_player)
|
||||||
|
|
||||||
|
# Award XP to character
|
||||||
|
char_service = get_character_service()
|
||||||
|
character = char_service.get_character(encounter.character_id, g.user_id)
|
||||||
|
|
||||||
|
from app.services.leveling_service import LevelingService
|
||||||
|
level_result = LevelingService.award_xp(character, xp_gained)
|
||||||
|
|
||||||
|
# Award gold
|
||||||
|
gold_gained = sum(enemy.level * 25 for enemy in encounter.combatants if not enemy.is_player)
|
||||||
|
character.gold += gold_gained
|
||||||
|
|
||||||
|
# Generate loot (TODO: implement loot tables)
|
||||||
|
loot = []
|
||||||
|
|
||||||
|
# Save character
|
||||||
|
char_service.update_character(character)
|
||||||
|
|
||||||
|
return success_response({
|
||||||
|
'victory': True,
|
||||||
|
'xp_gained': xp_gained,
|
||||||
|
'gold_gained': gold_gained,
|
||||||
|
'loot': loot,
|
||||||
|
'level_up': level_result
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create Level Up Modal Template:**
|
||||||
|
|
||||||
|
**File:** `/public_web/templates/game/partials/level_up_modal.html`
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="modal-overlay">
|
||||||
|
<div class="modal-content level-up-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>🎉 LEVEL UP! 🎉</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="level-up-text">
|
||||||
|
Congratulations! You've reached <strong>Level {{ new_level }}</strong>!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="level-up-rewards">
|
||||||
|
<p>You gained:</p>
|
||||||
|
<ul>
|
||||||
|
<li>+1 Skill Point</li>
|
||||||
|
<li>+{{ stat_increases.vitality }} Vitality</li>
|
||||||
|
<li>+{{ stat_increases.spirit }} Spirit</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-primary" onclick="closeModal()">Awesome!</button>
|
||||||
|
<a href="/characters/{{ character_id }}/skills" class="btn btn-secondary">
|
||||||
|
View Skill Trees
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- XP awarded after combat victory
|
||||||
|
- Level up triggers at XP threshold
|
||||||
|
- Skill points granted on level up
|
||||||
|
- Level up modal shown
|
||||||
|
- Character stats increase
|
||||||
|
|
||||||
|
---
|
||||||
513
docs/Phase4c.md
Normal file
513
docs/Phase4c.md
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
|
||||||
|
## Phase 4C: NPC Shop (Days 15-18)
|
||||||
|
|
||||||
|
### Task 5.1: Define Shop Inventory (4 hours)
|
||||||
|
|
||||||
|
**Objective:** Create YAML for shop items
|
||||||
|
|
||||||
|
**File:** `/api/app/data/shop/general_store.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
shop_id: "general_store"
|
||||||
|
shop_name: "General Store"
|
||||||
|
shop_description: "A well-stocked general store with essential supplies."
|
||||||
|
shopkeeper_name: "Merchant Guildmaster"
|
||||||
|
|
||||||
|
inventory:
|
||||||
|
# Weapons
|
||||||
|
- item_id: "iron_sword"
|
||||||
|
stock: -1 # Unlimited stock (-1)
|
||||||
|
price: 50
|
||||||
|
|
||||||
|
- item_id: "oak_bow"
|
||||||
|
stock: -1
|
||||||
|
price: 45
|
||||||
|
|
||||||
|
# Armor
|
||||||
|
- item_id: "leather_helmet"
|
||||||
|
stock: -1
|
||||||
|
price: 30
|
||||||
|
|
||||||
|
- item_id: "leather_chest"
|
||||||
|
stock: -1
|
||||||
|
price: 60
|
||||||
|
|
||||||
|
# Consumables
|
||||||
|
- item_id: "health_potion_small"
|
||||||
|
stock: -1
|
||||||
|
price: 10
|
||||||
|
|
||||||
|
- item_id: "health_potion_medium"
|
||||||
|
stock: -1
|
||||||
|
price: 30
|
||||||
|
|
||||||
|
- item_id: "mana_potion_small"
|
||||||
|
stock: -1
|
||||||
|
price: 15
|
||||||
|
|
||||||
|
- item_id: "antidote"
|
||||||
|
stock: -1
|
||||||
|
price: 20
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Shop inventory defined in YAML
|
||||||
|
- Mix of weapons, armor, consumables
|
||||||
|
- Reasonable pricing
|
||||||
|
- Unlimited stock for basics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5.2: Shop API Endpoints (4 hours)
|
||||||
|
|
||||||
|
**Objective:** Create shop endpoints
|
||||||
|
|
||||||
|
**File:** `/api/app/api/shop.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
Shop API Blueprint
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- GET /api/v1/shop/inventory - Browse shop items
|
||||||
|
- POST /api/v1/shop/purchase - Purchase item
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, request, g
|
||||||
|
|
||||||
|
from app.services.shop_service import ShopService
|
||||||
|
from app.services.character_service import get_character_service
|
||||||
|
from app.services.appwrite_service import get_appwrite_service
|
||||||
|
from app.utils.response import success_response, error_response
|
||||||
|
from app.utils.auth import require_auth
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
shop_bp = Blueprint('shop', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shop_bp.route('/inventory', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_shop_inventory():
|
||||||
|
"""Get shop inventory."""
|
||||||
|
shop_service = ShopService()
|
||||||
|
inventory = shop_service.get_shop_inventory("general_store")
|
||||||
|
|
||||||
|
return success_response({
|
||||||
|
'shop_name': "General Store",
|
||||||
|
'inventory': [
|
||||||
|
{
|
||||||
|
'item': item.to_dict(),
|
||||||
|
'price': price,
|
||||||
|
'in_stock': True
|
||||||
|
}
|
||||||
|
for item, price in inventory
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@shop_bp.route('/purchase', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def purchase_item():
|
||||||
|
"""
|
||||||
|
Purchase item from shop.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
{
|
||||||
|
"character_id": "char_abc",
|
||||||
|
"item_id": "iron_sword",
|
||||||
|
"quantity": 1
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
character_id = data.get('character_id')
|
||||||
|
item_id = data.get('item_id')
|
||||||
|
quantity = data.get('quantity', 1)
|
||||||
|
|
||||||
|
# Get character
|
||||||
|
char_service = get_character_service()
|
||||||
|
character = char_service.get_character(character_id, g.user_id)
|
||||||
|
|
||||||
|
# Purchase item
|
||||||
|
shop_service = ShopService()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = shop_service.purchase_item(
|
||||||
|
character,
|
||||||
|
"general_store",
|
||||||
|
item_id,
|
||||||
|
quantity
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save character
|
||||||
|
char_service.update_character(character)
|
||||||
|
|
||||||
|
return success_response(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return error_response(str(e), 400)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Also create `/api/app/services/shop_service.py`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
Shop Service
|
||||||
|
|
||||||
|
Manages NPC shop inventory and purchases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
from app.models.items import Item
|
||||||
|
from app.models.character import Character
|
||||||
|
from app.services.item_loader import ItemLoader
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
class ShopService:
|
||||||
|
"""Service for NPC shops."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.item_loader = ItemLoader()
|
||||||
|
self.shops = self._load_shops()
|
||||||
|
|
||||||
|
def _load_shops(self) -> dict:
|
||||||
|
"""Load all shop data from YAML."""
|
||||||
|
shops = {}
|
||||||
|
|
||||||
|
with open('app/data/shop/general_store.yaml', 'r') as f:
|
||||||
|
shop_data = yaml.safe_load(f)
|
||||||
|
shops[shop_data['shop_id']] = shop_data
|
||||||
|
|
||||||
|
return shops
|
||||||
|
|
||||||
|
def get_shop_inventory(self, shop_id: str) -> List[Tuple[Item, int]]:
|
||||||
|
"""
|
||||||
|
Get shop inventory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (Item, price) tuples
|
||||||
|
"""
|
||||||
|
shop = self.shops.get(shop_id)
|
||||||
|
if not shop:
|
||||||
|
return []
|
||||||
|
|
||||||
|
inventory = []
|
||||||
|
for item_data in shop['inventory']:
|
||||||
|
item = self.item_loader.get_item(item_data['item_id'])
|
||||||
|
price = item_data['price']
|
||||||
|
inventory.append((item, price))
|
||||||
|
|
||||||
|
return inventory
|
||||||
|
|
||||||
|
def purchase_item(
|
||||||
|
self,
|
||||||
|
character: Character,
|
||||||
|
shop_id: str,
|
||||||
|
item_id: str,
|
||||||
|
quantity: int = 1
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Purchase item from shop.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: Character instance
|
||||||
|
shop_id: Shop ID
|
||||||
|
item_id: Item to purchase
|
||||||
|
quantity: Quantity to buy
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Purchase result dict
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If insufficient gold or item not found
|
||||||
|
"""
|
||||||
|
shop = self.shops.get(shop_id)
|
||||||
|
if not shop:
|
||||||
|
raise ValueError("Shop not found")
|
||||||
|
|
||||||
|
# Find item in shop inventory
|
||||||
|
item_data = next(
|
||||||
|
(i for i in shop['inventory'] if i['item_id'] == item_id),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
if not item_data:
|
||||||
|
raise ValueError("Item not available in shop")
|
||||||
|
|
||||||
|
price = item_data['price'] * quantity
|
||||||
|
|
||||||
|
# Check if character has enough gold
|
||||||
|
if character.gold < price:
|
||||||
|
raise ValueError(f"Not enough gold. Need {price}, have {character.gold}")
|
||||||
|
|
||||||
|
# Deduct gold
|
||||||
|
character.gold -= price
|
||||||
|
|
||||||
|
# Add items to inventory
|
||||||
|
for _ in range(quantity):
|
||||||
|
if item_id not in character.inventory_item_ids:
|
||||||
|
character.inventory_item_ids.append(item_id)
|
||||||
|
else:
|
||||||
|
# Item already exists, increment stack (if stackable)
|
||||||
|
# For now, just add multiple entries
|
||||||
|
character.inventory_item_ids.append(item_id)
|
||||||
|
|
||||||
|
logger.info(f"Character {character.character_id} purchased {quantity}x {item_id} for {price} gold")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'item_purchased': item_id,
|
||||||
|
'quantity': quantity,
|
||||||
|
'total_cost': price,
|
||||||
|
'gold_remaining': character.gold
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Shop inventory endpoint works
|
||||||
|
- Purchase endpoint validates gold
|
||||||
|
- Items added to inventory
|
||||||
|
- Gold deducted
|
||||||
|
- Transactions logged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5.3: Shop UI (1 day / 8 hours)
|
||||||
|
|
||||||
|
**Objective:** Shop browse and purchase interface
|
||||||
|
|
||||||
|
**File:** `/public_web/templates/shop/index.html`
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Shop - Code of Conquest{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="shop-container">
|
||||||
|
<div class="shop-header">
|
||||||
|
<h1>🏪 {{ shop_name }}</h1>
|
||||||
|
<p class="shopkeeper">Shopkeeper: {{ shopkeeper_name }}</p>
|
||||||
|
<p class="player-gold">Your Gold: <strong>{{ character.gold }}</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shop-inventory">
|
||||||
|
{% for item_entry in inventory %}
|
||||||
|
<div class="shop-item-card {{ item_entry.item.rarity }}">
|
||||||
|
<div class="item-header">
|
||||||
|
<h3>{{ item_entry.item.name }}</h3>
|
||||||
|
<span class="item-price">{{ item_entry.price }} gold</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="item-description">{{ item_entry.item.description }}</p>
|
||||||
|
|
||||||
|
<div class="item-stats">
|
||||||
|
{% if item_entry.item.item_type == 'weapon' %}
|
||||||
|
<span>⚔️ Damage: {{ item_entry.item.damage }}</span>
|
||||||
|
{% elif item_entry.item.item_type == 'armor' %}
|
||||||
|
<span>🛡️ Defense: {{ item_entry.item.defense }}</span>
|
||||||
|
{% elif item_entry.item.item_type == 'consumable' %}
|
||||||
|
<span>❤️ Restores: {{ item_entry.item.hp_restore }} HP</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary btn-purchase"
|
||||||
|
{% if character.gold < item_entry.price %}disabled{% endif %}
|
||||||
|
hx-post="/shop/purchase"
|
||||||
|
hx-vals='{"character_id": "{{ character.character_id }}", "item_id": "{{ item_entry.item.item_id }}"}'
|
||||||
|
hx-target=".shop-container"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
{% if character.gold >= item_entry.price %}
|
||||||
|
Purchase
|
||||||
|
{% else %}
|
||||||
|
Not Enough Gold
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create view in `/public_web/app/views/shop.py`:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
Shop Views
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, g
|
||||||
|
|
||||||
|
from app.services.api_client import APIClient, APIError
|
||||||
|
from app.utils.auth import require_auth
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
shop_bp = Blueprint('shop', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shop_bp.route('/')
|
||||||
|
@require_auth
|
||||||
|
def shop_index():
|
||||||
|
"""Display shop."""
|
||||||
|
api_client = APIClient()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get shop inventory
|
||||||
|
shop_response = api_client.get('/shop/inventory')
|
||||||
|
inventory = shop_response['result']['inventory']
|
||||||
|
|
||||||
|
# Get character (for gold display)
|
||||||
|
char_response = api_client.get(f'/characters/{g.character_id}')
|
||||||
|
character = char_response['result']
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'shop/index.html',
|
||||||
|
shop_name="General Store",
|
||||||
|
shopkeeper_name="Merchant Guildmaster",
|
||||||
|
inventory=inventory,
|
||||||
|
character=character
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error(f"Failed to load shop: {e}")
|
||||||
|
return render_template('partials/error.html', error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@shop_bp.route('/purchase', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def purchase():
|
||||||
|
"""Purchase item (HTMX endpoint)."""
|
||||||
|
api_client = APIClient()
|
||||||
|
|
||||||
|
purchase_data = {
|
||||||
|
'character_id': request.form.get('character_id'),
|
||||||
|
'item_id': request.form.get('item_id'),
|
||||||
|
'quantity': 1
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = api_client.post('/shop/purchase', json=purchase_data)
|
||||||
|
|
||||||
|
# Reload shop
|
||||||
|
return shop_index()
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error(f"Purchase failed: {e}")
|
||||||
|
return render_template('partials/error.html', error=str(e))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Shop displays all items
|
||||||
|
- Item cards show stats and price
|
||||||
|
- Purchase button disabled if not enough gold
|
||||||
|
- Purchase adds item to inventory
|
||||||
|
- Gold updates dynamically
|
||||||
|
- UI refreshes after purchase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5.4: Transaction Logging (2 hours)
|
||||||
|
|
||||||
|
**Objective:** Log all shop purchases
|
||||||
|
|
||||||
|
**File:** `/api/app/models/transaction.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
Transaction Model
|
||||||
|
|
||||||
|
Tracks all gold transactions (shop, trades, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Transaction:
|
||||||
|
"""Represents a gold transaction."""
|
||||||
|
|
||||||
|
transaction_id: str
|
||||||
|
transaction_type: str # "shop_purchase", "trade", "quest_reward", etc.
|
||||||
|
character_id: str
|
||||||
|
amount: int # Negative for expenses, positive for income
|
||||||
|
description: str
|
||||||
|
timestamp: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize to dict."""
|
||||||
|
return {
|
||||||
|
"transaction_id": self.transaction_id,
|
||||||
|
"transaction_type": self.transaction_type,
|
||||||
|
"character_id": self.character_id,
|
||||||
|
"amount": self.amount,
|
||||||
|
"description": self.description,
|
||||||
|
"timestamp": self.timestamp.isoformat(),
|
||||||
|
"metadata": self.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> 'Transaction':
|
||||||
|
"""Deserialize from dict."""
|
||||||
|
return cls(
|
||||||
|
transaction_id=data["transaction_id"],
|
||||||
|
transaction_type=data["transaction_type"],
|
||||||
|
character_id=data["character_id"],
|
||||||
|
amount=data["amount"],
|
||||||
|
description=data["description"],
|
||||||
|
timestamp=datetime.fromisoformat(data["timestamp"]),
|
||||||
|
metadata=data.get("metadata", {})
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update `ShopService.purchase_item()` to log transaction:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In shop_service.py
|
||||||
|
|
||||||
|
def purchase_item(...):
|
||||||
|
# ... existing code ...
|
||||||
|
|
||||||
|
# Log transaction
|
||||||
|
from app.models.transaction import Transaction
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
transaction = Transaction(
|
||||||
|
transaction_id=str(uuid.uuid4()),
|
||||||
|
transaction_type="shop_purchase",
|
||||||
|
character_id=character.character_id,
|
||||||
|
amount=-price,
|
||||||
|
description=f"Purchased {quantity}x {item_id} from {shop_id}",
|
||||||
|
metadata={
|
||||||
|
"shop_id": shop_id,
|
||||||
|
"item_id": item_id,
|
||||||
|
"quantity": quantity,
|
||||||
|
"unit_price": item_data['price']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
from app.services.appwrite_service import get_appwrite_service
|
||||||
|
appwrite = get_appwrite_service()
|
||||||
|
appwrite.create_document("transactions", transaction.transaction_id, transaction.to_dict())
|
||||||
|
|
||||||
|
# ... rest of code ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- All purchases logged to database
|
||||||
|
- Transaction records complete
|
||||||
|
- Can query transaction history
|
||||||
|
|
||||||
|
---
|
||||||
481
docs/VECTOR_DATABASE_STRATEGY.md
Normal file
481
docs/VECTOR_DATABASE_STRATEGY.md
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
# Vector Database Strategy
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document outlines the strategy for implementing layered knowledge systems using vector databases to provide NPCs and the Dungeon Master with contextual lore, regional history, and world knowledge.
|
||||||
|
|
||||||
|
**Status:** Planning Phase
|
||||||
|
**Last Updated:** November 26, 2025
|
||||||
|
**Decision:** Use Weaviate for vector database implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Knowledge Hierarchy
|
||||||
|
|
||||||
|
### Three-Tier Vector Database Structure
|
||||||
|
|
||||||
|
1. **World Lore DB** (Global)
|
||||||
|
- Broad historical events, mythology, major kingdoms, legendary figures
|
||||||
|
- Accessible to all NPCs and DM for player questions
|
||||||
|
- Examples: "The Great War 200 years ago", "The origin of magic", "The Five Kingdoms"
|
||||||
|
- **Scope:** Universal knowledge any educated NPC might know
|
||||||
|
|
||||||
|
2. **Regional/Town Lore DB** (Location-specific)
|
||||||
|
- Local history, notable events, landmarks, politics, rumors
|
||||||
|
- Current town leadership, recent events, local legends
|
||||||
|
- Trade routes, neighboring settlements, regional conflicts
|
||||||
|
- **Scope:** Knowledge specific to geographic area
|
||||||
|
|
||||||
|
3. **NPC Persona** (Individual, YAML-defined)
|
||||||
|
- Personal background, personality, motivations
|
||||||
|
- Specific knowledge based on profession/role
|
||||||
|
- Personal relationships and secrets
|
||||||
|
- **Scope:** Character-specific information (already implemented in `/api/app/data/npcs/*.yaml`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Knowledge Layers Work Together
|
||||||
|
|
||||||
|
### Contextual Knowledge Layering
|
||||||
|
|
||||||
|
When an NPC engages in conversation, build their knowledge context by:
|
||||||
|
- **Always include**: NPC persona + their region's lore DB
|
||||||
|
- **Conditionally include**: World lore (if the topic seems historical/broad)
|
||||||
|
- **Use semantic search**: Query each DB for relevant chunks based on conversation topic
|
||||||
|
|
||||||
|
### Example Interaction Flow
|
||||||
|
|
||||||
|
**Player asks tavern keeper:** "Tell me about the old ruins north of town"
|
||||||
|
|
||||||
|
1. Check NPC persona: "Are ruins mentioned in their background?"
|
||||||
|
2. Query Regional DB: "old ruins + north + [town name]"
|
||||||
|
3. If no hits, query World Lore DB: "ancient ruins + [region name]"
|
||||||
|
4. Combine results with NPC personality filter
|
||||||
|
|
||||||
|
**Result:** NPC responds with appropriate lore, or authentically says "I don't know about that" if nothing is found.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Knowledge Boundaries & Authenticity
|
||||||
|
|
||||||
|
### NPCs Have Knowledge Limitations Based On:
|
||||||
|
|
||||||
|
- **Profession**: Blacksmith knows metallurgy lore, scholar knows history, farmer knows agricultural traditions
|
||||||
|
- **Social Status**: Nobles know court politics, commoners know street rumors
|
||||||
|
- **Age/Experience**: Elder NPCs might reference events from decades ago
|
||||||
|
- **Travel History**: Has this NPC been outside their region?
|
||||||
|
|
||||||
|
### Implementation of "I don't know"
|
||||||
|
|
||||||
|
Add metadata to vector DB entries:
|
||||||
|
- `required_profession: ["scholar", "priest"]`
|
||||||
|
- `social_class: ["noble", "merchant"]`
|
||||||
|
- `knowledge_type: "academic" | "common" | "secret"`
|
||||||
|
- `region_id: "thornhelm"`
|
||||||
|
- `time_period: "ancient" | "recent" | "current"`
|
||||||
|
|
||||||
|
Filter results before passing to the NPC's AI context, allowing authentic "I haven't heard of that" responses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retrieval-Augmented Generation (RAG) Pattern
|
||||||
|
|
||||||
|
### Building AI Prompts for NPC Dialogue
|
||||||
|
|
||||||
|
```
|
||||||
|
[NPC Persona from YAML]
|
||||||
|
+
|
||||||
|
[Top 3-5 relevant chunks from Regional DB based on conversation topic]
|
||||||
|
+
|
||||||
|
[Top 2-3 relevant chunks from World Lore if topic is broad/historical]
|
||||||
|
+
|
||||||
|
[Conversation history from character's npc_interactions]
|
||||||
|
→ Feed to Claude with instruction to stay in character and admit ignorance if uncertain
|
||||||
|
```
|
||||||
|
|
||||||
|
### DM Knowledge vs NPC Knowledge
|
||||||
|
|
||||||
|
**DM Mode** (Player talks directly to DM, not through NPC):
|
||||||
|
- DM has access to ALL databases without restrictions
|
||||||
|
- DM can reveal as much or as little as narratively appropriate
|
||||||
|
- DM can generate content not in databases (creative liberty)
|
||||||
|
|
||||||
|
**NPC Mode** (Player talks to specific NPC):
|
||||||
|
- NPC knowledge filtered by persona/role/location
|
||||||
|
- NPC can redirect: "You should ask the town elder about that" or "I've heard scholars at the university know more"
|
||||||
|
- Creates natural quest hooks and information-gathering gameplay
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Implementation
|
||||||
|
|
||||||
|
### Technology Choice: Weaviate
|
||||||
|
|
||||||
|
**Reasons for Weaviate:**
|
||||||
|
- Self-hosted option for dev/beta
|
||||||
|
- Managed cloud service (Weaviate Cloud Services) for production
|
||||||
|
- **Same API** for both self-hosted and managed (easy migration)
|
||||||
|
- Rich metadata filtering capabilities
|
||||||
|
- Multi-tenancy support
|
||||||
|
- GraphQL API (fits strong typing preference)
|
||||||
|
- Hybrid search (semantic + keyword)
|
||||||
|
|
||||||
|
### Storage & Indexing Strategy
|
||||||
|
|
||||||
|
**Where Each DB Lives:**
|
||||||
|
|
||||||
|
- **World Lore**: Single global vector DB collection
|
||||||
|
- **Regional DBs**: One collection with region metadata filtering
|
||||||
|
- Could use Weaviate multi-tenancy for efficient isolation
|
||||||
|
- Lazy-load when character enters region
|
||||||
|
- Cache in Redis for active sessions
|
||||||
|
- **NPC Personas**: Remain in YAML (structured data, not semantic search needed)
|
||||||
|
|
||||||
|
**Weaviate Collections Structure:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Collections:
|
||||||
|
- WorldLore
|
||||||
|
- Metadata: knowledge_type, time_period, required_profession
|
||||||
|
- RegionalLore
|
||||||
|
- Metadata: region_id, knowledge_type, social_class
|
||||||
|
- Rumors (optional: dynamic/time-sensitive content)
|
||||||
|
- Metadata: region_id, expiration_date, source_npc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Semantic Chunk Strategy
|
||||||
|
|
||||||
|
Chunk lore content by logical units:
|
||||||
|
- **Events**: "The Battle of Thornhelm (Year 1204) - A decisive victory..."
|
||||||
|
- **Locations**: "The Abandoned Lighthouse - Once a beacon for traders..."
|
||||||
|
- **Figures**: "Lord Varric the Stern - Current ruler of Thornhelm..."
|
||||||
|
- **Rumors/Gossip**: "Strange lights have been seen in the forest lately..."
|
||||||
|
|
||||||
|
Each chunk gets embedded and stored with rich metadata for filtering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Index-Once Strategy
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Lore is relatively static (updates only during major version releases)
|
||||||
|
- Read-heavy workload (perfect for vector DBs)
|
||||||
|
- Cost-effective (one-time embedding generation)
|
||||||
|
- Allows thorough testing before deployment
|
||||||
|
|
||||||
|
### Workflow Phases
|
||||||
|
|
||||||
|
**Development:**
|
||||||
|
1. Write lore content (YAML/JSON/Markdown)
|
||||||
|
2. Run embedding script locally
|
||||||
|
3. Upload to local Weaviate instance (Docker)
|
||||||
|
4. Test NPC conversations
|
||||||
|
5. Iterate on lore content
|
||||||
|
|
||||||
|
**Beta/Staging:**
|
||||||
|
1. Same self-hosted Weaviate, separate instance
|
||||||
|
2. Finalize lore content
|
||||||
|
3. Generate production embeddings
|
||||||
|
4. Performance testing
|
||||||
|
|
||||||
|
**Production:**
|
||||||
|
1. Migrate to Weaviate Cloud Services
|
||||||
|
2. Upload final embedded lore
|
||||||
|
3. Players query read-only
|
||||||
|
4. No changes until next major update
|
||||||
|
|
||||||
|
### Self-Hosted Development Setup
|
||||||
|
|
||||||
|
**Docker Compose Example:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
weaviate:
|
||||||
|
image: semitechnologies/weaviate:latest
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'true' # Dev only
|
||||||
|
PERSISTENCE_DATA_PATH: '/var/lib/weaviate'
|
||||||
|
volumes:
|
||||||
|
- weaviate_data:/var/lib/weaviate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hardware Requirements (Self-Hosted):**
|
||||||
|
- RAM: 4-8GB sufficient for beta
|
||||||
|
- CPU: Low (no heavy re-indexing)
|
||||||
|
- Storage: Minimal (vectors are compact)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Path: Dev → Production
|
||||||
|
|
||||||
|
### Zero-Code Migration
|
||||||
|
|
||||||
|
1. Export data from self-hosted Weaviate (backup tools)
|
||||||
|
2. Create Weaviate Cloud Services cluster
|
||||||
|
3. Import data to WCS
|
||||||
|
4. Change environment variable: `WEAVIATE_URL`
|
||||||
|
5. Deploy code (no code changes required)
|
||||||
|
|
||||||
|
**Environment Configuration:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# /api/config/development.yaml
|
||||||
|
weaviate:
|
||||||
|
url: "http://localhost:8080"
|
||||||
|
api_key: null
|
||||||
|
|
||||||
|
# /api/config/production.yaml
|
||||||
|
weaviate:
|
||||||
|
url: "https://your-cluster.weaviate.network"
|
||||||
|
api_key: "${WEAVIATE_API_KEY}" # From .env
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Embedding Strategy
|
||||||
|
|
||||||
|
### One-Time Embedding Generation
|
||||||
|
|
||||||
|
Since embeddings are generated once per release, prioritize **quality over cost**.
|
||||||
|
|
||||||
|
**Embedding Model Options:**
|
||||||
|
|
||||||
|
| Model | Pros | Cons | Recommendation |
|
||||||
|
|-------|------|------|----------------|
|
||||||
|
| OpenAI `text-embedding-3-large` | High quality, good semantic understanding | Paid per use | **Production** |
|
||||||
|
| Cohere Embed v3 | Optimized for search, multilingual | Paid per use | **Production Alternative** |
|
||||||
|
| sentence-transformers (OSS) | Free, self-host, fast iteration | Lower quality | **Development/Testing** |
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- **Development:** Use open-source models (iterate faster, zero cost)
|
||||||
|
- **Production:** Use OpenAI or Replicate https://replicate.com/beautyyuyanli/multilingual-e5-large (quality matters for player experience)
|
||||||
|
|
||||||
|
### Embedding Generation Script
|
||||||
|
|
||||||
|
Will be implemented in `/api/scripts/generate_lore_embeddings.py`:
|
||||||
|
1. Read lore files (YAML/JSON/Markdown)
|
||||||
|
2. Chunk content appropriately
|
||||||
|
3. Generate embeddings using chosen model
|
||||||
|
4. Upload to Weaviate with metadata
|
||||||
|
5. Validate retrieval quality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Content Management
|
||||||
|
|
||||||
|
### Lore Content Structure
|
||||||
|
|
||||||
|
**Storage Location:** `/api/app/data/lore/`
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/app/data/lore/
|
||||||
|
world/
|
||||||
|
history.yaml
|
||||||
|
mythology.yaml
|
||||||
|
kingdoms.yaml
|
||||||
|
regions/
|
||||||
|
thornhelm/
|
||||||
|
history.yaml
|
||||||
|
locations.yaml
|
||||||
|
rumors.yaml
|
||||||
|
silverwood/
|
||||||
|
history.yaml
|
||||||
|
locations.yaml
|
||||||
|
rumors.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Lore Entry (YAML):**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "thornhelm_founding"
|
||||||
|
title: "The Founding of Thornhelm"
|
||||||
|
content: |
|
||||||
|
Thornhelm was founded in the year 847 by Lord Theron the Bold,
|
||||||
|
a retired general seeking to establish a frontier town...
|
||||||
|
metadata:
|
||||||
|
region_id: "thornhelm"
|
||||||
|
knowledge_type: "common"
|
||||||
|
time_period: "historical"
|
||||||
|
required_profession: null # Anyone can know this
|
||||||
|
social_class: null # All classes
|
||||||
|
tags:
|
||||||
|
- "founding"
|
||||||
|
- "lord-theron"
|
||||||
|
- "history"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version Control for Lore Updates
|
||||||
|
|
||||||
|
**Complete Re-Index Strategy** (Simplest, recommended):
|
||||||
|
1. Delete old collections during maintenance window
|
||||||
|
2. Upload new lore with embeddings
|
||||||
|
3. Atomic cutover
|
||||||
|
4. Works great for infrequent major updates
|
||||||
|
|
||||||
|
**Alternative: Versioned Collections** (Overkill for our use case):
|
||||||
|
- `WorldLore_v1`, `WorldLore_v2`
|
||||||
|
- More overhead, probably unnecessary
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance & Cost Optimization
|
||||||
|
|
||||||
|
### Cost Considerations
|
||||||
|
|
||||||
|
**Embedding Generation:**
|
||||||
|
- One-time cost per lore chunk
|
||||||
|
- Only re-generate during major updates
|
||||||
|
- Estimated cost: $X per 1000 chunks (TBD based on model choice)
|
||||||
|
|
||||||
|
**Vector Search:**
|
||||||
|
- No embedding cost for queries (just retrieval)
|
||||||
|
- Self-hosted: Infrastructure cost only
|
||||||
|
- Managed (WCS): Pay for storage + queries
|
||||||
|
|
||||||
|
**Optimization Strategies:**
|
||||||
|
- Pre-compute all embeddings at build time
|
||||||
|
- Cache frequently accessed regional DBs in Redis
|
||||||
|
- Only search World Lore DB if regional search returns no results (fallback pattern)
|
||||||
|
- Use cheaper embedding models for non-critical content
|
||||||
|
|
||||||
|
### Retrieval Performance
|
||||||
|
|
||||||
|
**Expected Query Times:**
|
||||||
|
- Semantic search: < 100ms
|
||||||
|
- With metadata filtering: < 150ms
|
||||||
|
- Hybrid search: < 200ms
|
||||||
|
|
||||||
|
**Caching Strategy:**
|
||||||
|
- Cache top N regional lore chunks per active region in Redis
|
||||||
|
- TTL: 1 hour (or until session ends)
|
||||||
|
- Invalidate on major lore updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multiplayer Considerations
|
||||||
|
|
||||||
|
### Shared World State
|
||||||
|
|
||||||
|
If multiple characters are in the same town talking to NPCs:
|
||||||
|
- **Regional DB**: Shared (same lore for everyone)
|
||||||
|
- **World DB**: Shared
|
||||||
|
- **NPC Interactions**: Character-specific (stored in `character.npc_interactions`)
|
||||||
|
|
||||||
|
**Result:** NPCs can reference world events consistently across players while maintaining individual relationships.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Validation Steps
|
||||||
|
|
||||||
|
1. **Retrieval Quality Testing**
|
||||||
|
- Does semantic search return relevant lore?
|
||||||
|
- Are metadata filters working correctly?
|
||||||
|
- Do NPCs find appropriate information?
|
||||||
|
|
||||||
|
2. **NPC Knowledge Boundaries**
|
||||||
|
- Can a farmer access academic knowledge? (Should be filtered out)
|
||||||
|
- Do profession filters work as expected?
|
||||||
|
- Do NPCs authentically say "I don't know" when appropriate?
|
||||||
|
|
||||||
|
3. **Performance Testing**
|
||||||
|
- Query response times under load
|
||||||
|
- Cache hit rates
|
||||||
|
- Memory usage with multiple active regions
|
||||||
|
|
||||||
|
4. **Content Quality**
|
||||||
|
- Is lore consistent across databases?
|
||||||
|
- Are there contradictions between world/regional lore?
|
||||||
|
- Is chunk size appropriate for context?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Proof of Concept (Current)
|
||||||
|
- [ ] Set up local Weaviate with Docker
|
||||||
|
- [ ] Create sample lore chunks (20-30 entries for one town)
|
||||||
|
- [ ] Generate embeddings and upload to Weaviate
|
||||||
|
- [ ] Build simple API endpoint for querying Weaviate
|
||||||
|
- [ ] Test NPC conversation with lore augmentation
|
||||||
|
|
||||||
|
### Phase 2: Core Implementation
|
||||||
|
- [ ] Define lore content structure (YAML schema)
|
||||||
|
- [ ] Write lore for starter region
|
||||||
|
- [ ] Implement embedding generation script
|
||||||
|
- [ ] Create Weaviate service layer in `/api/app/services/weaviate_service.py`
|
||||||
|
- [ ] Integrate with NPC conversation system
|
||||||
|
- [ ] Add DM lore query endpoints
|
||||||
|
|
||||||
|
### Phase 3: Content Expansion
|
||||||
|
- [ ] Write world lore content
|
||||||
|
- [ ] Write lore for additional regions
|
||||||
|
- [ ] Implement knowledge filtering logic
|
||||||
|
- [ ] Add lore discovery system (optional: player codex)
|
||||||
|
|
||||||
|
### Phase 4: Production Readiness
|
||||||
|
- [ ] Migrate to Weaviate Cloud Services
|
||||||
|
- [ ] Performance optimization and caching
|
||||||
|
- [ ] Backup and disaster recovery
|
||||||
|
- [ ] Monitoring and alerting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Authoring Tools**: How will we create/maintain lore content efficiently?
|
||||||
|
- Manual YAML editing?
|
||||||
|
- AI-generated lore with human review?
|
||||||
|
- Web-based CMS?
|
||||||
|
|
||||||
|
2. **Lore Discovery**: Should players unlock lore entries (codex-style) as they learn about them?
|
||||||
|
- Could be fun for completionists
|
||||||
|
- Adds gameplay loop around exploration
|
||||||
|
|
||||||
|
3. **Dynamic Lore**: How to handle time-sensitive rumors or evolving world state?
|
||||||
|
- Separate "Rumors" collection with expiration dates?
|
||||||
|
- Regional events that trigger new lore entries?
|
||||||
|
|
||||||
|
4. **Chunk Size**: What's optimal for context vs. precision?
|
||||||
|
- Too small: NPCs miss broader context
|
||||||
|
- Too large: Less precise retrieval
|
||||||
|
- Needs testing to determine
|
||||||
|
|
||||||
|
5. **Consistency Validation**: How to ensure regional lore doesn't contradict world lore?
|
||||||
|
- Automated consistency checks?
|
||||||
|
- Manual review process?
|
||||||
|
- Lore versioning and dependency tracking?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- **Player-Generated Lore**: Allow DMs to add custom lore entries during sessions
|
||||||
|
- **Lore Relationships**: Graph connections between related lore entries
|
||||||
|
- **Multilingual Support**: Embed lore in multiple languages
|
||||||
|
- **Seasonal/Event Lore**: Time-based lore that appears during special events
|
||||||
|
- **Quest Integration**: Automatic lore unlock based on quest completion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- **Weaviate Documentation**: https://weaviate.io/developers/weaviate
|
||||||
|
- **RAG Pattern Best Practices**: (TBD)
|
||||||
|
- **Embedding Model Comparisons**: (TBD)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
This strategy aligns with the project's core principles:
|
||||||
|
- **Strong typing**: Lore models will use dataclasses
|
||||||
|
- **Configuration-driven**: Lore content in YAML/JSON
|
||||||
|
- **Microservices architecture**: Weaviate is independent service
|
||||||
|
- **Cost-conscious**: Index-once strategy minimizes ongoing costs
|
||||||
|
- **Future-proof**: Easy migration from self-hosted to managed
|
||||||
@@ -56,11 +56,13 @@ def create_app():
|
|||||||
# Register blueprints
|
# Register blueprints
|
||||||
from .views.auth_views import auth_bp
|
from .views.auth_views import auth_bp
|
||||||
from .views.character_views import character_bp
|
from .views.character_views import character_bp
|
||||||
|
from .views.combat_views import combat_bp
|
||||||
from .views.game_views import game_bp
|
from .views.game_views import game_bp
|
||||||
from .views.pages import pages_bp
|
from .views.pages import pages_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(character_bp)
|
app.register_blueprint(character_bp)
|
||||||
|
app.register_blueprint(combat_bp)
|
||||||
app.register_blueprint(game_bp)
|
app.register_blueprint(game_bp)
|
||||||
app.register_blueprint(pages_bp)
|
app.register_blueprint(pages_bp)
|
||||||
|
|
||||||
@@ -109,6 +111,6 @@ def create_app():
|
|||||||
logger.error("internal_server_error", error=str(error))
|
logger.error("internal_server_error", error=str(error))
|
||||||
return render_template('errors/500.html'), 500
|
return render_template('errors/500.html'), 500
|
||||||
|
|
||||||
logger.info("flask_app_created", blueprints=["auth", "character", "game", "pages"])
|
logger.info("flask_app_created", blueprints=["auth", "character", "combat", "game", "pages"])
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
574
public_web/app/views/combat_views.py
Normal file
574
public_web/app/views/combat_views.py
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
"""
|
||||||
|
Combat Views
|
||||||
|
|
||||||
|
Routes for combat UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, make_response
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from ..utils.api_client import get_api_client, APIError, APINotFoundError
|
||||||
|
from ..utils.auth import require_auth_web as require_auth
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
combat_bp = Blueprint('combat', __name__, url_prefix='/combat')
|
||||||
|
|
||||||
|
|
||||||
|
@combat_bp.route('/<session_id>')
|
||||||
|
@require_auth
|
||||||
|
def combat_view(session_id: str):
|
||||||
|
"""
|
||||||
|
Render the combat page for an active encounter.
|
||||||
|
|
||||||
|
Displays the 3-column combat interface with:
|
||||||
|
- Left: Combatants (player + enemies) with HP/MP bars
|
||||||
|
- Center: Combat log + action buttons
|
||||||
|
- Right: Turn order + active effects
|
||||||
|
"""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get combat state from API
|
||||||
|
response = client.get(f'/api/v1/combat/{session_id}/state')
|
||||||
|
result = response.get('result', {})
|
||||||
|
|
||||||
|
# Check if combat is still active
|
||||||
|
if not result.get('in_combat'):
|
||||||
|
# Combat ended - redirect to game play
|
||||||
|
return redirect(url_for('game.play_session', session_id=session_id))
|
||||||
|
|
||||||
|
encounter = result.get('encounter') or {}
|
||||||
|
combat_log = result.get('combat_log', [])
|
||||||
|
|
||||||
|
# Get current turn combatant ID directly from API response
|
||||||
|
current_turn_id = encounter.get('current_turn')
|
||||||
|
|
||||||
|
# Find if it's the player's turn
|
||||||
|
is_player_turn = False
|
||||||
|
player_combatant = None
|
||||||
|
for combatant in encounter.get('combatants', []):
|
||||||
|
if combatant.get('is_player'):
|
||||||
|
player_combatant = combatant
|
||||||
|
if combatant.get('combatant_id') == current_turn_id:
|
||||||
|
is_player_turn = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Format combat log entries for display
|
||||||
|
formatted_log = []
|
||||||
|
for entry in combat_log:
|
||||||
|
log_entry = {
|
||||||
|
'actor': entry.get('combatant_name', entry.get('actor', '')),
|
||||||
|
'message': entry.get('message', ''),
|
||||||
|
'damage': entry.get('damage'),
|
||||||
|
'heal': entry.get('healing'),
|
||||||
|
'is_crit': entry.get('is_critical', False),
|
||||||
|
'type': 'player' if entry.get('is_player', False) else 'enemy'
|
||||||
|
}
|
||||||
|
# Detect system messages
|
||||||
|
if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'):
|
||||||
|
log_entry['type'] = 'system'
|
||||||
|
formatted_log.append(log_entry)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'game/combat.html',
|
||||||
|
session_id=session_id,
|
||||||
|
encounter=encounter,
|
||||||
|
combat_log=formatted_log,
|
||||||
|
current_turn_id=current_turn_id,
|
||||||
|
is_player_turn=is_player_turn,
|
||||||
|
player_combatant=player_combatant
|
||||||
|
)
|
||||||
|
|
||||||
|
except APINotFoundError:
|
||||||
|
logger.warning("combat_not_found", session_id=session_id)
|
||||||
|
return render_template('errors/404.html', message="No active combat encounter"), 404
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_combat", session_id=session_id, error=str(e))
|
||||||
|
return render_template('errors/500.html', message=str(e)), 500
|
||||||
|
|
||||||
|
|
||||||
|
@combat_bp.route('/<session_id>/action', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def combat_action(session_id: str):
|
||||||
|
"""
|
||||||
|
Execute a combat action (attack, defend, ability, item).
|
||||||
|
|
||||||
|
Returns updated combat log entries.
|
||||||
|
"""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
action_type = request.form.get('action_type', 'attack')
|
||||||
|
ability_id = request.form.get('ability_id')
|
||||||
|
item_id = request.form.get('item_id')
|
||||||
|
target_id = request.form.get('target_id')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build action payload
|
||||||
|
payload = {
|
||||||
|
'action_type': action_type
|
||||||
|
}
|
||||||
|
|
||||||
|
if ability_id:
|
||||||
|
payload['ability_id'] = ability_id
|
||||||
|
if item_id:
|
||||||
|
payload['item_id'] = item_id
|
||||||
|
if target_id:
|
||||||
|
payload['target_id'] = target_id
|
||||||
|
|
||||||
|
# POST action to API
|
||||||
|
response = client.post(f'/api/v1/combat/{session_id}/action', payload)
|
||||||
|
result = response.get('result', {})
|
||||||
|
|
||||||
|
# Check if combat ended
|
||||||
|
combat_ended = result.get('combat_ended', False)
|
||||||
|
combat_status = result.get('combat_status')
|
||||||
|
|
||||||
|
if combat_ended:
|
||||||
|
# API returns lowercase status values: 'victory', 'defeat', 'fled'
|
||||||
|
status_lower = (combat_status or '').lower()
|
||||||
|
if status_lower == 'victory':
|
||||||
|
return render_template(
|
||||||
|
'game/partials/combat_victory.html',
|
||||||
|
session_id=session_id,
|
||||||
|
rewards=result.get('rewards', {})
|
||||||
|
)
|
||||||
|
elif status_lower == 'defeat':
|
||||||
|
return render_template(
|
||||||
|
'game/partials/combat_defeat.html',
|
||||||
|
session_id=session_id,
|
||||||
|
gold_lost=result.get('gold_lost', 0),
|
||||||
|
can_retry=result.get('can_retry', False)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format action result for log display
|
||||||
|
# API returns data directly in result, not nested under 'action_result'
|
||||||
|
log_entries = []
|
||||||
|
|
||||||
|
# Player action entry
|
||||||
|
player_entry = {
|
||||||
|
'actor': 'You',
|
||||||
|
'message': result.get('message', f'used {action_type}'),
|
||||||
|
'type': 'player',
|
||||||
|
'is_crit': False
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add damage info if present
|
||||||
|
damage_results = result.get('damage_results', [])
|
||||||
|
if damage_results:
|
||||||
|
for dmg in damage_results:
|
||||||
|
player_entry['damage'] = dmg.get('total_damage') or dmg.get('damage')
|
||||||
|
player_entry['is_crit'] = dmg.get('is_critical', False)
|
||||||
|
if player_entry['is_crit']:
|
||||||
|
player_entry['type'] = 'crit'
|
||||||
|
|
||||||
|
# Add healing info if present
|
||||||
|
if result.get('healing'):
|
||||||
|
player_entry['heal'] = result.get('healing')
|
||||||
|
player_entry['type'] = 'heal'
|
||||||
|
|
||||||
|
log_entries.append(player_entry)
|
||||||
|
|
||||||
|
# Add any effect entries
|
||||||
|
for effect in result.get('effects_applied', []):
|
||||||
|
# API may use "name" or "effect" key for the effect name
|
||||||
|
effect_name = effect.get('name') or effect.get('effect') or 'Unknown'
|
||||||
|
log_entries.append({
|
||||||
|
'actor': '',
|
||||||
|
'message': effect.get('message', f'Effect applied: {effect_name}'),
|
||||||
|
'type': 'system'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Return log entries HTML
|
||||||
|
resp = make_response(render_template(
|
||||||
|
'game/partials/combat_log.html',
|
||||||
|
combat_log=log_entries
|
||||||
|
))
|
||||||
|
|
||||||
|
# Trigger enemy turn if it's no longer player's turn
|
||||||
|
next_combatant = result.get('next_combatant_id')
|
||||||
|
if next_combatant and not result.get('next_is_player', True):
|
||||||
|
resp.headers['HX-Trigger'] = 'enemyTurn'
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("combat_action_failed", session_id=session_id, action_type=action_type, error=str(e))
|
||||||
|
return f'''
|
||||||
|
<div class="combat-log__entry combat-log__entry--system">
|
||||||
|
<span class="log-message">Action failed: {e}</span>
|
||||||
|
</div>
|
||||||
|
''', 500
|
||||||
|
|
||||||
|
|
||||||
|
@combat_bp.route('/<session_id>/abilities')
|
||||||
|
@require_auth
|
||||||
|
def combat_abilities(session_id: str):
|
||||||
|
"""Get abilities modal for combat."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get combat state to get player's abilities
|
||||||
|
response = client.get(f'/api/v1/combat/{session_id}/state')
|
||||||
|
result = response.get('result', {})
|
||||||
|
encounter = result.get('encounter', {})
|
||||||
|
|
||||||
|
# Find player combatant
|
||||||
|
player_combatant = None
|
||||||
|
for combatant in encounter.get('combatants', []):
|
||||||
|
if combatant.get('is_player'):
|
||||||
|
player_combatant = combatant
|
||||||
|
break
|
||||||
|
|
||||||
|
# Get abilities from player combatant or character
|
||||||
|
abilities = []
|
||||||
|
if player_combatant:
|
||||||
|
ability_ids = player_combatant.get('abilities', [])
|
||||||
|
current_mp = player_combatant.get('current_mp', 0)
|
||||||
|
cooldowns = player_combatant.get('cooldowns', {})
|
||||||
|
|
||||||
|
# Fetch ability details (if API has ability endpoint)
|
||||||
|
for ability_id in ability_ids:
|
||||||
|
try:
|
||||||
|
ability_response = client.get(f'/api/v1/abilities/{ability_id}')
|
||||||
|
ability_data = ability_response.get('result', {})
|
||||||
|
|
||||||
|
# Check availability
|
||||||
|
mp_cost = ability_data.get('mp_cost', 0)
|
||||||
|
cooldown = cooldowns.get(ability_id, 0)
|
||||||
|
available = current_mp >= mp_cost and cooldown == 0
|
||||||
|
|
||||||
|
abilities.append({
|
||||||
|
'id': ability_id,
|
||||||
|
'name': ability_data.get('name', ability_id),
|
||||||
|
'description': ability_data.get('description', ''),
|
||||||
|
'mp_cost': mp_cost,
|
||||||
|
'cooldown': cooldown,
|
||||||
|
'max_cooldown': ability_data.get('cooldown', 0),
|
||||||
|
'damage_type': ability_data.get('damage_type'),
|
||||||
|
'effect_type': ability_data.get('effect_type'),
|
||||||
|
'available': available
|
||||||
|
})
|
||||||
|
except (APINotFoundError, APIError):
|
||||||
|
# Ability not found, add basic entry
|
||||||
|
abilities.append({
|
||||||
|
'id': ability_id,
|
||||||
|
'name': ability_id.replace('_', ' ').title(),
|
||||||
|
'description': '',
|
||||||
|
'mp_cost': 0,
|
||||||
|
'cooldown': cooldowns.get(ability_id, 0),
|
||||||
|
'max_cooldown': 0,
|
||||||
|
'available': True
|
||||||
|
})
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'game/partials/ability_modal.html',
|
||||||
|
session_id=session_id,
|
||||||
|
abilities=abilities
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_abilities", session_id=session_id, error=str(e))
|
||||||
|
return f'''
|
||||||
|
<div class="modal-overlay" onclick="closeModal()">
|
||||||
|
<div class="modal-content modal-content--md">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Select Ability</h3>
|
||||||
|
<button class="modal-close" onclick="closeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="items-empty">Failed to load abilities: {e}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
@combat_bp.route('/<session_id>/items')
|
||||||
|
@require_auth
|
||||||
|
def combat_items(session_id: str):
|
||||||
|
"""
|
||||||
|
Get combat items bottom sheet (consumables only).
|
||||||
|
|
||||||
|
Returns a bottom sheet UI with only consumable items that can be used in combat.
|
||||||
|
"""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get session to find character
|
||||||
|
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||||
|
session_data = session_response.get('result', {})
|
||||||
|
character_id = session_data.get('character_id')
|
||||||
|
|
||||||
|
# Get character inventory - filter to consumables only
|
||||||
|
consumables = []
|
||||||
|
if character_id:
|
||||||
|
try:
|
||||||
|
inv_response = client.get(f'/api/v1/characters/{character_id}/inventory')
|
||||||
|
inv_data = inv_response.get('result', {})
|
||||||
|
inventory = inv_data.get('inventory', [])
|
||||||
|
|
||||||
|
# Filter to consumable items only
|
||||||
|
for item in inventory:
|
||||||
|
item_type = item.get('item_type', item.get('type', ''))
|
||||||
|
if item_type == 'consumable' or item.get('usable_in_combat', False):
|
||||||
|
consumables.append({
|
||||||
|
'item_id': item.get('item_id'),
|
||||||
|
'name': item.get('name', 'Unknown Item'),
|
||||||
|
'description': item.get('description', ''),
|
||||||
|
'effects_on_use': item.get('effects_on_use', []),
|
||||||
|
'rarity': item.get('rarity', 'common')
|
||||||
|
})
|
||||||
|
except (APINotFoundError, APIError) as e:
|
||||||
|
logger.warning("failed_to_load_combat_inventory", character_id=character_id, error=str(e))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'game/partials/combat_items_sheet.html',
|
||||||
|
session_id=session_id,
|
||||||
|
consumables=consumables,
|
||||||
|
has_consumables=len(consumables) > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_items", session_id=session_id, error=str(e))
|
||||||
|
return f'''
|
||||||
|
<div class="combat-items-sheet open">
|
||||||
|
<div class="sheet-handle"></div>
|
||||||
|
<div class="sheet-header">
|
||||||
|
<h3>Use Item</h3>
|
||||||
|
<button class="sheet-close" onclick="closeCombatSheet()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-body">
|
||||||
|
<div class="no-consumables">Failed to load items: {e}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-backdrop" onclick="closeCombatSheet()"></div>
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
@combat_bp.route('/<session_id>/items/<item_id>/detail')
|
||||||
|
@require_auth
|
||||||
|
def combat_item_detail(session_id: str, item_id: str):
|
||||||
|
"""Get item detail for combat bottom sheet."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get session to find character
|
||||||
|
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||||
|
session_data = session_response.get('result', {})
|
||||||
|
character_id = session_data.get('character_id')
|
||||||
|
|
||||||
|
item = None
|
||||||
|
if character_id:
|
||||||
|
# Get inventory and find the item
|
||||||
|
inv_response = client.get(f'/api/v1/characters/{character_id}/inventory')
|
||||||
|
inv_data = inv_response.get('result', {})
|
||||||
|
inventory = inv_data.get('inventory', [])
|
||||||
|
|
||||||
|
for inv_item in inventory:
|
||||||
|
if inv_item.get('item_id') == item_id:
|
||||||
|
item = inv_item
|
||||||
|
break
|
||||||
|
|
||||||
|
if not item:
|
||||||
|
return '<p>Item not found</p>', 404
|
||||||
|
|
||||||
|
# Format effect description
|
||||||
|
effect_desc = item.get('description', 'Use this item')
|
||||||
|
effects = item.get('effects_on_use', [])
|
||||||
|
if effects:
|
||||||
|
effect_parts = []
|
||||||
|
for effect in effects:
|
||||||
|
if effect.get('stat') == 'hp':
|
||||||
|
effect_parts.append(f"Restores {effect.get('value', 0)} HP")
|
||||||
|
elif effect.get('stat') == 'mp':
|
||||||
|
effect_parts.append(f"Restores {effect.get('value', 0)} MP")
|
||||||
|
elif effect.get('name'):
|
||||||
|
effect_parts.append(effect.get('name'))
|
||||||
|
if effect_parts:
|
||||||
|
effect_desc = ', '.join(effect_parts)
|
||||||
|
|
||||||
|
return f'''
|
||||||
|
<div class="detail-info">
|
||||||
|
<div class="detail-name">{item.get('name', 'Item')}</div>
|
||||||
|
<div class="detail-effect">{effect_desc}</div>
|
||||||
|
</div>
|
||||||
|
<button class="use-btn"
|
||||||
|
hx-post="{url_for('combat.combat_action', session_id=session_id)}"
|
||||||
|
hx-vals='{{"action_type": "item", "item_id": "{item_id}"}}'
|
||||||
|
hx-target="#combat-log"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
onclick="closeCombatSheet()">
|
||||||
|
Use
|
||||||
|
</button>
|
||||||
|
'''
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_combat_item_detail", session_id=session_id, item_id=item_id, error=str(e))
|
||||||
|
return f'<p>Failed to load item: {e}</p>', 500
|
||||||
|
|
||||||
|
|
||||||
|
@combat_bp.route('/<session_id>/flee', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def combat_flee(session_id: str):
|
||||||
|
"""Attempt to flee from combat."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.post(f'/api/v1/combat/{session_id}/flee', {})
|
||||||
|
result = response.get('result', {})
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
# Flee successful - use HX-Redirect for HTMX
|
||||||
|
resp = make_response(f'''
|
||||||
|
<div class="combat-log__entry combat-log__entry--system">
|
||||||
|
<span class="log-message">{result.get('message', 'You fled from combat!')}</span>
|
||||||
|
</div>
|
||||||
|
''')
|
||||||
|
resp.headers['HX-Redirect'] = url_for('game.play_session', session_id=session_id)
|
||||||
|
return resp
|
||||||
|
else:
|
||||||
|
# Flee failed - return log entry, trigger enemy turn
|
||||||
|
resp = make_response(f'''
|
||||||
|
<div class="combat-log__entry combat-log__entry--system">
|
||||||
|
<span class="log-message">{result.get('message', 'Failed to flee!')}</span>
|
||||||
|
</div>
|
||||||
|
''')
|
||||||
|
# Failed flee consumes turn, so trigger enemy turn if needed
|
||||||
|
if not result.get('next_is_player', True):
|
||||||
|
resp.headers['HX-Trigger'] = 'enemyTurn'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("flee_failed", session_id=session_id, error=str(e))
|
||||||
|
return f'''
|
||||||
|
<div class="combat-log__entry combat-log__entry--system">
|
||||||
|
<span class="log-message">Flee failed: {e}</span>
|
||||||
|
</div>
|
||||||
|
''', 500
|
||||||
|
|
||||||
|
|
||||||
|
@combat_bp.route('/<session_id>/enemy-turn', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def combat_enemy_turn(session_id: str):
|
||||||
|
"""Execute enemy turn and return result."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.post(f'/api/v1/combat/{session_id}/enemy-turn', {})
|
||||||
|
result = response.get('result', {})
|
||||||
|
|
||||||
|
# Check if combat ended
|
||||||
|
combat_ended = result.get('combat_ended', False)
|
||||||
|
combat_status = result.get('combat_status')
|
||||||
|
|
||||||
|
if combat_ended:
|
||||||
|
# API returns lowercase status values: 'victory', 'defeat', 'fled'
|
||||||
|
status_lower = (combat_status or '').lower()
|
||||||
|
if status_lower == 'victory':
|
||||||
|
return render_template(
|
||||||
|
'game/partials/combat_victory.html',
|
||||||
|
session_id=session_id,
|
||||||
|
rewards=result.get('rewards', {})
|
||||||
|
)
|
||||||
|
elif status_lower == 'defeat':
|
||||||
|
return render_template(
|
||||||
|
'game/partials/combat_defeat.html',
|
||||||
|
session_id=session_id,
|
||||||
|
gold_lost=result.get('gold_lost', 0),
|
||||||
|
can_retry=result.get('can_retry', False)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format enemy action for log
|
||||||
|
# API returns ActionResult directly in result, not nested under action_result
|
||||||
|
log_entries = [{
|
||||||
|
'actor': 'Enemy',
|
||||||
|
'message': result.get('message', 'attacks'),
|
||||||
|
'type': 'enemy',
|
||||||
|
'is_crit': False
|
||||||
|
}]
|
||||||
|
|
||||||
|
# Add damage info - API returns total_damage, not damage
|
||||||
|
damage_results = result.get('damage_results', [])
|
||||||
|
if damage_results:
|
||||||
|
log_entries[0]['damage'] = damage_results[0].get('total_damage')
|
||||||
|
log_entries[0]['is_crit'] = damage_results[0].get('is_critical', False)
|
||||||
|
|
||||||
|
# Check if it's still enemy turn (multiple enemies)
|
||||||
|
resp = make_response(render_template(
|
||||||
|
'game/partials/combat_log.html',
|
||||||
|
combat_log=log_entries
|
||||||
|
))
|
||||||
|
|
||||||
|
# If next combatant is also an enemy, trigger another enemy turn
|
||||||
|
if result.get('next_combatant_id') and not result.get('next_is_player', True):
|
||||||
|
resp.headers['HX-Trigger'] = 'enemyTurn'
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("enemy_turn_failed", session_id=session_id, error=str(e))
|
||||||
|
return f'''
|
||||||
|
<div class="combat-log__entry combat-log__entry--system">
|
||||||
|
<span class="log-message">Enemy turn error: {e}</span>
|
||||||
|
</div>
|
||||||
|
''', 500
|
||||||
|
|
||||||
|
|
||||||
|
@combat_bp.route('/<session_id>/log')
|
||||||
|
@require_auth
|
||||||
|
def combat_log(session_id: str):
|
||||||
|
"""Get current combat log."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.get(f'/api/v1/combat/{session_id}/state')
|
||||||
|
result = response.get('result', {})
|
||||||
|
combat_log_data = result.get('combat_log', [])
|
||||||
|
|
||||||
|
# Format log entries
|
||||||
|
formatted_log = []
|
||||||
|
for entry in combat_log_data:
|
||||||
|
log_entry = {
|
||||||
|
'actor': entry.get('combatant_name', entry.get('actor', '')),
|
||||||
|
'message': entry.get('message', ''),
|
||||||
|
'damage': entry.get('damage'),
|
||||||
|
'heal': entry.get('healing'),
|
||||||
|
'is_crit': entry.get('is_critical', False),
|
||||||
|
'type': 'player' if entry.get('is_player', False) else 'enemy'
|
||||||
|
}
|
||||||
|
if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'):
|
||||||
|
log_entry['type'] = 'system'
|
||||||
|
formatted_log.append(log_entry)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'game/partials/combat_log.html',
|
||||||
|
combat_log=formatted_log
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_combat_log", session_id=session_id, error=str(e))
|
||||||
|
return '<div class="combat-log__empty">Failed to load combat log</div>', 500
|
||||||
|
|
||||||
|
|
||||||
|
@combat_bp.route('/<session_id>/results')
|
||||||
|
@require_auth
|
||||||
|
def combat_results(session_id: str):
|
||||||
|
"""Display combat results (victory/defeat)."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.get(f'/api/v1/combat/{session_id}/results')
|
||||||
|
results = response.get('result', {})
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'game/combat_results.html',
|
||||||
|
victory=results['victory'],
|
||||||
|
xp_gained=results['xp_gained'],
|
||||||
|
gold_gained=results['gold_gained'],
|
||||||
|
loot=results['loot']
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_combat_results", session_id=session_id, error=str(e))
|
||||||
|
return redirect(url_for('game.play_session', session_id=session_id))
|
||||||
@@ -380,3 +380,652 @@ def do_travel(session_id: str):
|
|||||||
except APIError as e:
|
except APIError as e:
|
||||||
logger.error("failed_to_travel", session_id=session_id, location_id=location_id, error=str(e))
|
logger.error("failed_to_travel", session_id=session_id, location_id=location_id, error=str(e))
|
||||||
return f'<div class="error">Travel failed: {e}</div>', 500
|
return f'<div class="error">Travel failed: {e}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Combat Test Endpoints =====
|
||||||
|
|
||||||
|
@dev_bp.route('/combat')
|
||||||
|
@require_auth
|
||||||
|
def combat_hub():
|
||||||
|
"""Combat testing hub - select character and enemies to start combat."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get user's characters
|
||||||
|
characters_response = client.get('/api/v1/characters')
|
||||||
|
result = characters_response.get('result', {})
|
||||||
|
characters = result.get('characters', [])
|
||||||
|
|
||||||
|
# Get available enemy templates
|
||||||
|
enemies = []
|
||||||
|
try:
|
||||||
|
enemies_response = client.get('/api/v1/combat/enemies')
|
||||||
|
enemies = enemies_response.get('result', {}).get('enemies', [])
|
||||||
|
except (APINotFoundError, APIError):
|
||||||
|
# Enemies endpoint may not exist yet
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get all sessions to map characters to their sessions
|
||||||
|
sessions_in_combat = []
|
||||||
|
character_session_map = {} # character_id -> session_id
|
||||||
|
try:
|
||||||
|
sessions_response = client.get('/api/v1/sessions')
|
||||||
|
all_sessions = sessions_response.get('result', [])
|
||||||
|
for session in all_sessions:
|
||||||
|
# Map character to session (for dropdown)
|
||||||
|
char_id = session.get('character_id')
|
||||||
|
if char_id:
|
||||||
|
character_session_map[char_id] = session.get('session_id')
|
||||||
|
|
||||||
|
# Track sessions in combat (for resume list)
|
||||||
|
if session.get('in_combat') or session.get('game_state', {}).get('in_combat'):
|
||||||
|
sessions_in_combat.append(session)
|
||||||
|
except (APINotFoundError, APIError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Add session_id to each character for the template
|
||||||
|
for char in characters:
|
||||||
|
char['session_id'] = character_session_map.get(char.get('character_id'))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'dev/combat.html',
|
||||||
|
characters=characters,
|
||||||
|
enemies=enemies,
|
||||||
|
sessions_in_combat=sessions_in_combat
|
||||||
|
)
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_combat_hub", error=str(e))
|
||||||
|
return render_template('dev/combat.html', characters=[], enemies=[], sessions_in_combat=[], error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@dev_bp.route('/combat/start', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def start_combat():
|
||||||
|
"""Start a new combat encounter - returns redirect to combat session."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
session_id = request.form.get('session_id')
|
||||||
|
enemy_ids = request.form.getlist('enemy_ids')
|
||||||
|
|
||||||
|
logger.info("start_combat called",
|
||||||
|
session_id=session_id,
|
||||||
|
enemy_ids=enemy_ids,
|
||||||
|
form_data=dict(request.form))
|
||||||
|
|
||||||
|
if not session_id:
|
||||||
|
return '<div class="error">No session selected</div>', 400
|
||||||
|
|
||||||
|
if not enemy_ids:
|
||||||
|
return '<div class="error">No enemies selected</div>', 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.post('/api/v1/combat/start', {
|
||||||
|
'session_id': session_id,
|
||||||
|
'enemy_ids': enemy_ids
|
||||||
|
})
|
||||||
|
result = response.get('result', {})
|
||||||
|
|
||||||
|
# Return redirect script to combat session page
|
||||||
|
return f'''
|
||||||
|
<script>window.location.href = '/dev/combat/session/{session_id}';</script>
|
||||||
|
<div class="success">Combat started! Redirecting...</div>
|
||||||
|
'''
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_start_combat", session_id=session_id, error=str(e))
|
||||||
|
return f'<div class="error">Failed to start combat: {e}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
|
@dev_bp.route('/combat/session/<session_id>')
|
||||||
|
@require_auth
|
||||||
|
def combat_session(session_id: str):
|
||||||
|
"""Combat session debug interface - full 3-column layout."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get combat state from API
|
||||||
|
response = client.get(f'/api/v1/combat/{session_id}/state')
|
||||||
|
result = response.get('result', {})
|
||||||
|
|
||||||
|
# Check if combat is still active
|
||||||
|
if not result.get('in_combat'):
|
||||||
|
# Combat ended - redirect to combat index
|
||||||
|
return render_template('dev/combat.html',
|
||||||
|
message="Combat has ended. Start a new combat to continue.")
|
||||||
|
|
||||||
|
encounter = result.get('encounter') or {}
|
||||||
|
combat_log = result.get('combat_log', [])
|
||||||
|
|
||||||
|
# Get current turn combatant ID directly from API response
|
||||||
|
current_turn_id = encounter.get('current_turn')
|
||||||
|
turn_order = encounter.get('turn_order', [])
|
||||||
|
|
||||||
|
# Find player and determine if it's player's turn
|
||||||
|
is_player_turn = False
|
||||||
|
player_combatant = None
|
||||||
|
enemy_combatants = []
|
||||||
|
for combatant in encounter.get('combatants', []):
|
||||||
|
if combatant.get('is_player'):
|
||||||
|
player_combatant = combatant
|
||||||
|
if combatant.get('combatant_id') == current_turn_id:
|
||||||
|
is_player_turn = True
|
||||||
|
else:
|
||||||
|
enemy_combatants.append(combatant)
|
||||||
|
|
||||||
|
# Format combat log entries for display
|
||||||
|
formatted_log = []
|
||||||
|
for entry in combat_log:
|
||||||
|
log_entry = {
|
||||||
|
'actor': entry.get('combatant_name', entry.get('actor', '')),
|
||||||
|
'message': entry.get('message', ''),
|
||||||
|
'damage': entry.get('damage'),
|
||||||
|
'heal': entry.get('healing'),
|
||||||
|
'is_crit': entry.get('is_critical', False),
|
||||||
|
'type': 'player' if entry.get('is_player', False) else 'enemy'
|
||||||
|
}
|
||||||
|
# Detect system messages
|
||||||
|
if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'):
|
||||||
|
log_entry['type'] = 'system'
|
||||||
|
formatted_log.append(log_entry)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'dev/combat_session.html',
|
||||||
|
session_id=session_id,
|
||||||
|
encounter=encounter,
|
||||||
|
combat_log=formatted_log,
|
||||||
|
current_turn_id=current_turn_id,
|
||||||
|
is_player_turn=is_player_turn,
|
||||||
|
player_combatant=player_combatant,
|
||||||
|
enemy_combatants=enemy_combatants,
|
||||||
|
turn_order=turn_order,
|
||||||
|
raw_state=result
|
||||||
|
)
|
||||||
|
|
||||||
|
except APINotFoundError:
|
||||||
|
logger.warning("combat_not_found", session_id=session_id)
|
||||||
|
return render_template('dev/combat.html', error=f"No active combat for session {session_id}"), 404
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_combat_session", session_id=session_id, error=str(e))
|
||||||
|
return render_template('dev/combat.html', error=str(e)), 500
|
||||||
|
|
||||||
|
|
||||||
|
@dev_bp.route('/combat/<session_id>/state')
|
||||||
|
@require_auth
|
||||||
|
def combat_state(session_id: str):
|
||||||
|
"""Get combat state partial - returns refreshable state panel."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.get(f'/api/v1/combat/{session_id}/state')
|
||||||
|
result = response.get('result', {})
|
||||||
|
|
||||||
|
# Check if combat is still active
|
||||||
|
if not result.get('in_combat'):
|
||||||
|
return '<div class="state-section"><h4>Combat Ended</h4><p>No active combat.</p></div>'
|
||||||
|
|
||||||
|
encounter = result.get('encounter') or {}
|
||||||
|
|
||||||
|
# Get current turn combatant ID directly from API response
|
||||||
|
current_turn_id = encounter.get('current_turn')
|
||||||
|
|
||||||
|
# Separate player and enemies
|
||||||
|
player_combatant = None
|
||||||
|
enemy_combatants = []
|
||||||
|
is_player_turn = False
|
||||||
|
for combatant in encounter.get('combatants', []):
|
||||||
|
if combatant.get('is_player'):
|
||||||
|
player_combatant = combatant
|
||||||
|
if combatant.get('combatant_id') == current_turn_id:
|
||||||
|
is_player_turn = True
|
||||||
|
else:
|
||||||
|
enemy_combatants.append(combatant)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'dev/partials/combat_state.html',
|
||||||
|
session_id=session_id,
|
||||||
|
encounter=encounter,
|
||||||
|
player_combatant=player_combatant,
|
||||||
|
enemy_combatants=enemy_combatants,
|
||||||
|
current_turn_id=current_turn_id,
|
||||||
|
is_player_turn=is_player_turn
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_get_combat_state", session_id=session_id, error=str(e))
|
||||||
|
return f'<div class="error">Failed to load state: {e}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
|
@dev_bp.route('/combat/<session_id>/action', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def combat_action(session_id: str):
|
||||||
|
"""Execute a combat action - returns log entry HTML."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
action_type = request.form.get('action_type', 'attack')
|
||||||
|
ability_id = request.form.get('ability_id')
|
||||||
|
item_id = request.form.get('item_id')
|
||||||
|
target_id = request.form.get('target_id')
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = {'action_type': action_type}
|
||||||
|
if ability_id:
|
||||||
|
payload['ability_id'] = ability_id
|
||||||
|
if item_id:
|
||||||
|
payload['item_id'] = item_id
|
||||||
|
if target_id:
|
||||||
|
payload['target_id'] = target_id
|
||||||
|
|
||||||
|
response = client.post(f'/api/v1/combat/{session_id}/action', payload)
|
||||||
|
result = response.get('result', {})
|
||||||
|
|
||||||
|
# Check if combat ended
|
||||||
|
combat_ended = result.get('combat_ended', False)
|
||||||
|
combat_status = result.get('combat_status')
|
||||||
|
|
||||||
|
if combat_ended:
|
||||||
|
# API returns lowercase status values: 'victory', 'defeat', 'fled'
|
||||||
|
status_lower = (combat_status or '').lower()
|
||||||
|
if status_lower == 'victory':
|
||||||
|
return render_template(
|
||||||
|
'dev/partials/combat_victory.html',
|
||||||
|
session_id=session_id,
|
||||||
|
rewards=result.get('rewards', {})
|
||||||
|
)
|
||||||
|
elif status_lower == 'defeat':
|
||||||
|
return render_template(
|
||||||
|
'dev/partials/combat_defeat.html',
|
||||||
|
session_id=session_id,
|
||||||
|
gold_lost=result.get('gold_lost', 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format action result for log
|
||||||
|
# API returns data directly in result, not nested under 'action_result'
|
||||||
|
log_entries = []
|
||||||
|
|
||||||
|
player_entry = {
|
||||||
|
'actor': 'You',
|
||||||
|
'message': result.get('message', f'used {action_type}'),
|
||||||
|
'type': 'player',
|
||||||
|
'is_crit': False
|
||||||
|
}
|
||||||
|
|
||||||
|
damage_results = result.get('damage_results', [])
|
||||||
|
if damage_results:
|
||||||
|
for dmg in damage_results:
|
||||||
|
player_entry['damage'] = dmg.get('total_damage') or dmg.get('damage')
|
||||||
|
player_entry['is_crit'] = dmg.get('is_critical', False)
|
||||||
|
if player_entry['is_crit']:
|
||||||
|
player_entry['type'] = 'crit'
|
||||||
|
|
||||||
|
if result.get('healing'):
|
||||||
|
player_entry['heal'] = result.get('healing')
|
||||||
|
player_entry['type'] = 'heal'
|
||||||
|
|
||||||
|
log_entries.append(player_entry)
|
||||||
|
|
||||||
|
for effect in result.get('effects_applied', []):
|
||||||
|
log_entries.append({
|
||||||
|
'actor': '',
|
||||||
|
'message': effect.get('message', f'Effect applied: {effect.get("name")}'),
|
||||||
|
'type': 'system'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Return log entries with optional enemy turn trigger
|
||||||
|
from flask import make_response
|
||||||
|
resp = make_response(render_template(
|
||||||
|
'dev/partials/combat_debug_log.html',
|
||||||
|
combat_log=log_entries
|
||||||
|
))
|
||||||
|
|
||||||
|
# Trigger enemy turn if needed
|
||||||
|
if result.get('next_combatant_id') and not result.get('next_is_player', True):
|
||||||
|
resp.headers['HX-Trigger'] = 'enemyTurn'
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("combat_action_failed", session_id=session_id, action_type=action_type, error=str(e))
|
||||||
|
return f'''
|
||||||
|
<div class="log-entry log-entry--system">
|
||||||
|
<span class="log-message">Action failed: {e}</span>
|
||||||
|
</div>
|
||||||
|
''', 500
|
||||||
|
|
||||||
|
|
||||||
|
@dev_bp.route('/combat/<session_id>/enemy-turn', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def combat_enemy_turn(session_id: str):
|
||||||
|
"""Execute enemy turn - returns log entry HTML."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.post(f'/api/v1/combat/{session_id}/enemy-turn', {})
|
||||||
|
result = response.get('result', {})
|
||||||
|
|
||||||
|
# Check if combat ended
|
||||||
|
combat_ended = result.get('combat_ended', False)
|
||||||
|
combat_status = result.get('combat_status')
|
||||||
|
|
||||||
|
if combat_ended:
|
||||||
|
# API returns lowercase status values: 'victory', 'defeat', 'fled'
|
||||||
|
status_lower = (combat_status or '').lower()
|
||||||
|
if status_lower == 'victory':
|
||||||
|
return render_template(
|
||||||
|
'dev/partials/combat_victory.html',
|
||||||
|
session_id=session_id,
|
||||||
|
rewards=result.get('rewards', {})
|
||||||
|
)
|
||||||
|
elif status_lower == 'defeat':
|
||||||
|
return render_template(
|
||||||
|
'dev/partials/combat_defeat.html',
|
||||||
|
session_id=session_id,
|
||||||
|
gold_lost=result.get('gold_lost', 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format enemy action for log
|
||||||
|
# The API returns the action result directly with a complete message
|
||||||
|
damage_results = result.get('damage_results', [])
|
||||||
|
is_crit = damage_results[0].get('is_critical', False) if damage_results else False
|
||||||
|
|
||||||
|
log_entries = [{
|
||||||
|
'actor': '', # Message already contains the actor name
|
||||||
|
'message': result.get('message', 'Enemy attacks!'),
|
||||||
|
'type': 'crit' if is_crit else 'enemy',
|
||||||
|
'is_crit': is_crit,
|
||||||
|
'damage': damage_results[0].get('total_damage') if damage_results else None
|
||||||
|
}]
|
||||||
|
|
||||||
|
from flask import make_response
|
||||||
|
resp = make_response(render_template(
|
||||||
|
'dev/partials/combat_debug_log.html',
|
||||||
|
combat_log=log_entries
|
||||||
|
))
|
||||||
|
|
||||||
|
# Trigger another enemy turn if needed
|
||||||
|
if result.get('next_combatant_id') and not result.get('next_is_player', True):
|
||||||
|
resp.headers['HX-Trigger'] = 'enemyTurn'
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("enemy_turn_failed", session_id=session_id, error=str(e))
|
||||||
|
return f'''
|
||||||
|
<div class="log-entry log-entry--system">
|
||||||
|
<span class="log-message">Enemy turn error: {e}</span>
|
||||||
|
</div>
|
||||||
|
''', 500
|
||||||
|
|
||||||
|
|
||||||
|
@dev_bp.route('/combat/<session_id>/abilities')
|
||||||
|
@require_auth
|
||||||
|
def combat_abilities(session_id: str):
|
||||||
|
"""Get abilities modal for combat."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.get(f'/api/v1/combat/{session_id}/state')
|
||||||
|
result = response.get('result', {})
|
||||||
|
encounter = result.get('encounter', {})
|
||||||
|
|
||||||
|
player_combatant = None
|
||||||
|
for combatant in encounter.get('combatants', []):
|
||||||
|
if combatant.get('is_player'):
|
||||||
|
player_combatant = combatant
|
||||||
|
break
|
||||||
|
|
||||||
|
abilities = []
|
||||||
|
if player_combatant:
|
||||||
|
ability_ids = player_combatant.get('abilities', [])
|
||||||
|
current_mp = player_combatant.get('current_mp', 0)
|
||||||
|
cooldowns = player_combatant.get('cooldowns', {})
|
||||||
|
|
||||||
|
for ability_id in ability_ids:
|
||||||
|
try:
|
||||||
|
ability_response = client.get(f'/api/v1/abilities/{ability_id}')
|
||||||
|
ability_data = ability_response.get('result', {})
|
||||||
|
|
||||||
|
mp_cost = ability_data.get('mp_cost', 0)
|
||||||
|
cooldown = cooldowns.get(ability_id, 0)
|
||||||
|
available = current_mp >= mp_cost and cooldown == 0
|
||||||
|
|
||||||
|
abilities.append({
|
||||||
|
'id': ability_id,
|
||||||
|
'name': ability_data.get('name', ability_id),
|
||||||
|
'description': ability_data.get('description', ''),
|
||||||
|
'mp_cost': mp_cost,
|
||||||
|
'cooldown': cooldown,
|
||||||
|
'max_cooldown': ability_data.get('cooldown', 0),
|
||||||
|
'damage_type': ability_data.get('damage_type'),
|
||||||
|
'effect_type': ability_data.get('effect_type'),
|
||||||
|
'available': available
|
||||||
|
})
|
||||||
|
except (APINotFoundError, APIError):
|
||||||
|
abilities.append({
|
||||||
|
'id': ability_id,
|
||||||
|
'name': ability_id.replace('_', ' ').title(),
|
||||||
|
'description': '',
|
||||||
|
'mp_cost': 0,
|
||||||
|
'cooldown': cooldowns.get(ability_id, 0),
|
||||||
|
'max_cooldown': 0,
|
||||||
|
'available': True
|
||||||
|
})
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'dev/partials/ability_modal.html',
|
||||||
|
session_id=session_id,
|
||||||
|
abilities=abilities
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_abilities", session_id=session_id, error=str(e))
|
||||||
|
return f'''
|
||||||
|
<div class="modal-overlay" onclick="closeModal()">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>Select Ability</h3>
|
||||||
|
<div class="error">Failed to load abilities: {e}</div>
|
||||||
|
<button class="modal-close" onclick="closeModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
@dev_bp.route('/combat/<session_id>/items')
|
||||||
|
@require_auth
|
||||||
|
def combat_items(session_id: str):
|
||||||
|
"""Get combat items bottom sheet (consumables only)."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||||
|
session_data = session_response.get('result', {})
|
||||||
|
character_id = session_data.get('character_id')
|
||||||
|
|
||||||
|
consumables = []
|
||||||
|
if character_id:
|
||||||
|
try:
|
||||||
|
inv_response = client.get(f'/api/v1/characters/{character_id}/inventory')
|
||||||
|
inv_data = inv_response.get('result', {})
|
||||||
|
inventory = inv_data.get('inventory', [])
|
||||||
|
|
||||||
|
for item in inventory:
|
||||||
|
item_type = item.get('item_type', item.get('type', ''))
|
||||||
|
if item_type == 'consumable' or item.get('usable_in_combat', False):
|
||||||
|
consumables.append({
|
||||||
|
'item_id': item.get('item_id'),
|
||||||
|
'name': item.get('name', 'Unknown Item'),
|
||||||
|
'description': item.get('description', ''),
|
||||||
|
'effects_on_use': item.get('effects_on_use', []),
|
||||||
|
'rarity': item.get('rarity', 'common')
|
||||||
|
})
|
||||||
|
except (APINotFoundError, APIError) as e:
|
||||||
|
logger.warning("failed_to_load_combat_inventory", character_id=character_id, error=str(e))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'dev/partials/combat_items_sheet.html',
|
||||||
|
session_id=session_id,
|
||||||
|
consumables=consumables,
|
||||||
|
has_consumables=len(consumables) > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_items", session_id=session_id, error=str(e))
|
||||||
|
return f'''
|
||||||
|
<div class="combat-items-sheet open">
|
||||||
|
<div class="sheet-header">
|
||||||
|
<h3>Use Item</h3>
|
||||||
|
<button class="sheet-close" onclick="closeCombatSheet()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-body">
|
||||||
|
<div class="error">Failed to load items: {e}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-backdrop" onclick="closeCombatSheet()"></div>
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
@dev_bp.route('/combat/<session_id>/items/<item_id>/detail')
|
||||||
|
@require_auth
|
||||||
|
def combat_item_detail(session_id: str, item_id: str):
|
||||||
|
"""Get item detail for combat bottom sheet."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||||
|
session_data = session_response.get('result', {})
|
||||||
|
character_id = session_data.get('character_id')
|
||||||
|
|
||||||
|
item = None
|
||||||
|
if character_id:
|
||||||
|
inv_response = client.get(f'/api/v1/characters/{character_id}/inventory')
|
||||||
|
inv_data = inv_response.get('result', {})
|
||||||
|
inventory = inv_data.get('inventory', [])
|
||||||
|
|
||||||
|
for inv_item in inventory:
|
||||||
|
if inv_item.get('item_id') == item_id:
|
||||||
|
item = inv_item
|
||||||
|
break
|
||||||
|
|
||||||
|
if not item:
|
||||||
|
return '<p>Item not found</p>', 404
|
||||||
|
|
||||||
|
effect_desc = item.get('description', 'Use this item')
|
||||||
|
effects = item.get('effects_on_use', [])
|
||||||
|
if effects:
|
||||||
|
effect_parts = []
|
||||||
|
for effect in effects:
|
||||||
|
if effect.get('stat') == 'hp':
|
||||||
|
effect_parts.append(f"Restores {effect.get('value', 0)} HP")
|
||||||
|
elif effect.get('stat') == 'mp':
|
||||||
|
effect_parts.append(f"Restores {effect.get('value', 0)} MP")
|
||||||
|
elif effect.get('name'):
|
||||||
|
effect_parts.append(effect.get('name'))
|
||||||
|
if effect_parts:
|
||||||
|
effect_desc = ', '.join(effect_parts)
|
||||||
|
|
||||||
|
return f'''
|
||||||
|
<div class="detail-info">
|
||||||
|
<div class="detail-name">{item.get('name', 'Item')}</div>
|
||||||
|
<div class="detail-effect">{effect_desc}</div>
|
||||||
|
</div>
|
||||||
|
<button class="use-btn"
|
||||||
|
hx-post="/dev/combat/{session_id}/action"
|
||||||
|
hx-vals='{{"action_type": "item", "item_id": "{item_id}"}}'
|
||||||
|
hx-target="#combat-log"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
onclick="closeCombatSheet()">
|
||||||
|
Use
|
||||||
|
</button>
|
||||||
|
'''
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_combat_item_detail", session_id=session_id, item_id=item_id, error=str(e))
|
||||||
|
return f'<p>Failed to load item: {e}</p>', 500
|
||||||
|
|
||||||
|
|
||||||
|
@dev_bp.route('/combat/<session_id>/end', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def force_end_combat(session_id: str):
|
||||||
|
"""Force end combat (debug action)."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
victory = request.form.get('victory', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.post(f'/api/v1/combat/{session_id}/end', {'victory': victory})
|
||||||
|
result = response.get('result', {})
|
||||||
|
|
||||||
|
if victory:
|
||||||
|
return render_template(
|
||||||
|
'dev/partials/combat_victory.html',
|
||||||
|
session_id=session_id,
|
||||||
|
rewards=result.get('rewards', {})
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return render_template(
|
||||||
|
'dev/partials/combat_defeat.html',
|
||||||
|
session_id=session_id,
|
||||||
|
gold_lost=result.get('gold_lost', 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_end_combat", session_id=session_id, error=str(e))
|
||||||
|
return f'<div class="error">Failed to end combat: {e}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
|
@dev_bp.route('/combat/<session_id>/reset-hp-mp', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def reset_hp_mp(session_id: str):
|
||||||
|
"""Reset player HP and MP to full (debug action)."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.post(f'/api/v1/combat/{session_id}/debug/reset-hp-mp', {})
|
||||||
|
result = response.get('result', {})
|
||||||
|
|
||||||
|
return f'''
|
||||||
|
<div class="log-entry log-entry--heal">
|
||||||
|
<span class="log-message">HP and MP restored to full! (HP: {result.get('current_hp', '?')}/{result.get('max_hp', '?')}, MP: {result.get('current_mp', '?')}/{result.get('max_mp', '?')})</span>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_reset_hp_mp", session_id=session_id, error=str(e))
|
||||||
|
return f'''
|
||||||
|
<div class="log-entry log-entry--system">
|
||||||
|
<span class="log-message">Failed to reset HP/MP: {e}</span>
|
||||||
|
</div>
|
||||||
|
''', 500
|
||||||
|
|
||||||
|
|
||||||
|
@dev_bp.route('/combat/<session_id>/log')
|
||||||
|
@require_auth
|
||||||
|
def combat_log(session_id: str):
|
||||||
|
"""Get full combat log."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.get(f'/api/v1/combat/{session_id}/state')
|
||||||
|
result = response.get('result', {})
|
||||||
|
combat_log_data = result.get('combat_log', [])
|
||||||
|
|
||||||
|
formatted_log = []
|
||||||
|
for entry in combat_log_data:
|
||||||
|
log_entry = {
|
||||||
|
'actor': entry.get('combatant_name', entry.get('actor', '')),
|
||||||
|
'message': entry.get('message', ''),
|
||||||
|
'damage': entry.get('damage'),
|
||||||
|
'heal': entry.get('healing'),
|
||||||
|
'is_crit': entry.get('is_critical', False),
|
||||||
|
'type': 'player' if entry.get('is_player', False) else 'enemy'
|
||||||
|
}
|
||||||
|
if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'):
|
||||||
|
log_entry['type'] = 'system'
|
||||||
|
formatted_log.append(log_entry)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'dev/partials/combat_debug_log.html',
|
||||||
|
combat_log=formatted_log
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_combat_log", session_id=session_id, error=str(e))
|
||||||
|
return '<div class="error">Failed to load combat log</div>', 500
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Provides the main gameplay interface with 3-column layout:
|
|||||||
- Right: Accordions for history, quests, NPCs, map
|
- Right: Accordions for history, quests, NPCs, map
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, render_template, request
|
from flask import Blueprint, render_template, request, redirect, url_for
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from ..utils.api_client import get_api_client, APIError, APINotFoundError
|
from ..utils.api_client import get_api_client, APIError, APINotFoundError
|
||||||
@@ -25,7 +25,6 @@ game_bp = Blueprint('game', __name__, url_prefix='/play')
|
|||||||
DEFAULT_ACTIONS = {
|
DEFAULT_ACTIONS = {
|
||||||
'free': [
|
'free': [
|
||||||
{'prompt_id': 'ask_locals', 'display_text': 'Ask Locals for Information', 'icon': 'chat', 'context': ['town', 'tavern']},
|
{'prompt_id': 'ask_locals', 'display_text': 'Ask Locals for Information', 'icon': 'chat', 'context': ['town', 'tavern']},
|
||||||
{'prompt_id': 'explore_area', 'display_text': 'Explore the Area', 'icon': 'compass', 'context': ['wilderness', 'dungeon']},
|
|
||||||
{'prompt_id': 'search_supplies', 'display_text': 'Search for Supplies', 'icon': 'search', 'context': ['any'], 'cooldown': 2},
|
{'prompt_id': 'search_supplies', 'display_text': 'Search for Supplies', 'icon': 'search', 'context': ['any'], 'cooldown': 2},
|
||||||
{'prompt_id': 'rest_recover', 'display_text': 'Rest and Recover', 'icon': 'bed', 'context': ['town', 'tavern', 'safe_area'], 'cooldown': 3}
|
{'prompt_id': 'rest_recover', 'display_text': 'Rest and Recover', 'icon': 'bed', 'context': ['town', 'tavern', 'safe_area'], 'cooldown': 3}
|
||||||
],
|
],
|
||||||
@@ -718,6 +717,243 @@ def do_travel(session_id: str):
|
|||||||
return f'<div class="error">Travel failed: {e}</div>', 500
|
return f'<div class="error">Travel failed: {e}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
|
@game_bp.route('/session/<session_id>/monster-modal')
|
||||||
|
@require_auth
|
||||||
|
def monster_modal(session_id: str):
|
||||||
|
"""
|
||||||
|
Get monster selection modal with encounter options.
|
||||||
|
|
||||||
|
Fetches random encounter groups appropriate for the current location
|
||||||
|
and character level from the API.
|
||||||
|
"""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get encounter options from API
|
||||||
|
response = client.get(f'/api/v1/combat/encounters?session_id={session_id}')
|
||||||
|
result = response.get('result', {})
|
||||||
|
|
||||||
|
location_name = result.get('location_name', 'Unknown Area')
|
||||||
|
encounters = result.get('encounters', [])
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'game/partials/monster_modal.html',
|
||||||
|
session_id=session_id,
|
||||||
|
location_name=location_name,
|
||||||
|
encounters=encounters
|
||||||
|
)
|
||||||
|
|
||||||
|
except APINotFoundError as e:
|
||||||
|
# No enemies found for this location
|
||||||
|
return render_template(
|
||||||
|
'game/partials/monster_modal.html',
|
||||||
|
session_id=session_id,
|
||||||
|
location_name='this area',
|
||||||
|
encounters=[]
|
||||||
|
)
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_monster_modal", session_id=session_id, error=str(e))
|
||||||
|
return f'''
|
||||||
|
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">⚔️ Search for Monsters</h3>
|
||||||
|
<button class="modal-close" onclick="closeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="error">Failed to search for monsters: {e}</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
@game_bp.route('/session/<session_id>/combat/start', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def start_combat(session_id: str):
|
||||||
|
"""
|
||||||
|
Start combat with selected enemies.
|
||||||
|
|
||||||
|
Called when player selects an encounter from the monster modal.
|
||||||
|
Initiates combat via API and redirects to combat UI.
|
||||||
|
|
||||||
|
If there's already an active combat session, shows a conflict modal
|
||||||
|
allowing the user to resume or abandon the existing combat.
|
||||||
|
"""
|
||||||
|
from flask import make_response
|
||||||
|
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
# Get enemy_ids from request
|
||||||
|
# HTMX hx-vals sends as form data (not JSON), where arrays become multiple values
|
||||||
|
if request.is_json:
|
||||||
|
enemy_ids = request.json.get('enemy_ids', [])
|
||||||
|
else:
|
||||||
|
# Form data: array values come as multiple entries with the same key
|
||||||
|
enemy_ids = request.form.getlist('enemy_ids')
|
||||||
|
|
||||||
|
if not enemy_ids:
|
||||||
|
return '<div class="error">No enemies selected.</div>', 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start combat via API
|
||||||
|
response = client.post('/api/v1/combat/start', {
|
||||||
|
'session_id': session_id,
|
||||||
|
'enemy_ids': enemy_ids
|
||||||
|
})
|
||||||
|
result = response.get('result', {})
|
||||||
|
encounter_id = result.get('encounter_id')
|
||||||
|
|
||||||
|
if not encounter_id:
|
||||||
|
logger.error("combat_start_no_encounter_id", session_id=session_id)
|
||||||
|
return '<div class="error">Failed to start combat - no encounter ID returned.</div>', 500
|
||||||
|
|
||||||
|
logger.info("combat_started_from_modal",
|
||||||
|
session_id=session_id,
|
||||||
|
encounter_id=encounter_id,
|
||||||
|
enemy_count=len(enemy_ids))
|
||||||
|
|
||||||
|
# Close modal and redirect to combat page
|
||||||
|
resp = make_response('')
|
||||||
|
resp.headers['HX-Redirect'] = url_for('combat.combat_view', session_id=session_id)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
# Check if this is an "already in combat" error
|
||||||
|
error_str = str(e)
|
||||||
|
if 'already in combat' in error_str.lower() or 'ALREADY_IN_COMBAT' in error_str:
|
||||||
|
# Fetch existing combat info and show conflict modal
|
||||||
|
try:
|
||||||
|
check_response = client.get(f'/api/v1/combat/{session_id}/check')
|
||||||
|
combat_info = check_response.get('result', {})
|
||||||
|
|
||||||
|
if combat_info.get('has_active_combat'):
|
||||||
|
return render_template(
|
||||||
|
'game/partials/combat_conflict_modal.html',
|
||||||
|
session_id=session_id,
|
||||||
|
combat_info=combat_info,
|
||||||
|
pending_enemy_ids=enemy_ids
|
||||||
|
)
|
||||||
|
except APIError:
|
||||||
|
pass # Fall through to generic error
|
||||||
|
|
||||||
|
logger.error("failed_to_start_combat", session_id=session_id, error=str(e))
|
||||||
|
return f'<div class="error">Failed to start combat: {e}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
|
@game_bp.route('/session/<session_id>/combat/check', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def check_combat_status(session_id: str):
|
||||||
|
"""
|
||||||
|
Check if the session has an active combat.
|
||||||
|
|
||||||
|
Returns JSON with combat status that can be used by HTMX
|
||||||
|
to decide whether to show the monster modal or conflict modal.
|
||||||
|
"""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.get(f'/api/v1/combat/{session_id}/check')
|
||||||
|
result = response.get('result', {})
|
||||||
|
return result
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_check_combat", session_id=session_id, error=str(e))
|
||||||
|
return {'has_active_combat': False, 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@game_bp.route('/session/<session_id>/combat/abandon', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def abandon_combat(session_id: str):
|
||||||
|
"""
|
||||||
|
Abandon an existing combat session.
|
||||||
|
|
||||||
|
Called when player chooses to abandon their current combat
|
||||||
|
in order to start a fresh one.
|
||||||
|
"""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.post(f'/api/v1/combat/{session_id}/abandon', {})
|
||||||
|
result = response.get('result', {})
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
logger.info("combat_abandoned", session_id=session_id)
|
||||||
|
# Return success - the frontend will then try to start new combat
|
||||||
|
return render_template(
|
||||||
|
'game/partials/combat_abandoned_success.html',
|
||||||
|
session_id=session_id,
|
||||||
|
message="Combat abandoned. You can now start a new encounter."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return '<div class="error">No active combat to abandon.</div>', 400
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_abandon_combat", session_id=session_id, error=str(e))
|
||||||
|
return f'<div class="error">Failed to abandon combat: {e}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
|
@game_bp.route('/session/<session_id>/combat/abandon-and-start', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def abandon_and_start_combat(session_id: str):
|
||||||
|
"""
|
||||||
|
Abandon existing combat and start a new one in a single action.
|
||||||
|
|
||||||
|
This is a convenience endpoint that combines abandon + start
|
||||||
|
for a smoother user experience in the conflict modal.
|
||||||
|
"""
|
||||||
|
from flask import make_response
|
||||||
|
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
# Get enemy_ids from request
|
||||||
|
if request.is_json:
|
||||||
|
enemy_ids = request.json.get('enemy_ids', [])
|
||||||
|
else:
|
||||||
|
enemy_ids = request.form.getlist('enemy_ids')
|
||||||
|
|
||||||
|
if not enemy_ids:
|
||||||
|
return '<div class="error">No enemies selected.</div>', 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First abandon the existing combat
|
||||||
|
abandon_response = client.post(f'/api/v1/combat/{session_id}/abandon', {})
|
||||||
|
abandon_result = abandon_response.get('result', {})
|
||||||
|
|
||||||
|
if not abandon_result.get('success'):
|
||||||
|
# No combat to abandon, but that's fine - proceed with start
|
||||||
|
logger.info("no_combat_to_abandon", session_id=session_id)
|
||||||
|
|
||||||
|
# Now start the new combat
|
||||||
|
start_response = client.post('/api/v1/combat/start', {
|
||||||
|
'session_id': session_id,
|
||||||
|
'enemy_ids': enemy_ids
|
||||||
|
})
|
||||||
|
result = start_response.get('result', {})
|
||||||
|
encounter_id = result.get('encounter_id')
|
||||||
|
|
||||||
|
if not encounter_id:
|
||||||
|
logger.error("combat_start_no_encounter_id_after_abandon", session_id=session_id)
|
||||||
|
return '<div class="error">Failed to start combat - no encounter ID returned.</div>', 500
|
||||||
|
|
||||||
|
logger.info("combat_started_after_abandon",
|
||||||
|
session_id=session_id,
|
||||||
|
encounter_id=encounter_id,
|
||||||
|
enemy_count=len(enemy_ids))
|
||||||
|
|
||||||
|
# Close modal and redirect to combat page
|
||||||
|
resp = make_response('')
|
||||||
|
resp.headers['HX-Redirect'] = url_for('combat.combat_view', session_id=session_id)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_abandon_and_start_combat", session_id=session_id, error=str(e))
|
||||||
|
return f'<div class="error">Failed to start combat: {e}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
@game_bp.route('/session/<session_id>/npc/<npc_id>')
|
@game_bp.route('/session/<session_id>/npc/<npc_id>')
|
||||||
@require_auth
|
@require_auth
|
||||||
def npc_chat_page(session_id: str, npc_id: str):
|
def npc_chat_page(session_id: str, npc_id: str):
|
||||||
@@ -866,6 +1102,220 @@ def npc_chat_history(session_id: str, npc_id: str):
|
|||||||
return '<div class="history-empty">Failed to load history</div>', 500
|
return '<div class="history-empty">Failed to load history</div>', 500
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Inventory Routes =====
|
||||||
|
|
||||||
|
@game_bp.route('/session/<session_id>/inventory-modal')
|
||||||
|
@require_auth
|
||||||
|
def inventory_modal(session_id: str):
|
||||||
|
"""
|
||||||
|
Get inventory modal with all items.
|
||||||
|
|
||||||
|
Supports filtering by item type via ?filter= parameter.
|
||||||
|
"""
|
||||||
|
client = get_api_client()
|
||||||
|
filter_type = request.args.get('filter', 'all')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get session to find character
|
||||||
|
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||||
|
session_data = session_response.get('result', {})
|
||||||
|
character_id = session_data.get('character_id')
|
||||||
|
|
||||||
|
inventory = []
|
||||||
|
equipped = {}
|
||||||
|
gold = 0
|
||||||
|
inventory_count = 0
|
||||||
|
inventory_max = 100
|
||||||
|
|
||||||
|
if character_id:
|
||||||
|
try:
|
||||||
|
inv_response = client.get(f'/api/v1/characters/{character_id}/inventory')
|
||||||
|
inv_data = inv_response.get('result', {})
|
||||||
|
inventory = inv_data.get('inventory', [])
|
||||||
|
equipped = inv_data.get('equipped', {})
|
||||||
|
inventory_count = inv_data.get('inventory_count', len(inventory))
|
||||||
|
inventory_max = inv_data.get('max_inventory', 100)
|
||||||
|
|
||||||
|
# Get gold from character
|
||||||
|
char_response = client.get(f'/api/v1/characters/{character_id}')
|
||||||
|
char_data = char_response.get('result', {})
|
||||||
|
gold = char_data.get('gold', 0)
|
||||||
|
except (APINotFoundError, APIError) as e:
|
||||||
|
logger.warning("failed_to_load_inventory", character_id=character_id, error=str(e))
|
||||||
|
|
||||||
|
# Filter inventory by type if specified
|
||||||
|
if filter_type != 'all':
|
||||||
|
inventory = [item for item in inventory if item.get('item_type') == filter_type]
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'game/partials/inventory_modal.html',
|
||||||
|
session_id=session_id,
|
||||||
|
inventory=inventory,
|
||||||
|
equipped=equipped,
|
||||||
|
gold=gold,
|
||||||
|
inventory_count=inventory_count,
|
||||||
|
inventory_max=inventory_max,
|
||||||
|
filter=filter_type
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_inventory_modal", session_id=session_id, error=str(e))
|
||||||
|
return f'''
|
||||||
|
<div class="modal-overlay" onclick="closeModal()">
|
||||||
|
<div class="modal-content inventory-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Inventory</h2>
|
||||||
|
<button class="modal-close" onclick="closeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="inventory-empty">Failed to load inventory: {e}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
@game_bp.route('/session/<session_id>/inventory/item/<item_id>')
|
||||||
|
@require_auth
|
||||||
|
def inventory_item_detail(session_id: str, item_id: str):
|
||||||
|
"""Get item detail partial for HTMX swap."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get session to find character
|
||||||
|
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||||
|
session_data = session_response.get('result', {})
|
||||||
|
character_id = session_data.get('character_id')
|
||||||
|
|
||||||
|
item = None
|
||||||
|
if character_id:
|
||||||
|
# Get inventory and find the item
|
||||||
|
inv_response = client.get(f'/api/v1/characters/{character_id}/inventory')
|
||||||
|
inv_data = inv_response.get('result', {})
|
||||||
|
inventory = inv_data.get('inventory', [])
|
||||||
|
|
||||||
|
for inv_item in inventory:
|
||||||
|
if inv_item.get('item_id') == item_id:
|
||||||
|
item = inv_item
|
||||||
|
break
|
||||||
|
|
||||||
|
if not item:
|
||||||
|
return '<div class="item-detail-empty">Item not found</div>', 404
|
||||||
|
|
||||||
|
# Determine suggested slot for equipment
|
||||||
|
suggested_slot = None
|
||||||
|
item_type = item.get('item_type', '')
|
||||||
|
if item_type == 'weapon':
|
||||||
|
suggested_slot = 'weapon'
|
||||||
|
elif item_type == 'armor':
|
||||||
|
# Could be any armor slot - default to chest
|
||||||
|
suggested_slot = 'chest'
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'game/partials/inventory_item_detail.html',
|
||||||
|
session_id=session_id,
|
||||||
|
item=item,
|
||||||
|
suggested_slot=suggested_slot
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_item_detail", session_id=session_id, item_id=item_id, error=str(e))
|
||||||
|
return f'<div class="item-detail-empty">Failed to load item: {e}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
|
@game_bp.route('/session/<session_id>/inventory/use', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def inventory_use(session_id: str):
|
||||||
|
"""Use a consumable item."""
|
||||||
|
client = get_api_client()
|
||||||
|
item_id = request.form.get('item_id')
|
||||||
|
|
||||||
|
if not item_id:
|
||||||
|
return '<div class="error">No item selected</div>', 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get session to find character
|
||||||
|
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||||
|
session_data = session_response.get('result', {})
|
||||||
|
character_id = session_data.get('character_id')
|
||||||
|
|
||||||
|
if not character_id:
|
||||||
|
return '<div class="error">No character found</div>', 400
|
||||||
|
|
||||||
|
# Use the item via API
|
||||||
|
client.post(f'/api/v1/characters/{character_id}/inventory/use', {
|
||||||
|
'item_id': item_id
|
||||||
|
})
|
||||||
|
|
||||||
|
# Return updated character panel
|
||||||
|
return redirect(url_for('game.character_panel', session_id=session_id))
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_use_item", session_id=session_id, item_id=item_id, error=str(e))
|
||||||
|
return f'<div class="error">Failed to use item: {e}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
|
@game_bp.route('/session/<session_id>/inventory/equip', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def inventory_equip(session_id: str):
|
||||||
|
"""Equip an item to a slot."""
|
||||||
|
client = get_api_client()
|
||||||
|
item_id = request.form.get('item_id')
|
||||||
|
slot = request.form.get('slot')
|
||||||
|
|
||||||
|
if not item_id:
|
||||||
|
return '<div class="error">No item selected</div>', 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get session to find character
|
||||||
|
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||||
|
session_data = session_response.get('result', {})
|
||||||
|
character_id = session_data.get('character_id')
|
||||||
|
|
||||||
|
if not character_id:
|
||||||
|
return '<div class="error">No character found</div>', 400
|
||||||
|
|
||||||
|
# Equip the item via API
|
||||||
|
payload = {'item_id': item_id}
|
||||||
|
if slot:
|
||||||
|
payload['slot'] = slot
|
||||||
|
|
||||||
|
client.post(f'/api/v1/characters/{character_id}/inventory/equip', payload)
|
||||||
|
|
||||||
|
# Return updated character panel
|
||||||
|
return redirect(url_for('game.character_panel', session_id=session_id))
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_equip_item", session_id=session_id, item_id=item_id, error=str(e))
|
||||||
|
return f'<div class="error">Failed to equip item: {e}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
|
@game_bp.route('/session/<session_id>/inventory/<item_id>', methods=['DELETE'])
|
||||||
|
@require_auth
|
||||||
|
def inventory_drop(session_id: str, item_id: str):
|
||||||
|
"""Drop (delete) an item from inventory."""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get session to find character
|
||||||
|
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||||
|
session_data = session_response.get('result', {})
|
||||||
|
character_id = session_data.get('character_id')
|
||||||
|
|
||||||
|
if not character_id:
|
||||||
|
return '<div class="error">No character found</div>', 400
|
||||||
|
|
||||||
|
# Delete the item via API
|
||||||
|
client.delete(f'/api/v1/characters/{character_id}/inventory/{item_id}')
|
||||||
|
|
||||||
|
# Return updated inventory modal
|
||||||
|
return redirect(url_for('game.inventory_modal', session_id=session_id))
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_drop_item", session_id=session_id, item_id=item_id, error=str(e))
|
||||||
|
return f'<div class="error">Failed to drop item: {e}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
@game_bp.route('/session/<session_id>/npc/<npc_id>/talk', methods=['POST'])
|
@game_bp.route('/session/<session_id>/npc/<npc_id>/talk', methods=['POST'])
|
||||||
@require_auth
|
@require_auth
|
||||||
def talk_to_npc(session_id: str, npc_id: str):
|
def talk_to_npc(session_id: str, npc_id: str):
|
||||||
|
|||||||
1182
public_web/static/css/combat.css
Normal file
1182
public_web/static/css/combat.css
Normal file
File diff suppressed because it is too large
Load Diff
722
public_web/static/css/inventory.css
Normal file
722
public_web/static/css/inventory.css
Normal file
@@ -0,0 +1,722 @@
|
|||||||
|
/**
|
||||||
|
* Code of Conquest - Inventory UI Stylesheet
|
||||||
|
* Inventory modal, item grid, and combat items sheet
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===== INVENTORY VARIABLES ===== */
|
||||||
|
:root {
|
||||||
|
/* Rarity colors */
|
||||||
|
--rarity-common: #9ca3af;
|
||||||
|
--rarity-uncommon: #22c55e;
|
||||||
|
--rarity-rare: #3b82f6;
|
||||||
|
--rarity-epic: #a855f7;
|
||||||
|
--rarity-legendary: #f59e0b;
|
||||||
|
|
||||||
|
/* Item card */
|
||||||
|
--item-bg: var(--bg-input, #1e1e24);
|
||||||
|
--item-border: var(--border-primary, #3a3a45);
|
||||||
|
--item-hover-bg: rgba(255, 255, 255, 0.05);
|
||||||
|
|
||||||
|
/* Touch targets - WCAG compliant */
|
||||||
|
--touch-target-min: 48px;
|
||||||
|
--touch-target-primary: 56px;
|
||||||
|
--touch-spacing: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== INVENTORY MODAL ===== */
|
||||||
|
.inventory-modal {
|
||||||
|
max-width: 800px;
|
||||||
|
width: 95%;
|
||||||
|
max-height: 85vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-modal .modal-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== TAB FILTER BAR ===== */
|
||||||
|
.inventory-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
background: var(--bg-tertiary, #16161a);
|
||||||
|
border-bottom: 1px solid var(--play-border, #3a3a45);
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-tabs .tab {
|
||||||
|
min-height: var(--touch-target-min);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-secondary, #a0a0a8);
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-tabs .tab:hover {
|
||||||
|
color: var(--text-primary, #e5e5e5);
|
||||||
|
background: var(--item-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-tabs .tab.active {
|
||||||
|
color: var(--accent-gold, #f3a61a);
|
||||||
|
border-bottom-color: var(--accent-gold, #f3a61a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== INVENTORY CONTENT LAYOUT ===== */
|
||||||
|
.inventory-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-grid-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== ITEM GRID ===== */
|
||||||
|
.inventory-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--touch-spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive grid columns */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.inventory-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.inventory-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== INVENTORY ITEM CARD ===== */
|
||||||
|
.inventory-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
min-height: 96px;
|
||||||
|
min-width: 80px;
|
||||||
|
background: var(--item-bg);
|
||||||
|
border: 2px solid var(--item-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-item:hover,
|
||||||
|
.inventory-item:focus {
|
||||||
|
background: var(--item-hover-bg);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-item:focus {
|
||||||
|
outline: 2px solid var(--accent-gold, #f3a61a);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-item.selected {
|
||||||
|
border-color: var(--accent-gold, #f3a61a);
|
||||||
|
box-shadow: 0 0 12px rgba(243, 166, 26, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rarity border colors */
|
||||||
|
.inventory-item.rarity-common { border-color: var(--rarity-common); }
|
||||||
|
.inventory-item.rarity-uncommon { border-color: var(--rarity-uncommon); }
|
||||||
|
.inventory-item.rarity-rare { border-color: var(--rarity-rare); }
|
||||||
|
.inventory-item.rarity-epic { border-color: var(--rarity-epic); }
|
||||||
|
.inventory-item.rarity-legendary {
|
||||||
|
border-color: var(--rarity-legendary);
|
||||||
|
box-shadow: 0 0 8px rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item icon */
|
||||||
|
.inventory-item img {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item name */
|
||||||
|
.inventory-item .item-name {
|
||||||
|
font-size: var(--text-xs, 0.75rem);
|
||||||
|
color: var(--text-primary, #e5e5e5);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item quantity badge */
|
||||||
|
.inventory-item .item-quantity {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: var(--bg-tertiary, #16161a);
|
||||||
|
border: 1px solid var(--item-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e5e5e5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.inventory-empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-muted, #707078);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== ITEM DETAIL PANEL ===== */
|
||||||
|
.item-detail {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 280px;
|
||||||
|
background: var(--bg-tertiary, #16161a);
|
||||||
|
border: 1px solid var(--play-border, #3a3a45);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-detail-empty {
|
||||||
|
color: var(--text-muted, #707078);
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-detail-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--play-border, #3a3a45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-detail-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-detail-title h3 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: var(--text-lg, 1.125rem);
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-detail-title .item-type {
|
||||||
|
font-size: var(--text-xs, 0.75rem);
|
||||||
|
color: var(--text-muted, #707078);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rarity text colors */
|
||||||
|
.rarity-text-common { color: var(--rarity-common); }
|
||||||
|
.rarity-text-uncommon { color: var(--rarity-uncommon); }
|
||||||
|
.rarity-text-rare { color: var(--rarity-rare); }
|
||||||
|
.rarity-text-epic { color: var(--rarity-epic); }
|
||||||
|
.rarity-text-legendary { color: var(--rarity-legendary); }
|
||||||
|
|
||||||
|
.item-description {
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
color: var(--text-secondary, #a0a0a8);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item stats */
|
||||||
|
.item-stats {
|
||||||
|
background: var(--item-bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-stats div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-stats div:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--item-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item action buttons */
|
||||||
|
.item-actions {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions .action-btn {
|
||||||
|
min-height: var(--touch-target-primary);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions .action-btn--primary {
|
||||||
|
background: var(--accent-gold, #f3a61a);
|
||||||
|
color: var(--bg-primary, #0a0a0c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions .action-btn--primary:hover {
|
||||||
|
background: var(--accent-gold-hover, #e69500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions .action-btn--secondary {
|
||||||
|
background: var(--bg-input, #1e1e24);
|
||||||
|
border: 1px solid var(--play-border, #3a3a45);
|
||||||
|
color: var(--text-primary, #e5e5e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions .action-btn--secondary:hover {
|
||||||
|
background: var(--item-hover-bg);
|
||||||
|
border-color: var(--text-muted, #707078);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions .action-btn--danger {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions .action-btn--danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MODAL FOOTER ===== */
|
||||||
|
.inventory-modal .modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold-display {
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
color: var(--accent-gold, #f3a61a);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold-display::before {
|
||||||
|
content: "coins ";
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== COMBAT ITEMS BOTTOM SHEET ===== */
|
||||||
|
.combat-items-sheet {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 70vh;
|
||||||
|
background: var(--bg-secondary, #12121a);
|
||||||
|
border: 2px solid var(--border-ornate, #f3a61a);
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
z-index: 1001;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-items-sheet.open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sheet backdrop */
|
||||||
|
.sheet-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag handle */
|
||||||
|
.sheet-handle {
|
||||||
|
width: 40px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--text-muted, #707078);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 8px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sheet header */
|
||||||
|
.sheet-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--play-border, #3a3a45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-header h3 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: var(--text-lg, 1.125rem);
|
||||||
|
color: var(--accent-gold, #f3a61a);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-close {
|
||||||
|
width: var(--touch-target-min);
|
||||||
|
height: var(--touch-target-min);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted, #707078);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-close:hover {
|
||||||
|
color: var(--text-primary, #e5e5e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sheet body */
|
||||||
|
.sheet-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Combat items grid - larger items for combat */
|
||||||
|
.combat-items-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: var(--touch-spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
min-height: 120px;
|
||||||
|
background: var(--item-bg);
|
||||||
|
border: 2px solid var(--rarity-common);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-item:hover,
|
||||||
|
.combat-item:focus {
|
||||||
|
background: var(--item-hover-bg);
|
||||||
|
border-color: var(--accent-gold, #f3a61a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-item:focus {
|
||||||
|
outline: 2px solid var(--accent-gold, #f3a61a);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-item.selected {
|
||||||
|
border-color: var(--accent-gold, #f3a61a);
|
||||||
|
box-shadow: 0 0 12px rgba(243, 166, 26, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-item img {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-item .item-name {
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
color: var(--text-primary, #e5e5e5);
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-item .item-effect {
|
||||||
|
font-size: var(--text-xs, 0.75rem);
|
||||||
|
color: var(--text-muted, #707078);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Combat item detail section */
|
||||||
|
.combat-item-detail {
|
||||||
|
background: var(--bg-tertiary, #16161a);
|
||||||
|
border: 1px solid var(--play-border, #3a3a45);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-item-detail .detail-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-item-detail .detail-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e5e5e5);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-item-detail .detail-effect {
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
color: var(--text-secondary, #a0a0a8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-item-detail .use-btn {
|
||||||
|
min-width: 100px;
|
||||||
|
min-height: var(--touch-target-primary);
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--hp-bar-fill, #ef4444);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-item-detail .use-btn:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No consumables message */
|
||||||
|
.no-consumables {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-muted, #707078);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MOBILE RESPONSIVENESS ===== */
|
||||||
|
|
||||||
|
/* Full-screen modal on mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.inventory-modal {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-modal .modal-body {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item detail slides in from right on mobile */
|
||||||
|
.item-detail {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
min-width: unset;
|
||||||
|
z-index: 1002;
|
||||||
|
border-radius: 0;
|
||||||
|
border-left: 2px solid var(--border-ornate, #f3a61a);
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-detail.visible {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-detail-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Back button for mobile detail view */
|
||||||
|
.item-detail-back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin: -1rem -1rem 1rem -1rem;
|
||||||
|
background: var(--bg-secondary, #12121a);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--play-border, #3a3a45);
|
||||||
|
color: var(--accent-gold, #f3a61a);
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
cursor: pointer;
|
||||||
|
width: calc(100% + 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-detail-back:hover {
|
||||||
|
background: var(--item-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons fixed at bottom on mobile */
|
||||||
|
.item-actions {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg-tertiary, #16161a);
|
||||||
|
padding: 1rem;
|
||||||
|
margin: auto -1rem -1rem -1rem;
|
||||||
|
border-top: 1px solid var(--play-border, #3a3a45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger touch targets on mobile */
|
||||||
|
.inventory-item {
|
||||||
|
min-height: 88px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs scroll horizontally on mobile */
|
||||||
|
.inventory-tabs {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-tabs .tab {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Combat sheet takes more space on mobile */
|
||||||
|
.combat-items-sheet {
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-items-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-item {
|
||||||
|
min-height: 100px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extra small screens */
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.inventory-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-item {
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-item img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== LOADING STATE ===== */
|
||||||
|
.inventory-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-muted, #707078);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-loading::after {
|
||||||
|
content: "";
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
border: 2px solid var(--text-muted, #707078);
|
||||||
|
border-top-color: var(--accent-gold, #f3a61a);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== ACCESSIBILITY ===== */
|
||||||
|
|
||||||
|
/* Focus visible for keyboard navigation */
|
||||||
|
.inventory-item:focus-visible,
|
||||||
|
.combat-item:focus-visible,
|
||||||
|
.inventory-tabs .tab:focus-visible,
|
||||||
|
.action-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-gold, #f3a61a);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.inventory-item,
|
||||||
|
.combat-item,
|
||||||
|
.combat-items-sheet,
|
||||||
|
.item-detail {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-loading::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1119,6 +1119,161 @@
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Monster Selection Modal */
|
||||||
|
.monster-modal {
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monster-modal-location {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monster-modal-hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-option {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--play-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
border-left: 4px solid var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-option:hover {
|
||||||
|
transform: translateX(4px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Challenge level border colors */
|
||||||
|
.encounter-option--easy {
|
||||||
|
border-left-color: #2ecc71; /* Green for easy */
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-option--easy:hover {
|
||||||
|
border-color: #2ecc71;
|
||||||
|
background: rgba(46, 204, 113, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-option--medium {
|
||||||
|
border-left-color: #f39c12; /* Gold/orange for medium */
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-option--medium:hover {
|
||||||
|
border-color: #f39c12;
|
||||||
|
background: rgba(243, 156, 18, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-option--hard {
|
||||||
|
border-left-color: #e74c3c; /* Red for hard */
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-option--hard:hover {
|
||||||
|
border-color: #e74c3c;
|
||||||
|
background: rgba(231, 76, 60, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-option--boss {
|
||||||
|
border-left-color: #9b59b6; /* Purple for boss */
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-option--boss:hover {
|
||||||
|
border-color: #9b59b6;
|
||||||
|
background: rgba(155, 89, 182, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-enemies {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-badge {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-challenge {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge--easy {
|
||||||
|
color: #2ecc71;
|
||||||
|
background: rgba(46, 204, 113, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge--medium {
|
||||||
|
color: #f39c12;
|
||||||
|
background: rgba(243, 156, 18, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge--hard {
|
||||||
|
color: #e74c3c;
|
||||||
|
background: rgba(231, 76, 60, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge--boss {
|
||||||
|
color: #9b59b6;
|
||||||
|
background: rgba(155, 89, 182, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-empty p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Combat action button highlight */
|
||||||
|
.action-btn--combat {
|
||||||
|
background: linear-gradient(135deg, rgba(231, 76, 60, 0.2), rgba(155, 89, 182, 0.2));
|
||||||
|
border-color: rgba(231, 76, 60, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--combat:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(231, 76, 60, 0.3), rgba(155, 89, 182, 0.3));
|
||||||
|
border-color: rgba(231, 76, 60, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
/* NPC Chat Modal */
|
/* NPC Chat Modal */
|
||||||
.npc-chat-header {
|
.npc-chat-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
7
public_web/static/img/items/armor.svg
Normal file
7
public_web/static/img/items/armor.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Shield shape -->
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||||
|
<!-- Shield decoration -->
|
||||||
|
<path d="M12 8v6"/>
|
||||||
|
<path d="M9 11h6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 321 B |
14
public_web/static/img/items/consumable.svg
Normal file
14
public_web/static/img/items/consumable.svg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Potion bottle body -->
|
||||||
|
<path d="M10 2v4"/>
|
||||||
|
<path d="M14 2v4"/>
|
||||||
|
<!-- Bottle neck -->
|
||||||
|
<path d="M8 6h8"/>
|
||||||
|
<!-- Bottle shape -->
|
||||||
|
<path d="M8 6l-2 4v10a2 2 0 002 2h8a2 2 0 002-2V10l-2-4"/>
|
||||||
|
<!-- Liquid level -->
|
||||||
|
<path d="M6 14h12"/>
|
||||||
|
<!-- Bubbles -->
|
||||||
|
<circle cx="10" cy="17" r="1"/>
|
||||||
|
<circle cx="14" cy="16" r="0.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 505 B |
7
public_web/static/img/items/default.svg
Normal file
7
public_web/static/img/items/default.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Box/crate shape -->
|
||||||
|
<path d="M21 8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16V8z"/>
|
||||||
|
<!-- Box edges -->
|
||||||
|
<path d="M3.27 6.96L12 12.01l8.73-5.05"/>
|
||||||
|
<path d="M12 22.08V12"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 410 B |
12
public_web/static/img/items/quest_item.svg
Normal file
12
public_web/static/img/items/quest_item.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Scroll body -->
|
||||||
|
<path d="M4 4a2 2 0 012-2h12a2 2 0 012 2v16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"/>
|
||||||
|
<!-- Scroll roll top -->
|
||||||
|
<path d="M4 4h16"/>
|
||||||
|
<ellipse cx="4" cy="4" rx="1" ry="2"/>
|
||||||
|
<ellipse cx="20" cy="4" rx="1" ry="2"/>
|
||||||
|
<!-- Text lines -->
|
||||||
|
<path d="M8 9h8"/>
|
||||||
|
<path d="M8 13h6"/>
|
||||||
|
<path d="M8 17h4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 488 B |
10
public_web/static/img/items/weapon.svg
Normal file
10
public_web/static/img/items/weapon.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Sword blade -->
|
||||||
|
<path d="M14.5 17.5L3 6V3h3l11.5 11.5"/>
|
||||||
|
<!-- Sword guard -->
|
||||||
|
<path d="M13 19l6-6"/>
|
||||||
|
<!-- Sword handle -->
|
||||||
|
<path d="M16 16l4 4"/>
|
||||||
|
<!-- Blade tip detail -->
|
||||||
|
<path d="M19 21l2-2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 382 B |
@@ -81,6 +81,10 @@
|
|||||||
<span class="stat-name">CHA</span>
|
<span class="stat-name">CHA</span>
|
||||||
<span class="stat-value">{{ character.base_stats.charisma }}</span>
|
<span class="stat-value">{{ character.base_stats.charisma }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-name">LUK</span>
|
||||||
|
<span class="stat-value">{{ character.base_stats.luck }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Derived Stats -->
|
<!-- Derived Stats -->
|
||||||
|
|||||||
337
public_web/templates/dev/combat.html
Normal file
337
public_web/templates/dev/combat.html
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Combat Tester - Dev Tools{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.dev-banner {
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-hub {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-section {
|
||||||
|
background: rgba(30, 30, 40, 0.9);
|
||||||
|
border: 1px solid #4a4a5a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-section h2 {
|
||||||
|
color: #f59e0b;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
border-bottom: 1px solid #4a4a5a;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #1a1a2a;
|
||||||
|
border: 1px solid #4a4a5a;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #2a2a3a;
|
||||||
|
border: 1px solid #4a4a5a;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-option:hover {
|
||||||
|
background: #3a3a4a;
|
||||||
|
border-color: #5a5a6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-option.selected {
|
||||||
|
background: #3b3b5b;
|
||||||
|
border-color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-option input[type="checkbox"] {
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-name {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-level {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start:hover {
|
||||||
|
background: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start:disabled {
|
||||||
|
background: #4a4a5a;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#create-result {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #2a2a3a;
|
||||||
|
border: 1px solid #4a4a5a;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-id {
|
||||||
|
color: #f59e0b;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-character {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status {
|
||||||
|
color: #10b981;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-resume {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-resume:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
color: #6b7280;
|
||||||
|
padding: 2rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #7f1d1d;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background: #064e3b;
|
||||||
|
color: #a7f3d0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #60a5fa;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper-text {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="dev-banner">
|
||||||
|
DEV MODE - Combat System Tester
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="combat-hub">
|
||||||
|
<a href="{{ url_for('dev.index') }}" class="back-link">← Back to Dev Tools</a>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Start New Combat -->
|
||||||
|
<div class="dev-section">
|
||||||
|
<h2>Start New Combat</h2>
|
||||||
|
|
||||||
|
<form hx-post="{{ url_for('dev.start_combat') }}"
|
||||||
|
hx-target="#create-result"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
|
||||||
|
<!-- Session Selection -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Select Session (must have a character)</label>
|
||||||
|
<select name="session_id" class="form-select" required>
|
||||||
|
<option value="">-- Select a session --</option>
|
||||||
|
{% for char in characters %}
|
||||||
|
<option value="{{ char.session_id if char.session_id else '' }}"
|
||||||
|
{% if not char.session_id %}disabled{% endif %}>
|
||||||
|
{{ char.name }} ({{ char.class_name }} Lv.{{ char.level }})
|
||||||
|
{% if not char.session_id %} - No active session{% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="helper-text">You need an active story session to start combat. Create one in the Story Tester first.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enemy Selection -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Select Enemies (check multiple for group encounter)</label>
|
||||||
|
{% if enemies %}
|
||||||
|
<div class="enemy-grid">
|
||||||
|
{% for enemy in enemies %}
|
||||||
|
<label class="enemy-option" onclick="this.classList.toggle('selected')">
|
||||||
|
<input type="checkbox" name="enemy_ids" value="{{ enemy.enemy_id }}">
|
||||||
|
<div class="enemy-info">
|
||||||
|
<div class="enemy-name">{{ enemy.name }}</div>
|
||||||
|
<div class="enemy-level">{{ enemy.difficulty | capitalize }} · {{ enemy.experience_reward }} XP</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
No enemy templates available. Check that the API has enemy data loaded.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-start" {% if not enemies %}disabled{% endif %}>
|
||||||
|
Start Combat
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="create-result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Combat Sessions -->
|
||||||
|
<div class="dev-section">
|
||||||
|
<h2>Active Combat Sessions</h2>
|
||||||
|
|
||||||
|
{% if sessions_in_combat %}
|
||||||
|
<div class="session-list">
|
||||||
|
{% for session in sessions_in_combat %}
|
||||||
|
<div class="session-card">
|
||||||
|
<div class="session-info">
|
||||||
|
<div class="session-id">{{ session.session_id[:12] }}...</div>
|
||||||
|
<div class="session-character">{{ session.character_name or 'Unknown Character' }}</div>
|
||||||
|
<div class="session-status">In Combat - Round {{ session.game_state.combat_round or 1 }}</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('dev.combat_session', session_id=session.session_id) }}" class="btn-resume">
|
||||||
|
Resume Combat
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
No active combat sessions. Start a new combat above.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Toggle selected state on checkbox change
|
||||||
|
document.querySelectorAll('.enemy-option input[type="checkbox"]').forEach(checkbox => {
|
||||||
|
checkbox.addEventListener('change', function() {
|
||||||
|
this.closest('.enemy-option').classList.toggle('selected', this.checked);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
864
public_web/templates/dev/combat_session.html
Normal file
864
public_web/templates/dev/combat_session.html
Normal file
@@ -0,0 +1,864 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Combat Debug - Dev Tools{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.dev-banner {
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 1rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr 300px;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.combat-container {
|
||||||
|
grid-template-columns: 250px 1fr;
|
||||||
|
}
|
||||||
|
.right-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.combat-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.left-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: rgba(30, 30, 40, 0.9);
|
||||||
|
border: 1px solid #4a4a5a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h3 {
|
||||||
|
color: #f59e0b;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-bottom: 1px solid #4a4a5a;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh {
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left Panel - State */
|
||||||
|
.state-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-section h4 {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-item {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-label {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-value {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combatant-card {
|
||||||
|
background: #2a2a3a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-left: 3px solid #4a4a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combatant-card.player {
|
||||||
|
border-left-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combatant-card.enemy {
|
||||||
|
border-left-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combatant-card.active {
|
||||||
|
box-shadow: 0 0 0 2px #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combatant-card.defeated {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combatant-name {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: #1a1a2a;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-bar-fill.hp {
|
||||||
|
background: linear-gradient(90deg, #ef4444, #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-bar-fill.mp {
|
||||||
|
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-bar-fill.low {
|
||||||
|
background: linear-gradient(90deg, #dc2626, #ef4444);
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-text {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Debug Actions */
|
||||||
|
.debug-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #4a4a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border: 1px solid #4a4a5a;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-btn.victory {
|
||||||
|
background: #064e3b;
|
||||||
|
color: #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-btn.victory:hover {
|
||||||
|
background: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-btn.defeat {
|
||||||
|
background: #7f1d1d;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-btn.defeat:hover {
|
||||||
|
background: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-btn.reset {
|
||||||
|
background: #1e40af;
|
||||||
|
color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-btn.reset:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Center Panel - Main */
|
||||||
|
.main-panel {
|
||||||
|
min-height: 600px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#combat-log {
|
||||||
|
flex: 1;
|
||||||
|
background: #1a1a2a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
min-height: 300px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry--player {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
border-left: 3px solid #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry--enemy {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border-left: 3px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry--crit {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
border-left: 3px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry--system {
|
||||||
|
background: rgba(107, 114, 128, 0.15);
|
||||||
|
border-left: 3px solid #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry--heal {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
border-left: 3px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-actor {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-message {
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-damage {
|
||||||
|
color: #ef4444;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-heal {
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-crit {
|
||||||
|
color: #f59e0b;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Buttons */
|
||||||
|
.actions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.actions-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.attack {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.action-btn.attack:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.ability {
|
||||||
|
background: #8b5cf6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.action-btn.ability:hover:not(:disabled) {
|
||||||
|
background: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.item {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.action-btn.item:hover:not(:disabled) {
|
||||||
|
background: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.defend {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.action-btn.defend:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.flee {
|
||||||
|
background: #6b7280;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.action-btn.flee:hover:not(:disabled) {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right Panel */
|
||||||
|
.turn-order {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
background: #2a2a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-item.active {
|
||||||
|
background: #3b3b5b;
|
||||||
|
border: 1px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-number {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: #4a4a5a;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-item.active .turn-number {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #1a1a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-name {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-name.player {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-name.enemy {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Effects Panel */
|
||||||
|
.effects-panel {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
background: #2a2a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-name {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-duration {
|
||||||
|
color: #f59e0b;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Debug Panel */
|
||||||
|
.debug-panel {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #1a1a2a;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-toggle {
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-content {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre;
|
||||||
|
color: #a3e635;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: #1a1a2a;
|
||||||
|
border: 1px solid #4a4a5a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h3 {
|
||||||
|
color: #f59e0b;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #6b7280;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sheet Styles */
|
||||||
|
.combat-items-sheet {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #1a1a2a;
|
||||||
|
border-top: 1px solid #4a4a5a;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
padding: 1rem;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1000;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-items-sheet.open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-header h3 {
|
||||||
|
color: #f59e0b;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #7f1d1d;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background: #064e3b;
|
||||||
|
color: #a7f3d0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: #60a5fa;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="dev-banner">
|
||||||
|
DEV MODE - Combat Session {{ session_id[:8] }}...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="combat-container">
|
||||||
|
<!-- Left Panel: Combat State -->
|
||||||
|
<div class="panel left-panel">
|
||||||
|
<h3>
|
||||||
|
Combat State
|
||||||
|
<button class="btn-refresh"
|
||||||
|
hx-get="{{ url_for('dev.combat_state', session_id=session_id) }}"
|
||||||
|
hx-target="#state-content"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<div id="state-content">
|
||||||
|
{% include 'dev/partials/combat_state.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Debug Actions -->
|
||||||
|
<div class="debug-actions">
|
||||||
|
<h4 style="color: #f59e0b; font-size: 0.85rem; margin: 0 0 0.5rem 0;">Debug Actions</h4>
|
||||||
|
<button class="debug-btn reset"
|
||||||
|
hx-post="{{ url_for('dev.reset_hp_mp', session_id=session_id) }}"
|
||||||
|
hx-target="#combat-log"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
Reset HP/MP
|
||||||
|
</button>
|
||||||
|
<button class="debug-btn victory"
|
||||||
|
hx-post="{{ url_for('dev.force_end_combat', session_id=session_id) }}"
|
||||||
|
hx-vals='{"victory": "true"}'
|
||||||
|
hx-target="#combat-log"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Force Victory
|
||||||
|
</button>
|
||||||
|
<button class="debug-btn defeat"
|
||||||
|
hx-post="{{ url_for('dev.force_end_combat', session_id=session_id) }}"
|
||||||
|
hx-vals='{"victory": "false"}'
|
||||||
|
hx-target="#combat-log"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Force Defeat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #4a4a5a;">
|
||||||
|
<a href="{{ url_for('dev.combat_hub') }}" class="back-link">← Back to Combat Hub</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Center Panel: Combat Log & Actions -->
|
||||||
|
<div class="panel main-panel">
|
||||||
|
<h3>Combat Log</h3>
|
||||||
|
|
||||||
|
<!-- Combat Log -->
|
||||||
|
<div id="combat-log" role="log" aria-live="polite">
|
||||||
|
{% for entry in combat_log %}
|
||||||
|
<div class="log-entry log-entry--{{ entry.type }}">
|
||||||
|
{% if entry.actor %}
|
||||||
|
<span class="log-actor">{{ entry.actor }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="log-message">{{ entry.message }}</span>
|
||||||
|
{% if entry.damage %}
|
||||||
|
<span class="log-damage">-{{ entry.damage }} HP</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.heal %}
|
||||||
|
<span class="log-heal">+{{ entry.heal }} HP</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.is_crit %}
|
||||||
|
<span class="log-crit">CRITICAL!</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="log-entry log-entry--system">
|
||||||
|
Combat begins!
|
||||||
|
{% if is_player_turn %}
|
||||||
|
Take your action.
|
||||||
|
{% else %}
|
||||||
|
Waiting for enemy turn...
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="actions-grid" id="action-buttons">
|
||||||
|
<button class="action-btn attack"
|
||||||
|
hx-post="{{ url_for('dev.combat_action', session_id=session_id) }}"
|
||||||
|
hx-vals='{"action_type": "attack"}'
|
||||||
|
hx-target="#combat-log"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
hx-disabled-elt="this"
|
||||||
|
{% if not is_player_turn %}disabled{% endif %}>
|
||||||
|
Attack
|
||||||
|
</button>
|
||||||
|
<button class="action-btn ability"
|
||||||
|
hx-get="{{ url_for('dev.combat_abilities', session_id=session_id) }}"
|
||||||
|
hx-target="#modal-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
{% if not is_player_turn %}disabled{% endif %}>
|
||||||
|
Ability
|
||||||
|
</button>
|
||||||
|
<button class="action-btn item"
|
||||||
|
hx-get="{{ url_for('dev.combat_items', session_id=session_id) }}"
|
||||||
|
hx-target="#sheet-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
{% if not is_player_turn %}disabled{% endif %}>
|
||||||
|
Item
|
||||||
|
</button>
|
||||||
|
<button class="action-btn defend"
|
||||||
|
hx-post="{{ url_for('dev.combat_action', session_id=session_id) }}"
|
||||||
|
hx-vals='{"action_type": "defend"}'
|
||||||
|
hx-target="#combat-log"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
hx-disabled-elt="this"
|
||||||
|
{% if not is_player_turn %}disabled{% endif %}>
|
||||||
|
Defend
|
||||||
|
</button>
|
||||||
|
<button class="action-btn flee"
|
||||||
|
hx-post="{{ url_for('dev.combat_action', session_id=session_id) }}"
|
||||||
|
hx-vals='{"action_type": "flee"}'
|
||||||
|
hx-target="#combat-log"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
hx-disabled-elt="this"
|
||||||
|
{% if not is_player_turn %}disabled{% endif %}>
|
||||||
|
Flee
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Debug Panel -->
|
||||||
|
<div class="debug-panel">
|
||||||
|
<div class="debug-toggle" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">
|
||||||
|
[+] Raw State JSON (click to toggle)
|
||||||
|
</div>
|
||||||
|
<div class="debug-content" style="display: none;">{{ raw_state | tojson(indent=2) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Panel: Turn Order & Effects -->
|
||||||
|
<div class="panel right-panel">
|
||||||
|
<h3>Turn Order</h3>
|
||||||
|
|
||||||
|
<div class="turn-order">
|
||||||
|
{% for combatant_id in turn_order %}
|
||||||
|
{% set ns = namespace(combatant=None) %}
|
||||||
|
{% for c in encounter.combatants %}
|
||||||
|
{% if c.combatant_id == combatant_id %}
|
||||||
|
{% set ns.combatant = c %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="turn-item {% if combatant_id == current_turn_id %}active{% endif %}">
|
||||||
|
<span class="turn-number">{{ loop.index }}</span>
|
||||||
|
<span class="turn-name {% if ns.combatant and ns.combatant.is_player %}player{% else %}enemy{% endif %}">
|
||||||
|
{% if ns.combatant %}{{ ns.combatant.name }}{% else %}{{ combatant_id[:8] }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 1rem;">Active Effects</h3>
|
||||||
|
<div class="effects-panel">
|
||||||
|
{% if player_combatant and player_combatant.active_effects %}
|
||||||
|
{% for effect in player_combatant.active_effects %}
|
||||||
|
<div class="effect-item">
|
||||||
|
<span class="effect-name">{{ effect.name }}</span>
|
||||||
|
<span class="effect-duration">{{ effect.remaining_duration }} turns</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p style="color: #6b7280; font-size: 0.85rem; text-align: center;">No active effects</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Container -->
|
||||||
|
<div id="modal-container"></div>
|
||||||
|
|
||||||
|
<!-- Sheet Container -->
|
||||||
|
<div id="sheet-container"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Close modal function
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modal-container').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close combat sheet function
|
||||||
|
function closeCombatSheet() {
|
||||||
|
document.getElementById('sheet-container').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh combat state panel
|
||||||
|
function refreshCombatState() {
|
||||||
|
htmx.ajax('GET', '{{ url_for("dev.combat_state", session_id=session_id) }}', {
|
||||||
|
target: '#state-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll combat log
|
||||||
|
const combatLog = document.getElementById('combat-log');
|
||||||
|
if (combatLog) {
|
||||||
|
combatLog.scrollTop = combatLog.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe combat log for new entries and auto-scroll
|
||||||
|
const observer = new MutationObserver(function() {
|
||||||
|
combatLog.scrollTop = combatLog.scrollHeight;
|
||||||
|
});
|
||||||
|
observer.observe(combatLog, { childList: true });
|
||||||
|
|
||||||
|
// Guard against duplicate enemy turn requests
|
||||||
|
let enemyTurnPending = false;
|
||||||
|
let enemyTurnTimeout = null;
|
||||||
|
|
||||||
|
function triggerEnemyTurn(delay = 1000) {
|
||||||
|
// Prevent duplicate requests
|
||||||
|
if (enemyTurnPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any pending timeout
|
||||||
|
if (enemyTurnTimeout) {
|
||||||
|
clearTimeout(enemyTurnTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
enemyTurnPending = true;
|
||||||
|
enemyTurnTimeout = setTimeout(function() {
|
||||||
|
htmx.ajax('POST', '{{ url_for("dev.combat_enemy_turn", session_id=session_id) }}', {
|
||||||
|
target: '#combat-log',
|
||||||
|
swap: 'beforeend'
|
||||||
|
}).then(function() {
|
||||||
|
enemyTurnPending = false;
|
||||||
|
// Refresh state after enemy turn completes
|
||||||
|
setTimeout(refreshCombatState, 500);
|
||||||
|
}).catch(function() {
|
||||||
|
enemyTurnPending = false;
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-trigger enemy turn on page load if it's not the player's turn
|
||||||
|
{% if not is_player_turn %}
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Small delay to let the page render first
|
||||||
|
triggerEnemyTurn(500);
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
// Handle enemy turn trigger
|
||||||
|
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
// Check for enemyTurn trigger
|
||||||
|
const trigger = event.detail.xhr.getResponseHeader('HX-Trigger');
|
||||||
|
if (trigger && trigger.includes('enemyTurn')) {
|
||||||
|
triggerEnemyTurn(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh state after any combat action (player action, debug action, but NOT enemy turn - handled above)
|
||||||
|
const requestUrl = event.detail.pathInfo?.requestPath || '';
|
||||||
|
const isActionBtn = event.detail.elt && event.detail.elt.classList && event.detail.elt.classList.contains('action-btn');
|
||||||
|
const isDebugBtn = event.detail.elt && event.detail.elt.classList && event.detail.elt.classList.contains('debug-btn');
|
||||||
|
|
||||||
|
if (isActionBtn || isDebugBtn) {
|
||||||
|
setTimeout(refreshCombatState, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-enable buttons when player turn returns
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||||
|
// If state was updated, check if it's player turn
|
||||||
|
if (event.detail.target.id === 'state-content') {
|
||||||
|
const stateContent = document.getElementById('state-content');
|
||||||
|
const isPlayerTurn = stateContent && stateContent.textContent.includes('Your Turn');
|
||||||
|
const buttons = document.querySelectorAll('.action-btn');
|
||||||
|
buttons.forEach(function(btn) {
|
||||||
|
btn.disabled = !isPlayerTurn;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -83,6 +83,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="dev-section">
|
||||||
|
<h2>Combat System</h2>
|
||||||
|
<a href="{{ url_for('dev.combat_hub') }}" class="dev-link">
|
||||||
|
Combat System Tester
|
||||||
|
<small>Start encounters, test actions, abilities, items, and enemy AI</small>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="dev-section">
|
<div class="dev-section">
|
||||||
<h2>Quest System</h2>
|
<h2>Quest System</h2>
|
||||||
<span class="dev-link dev-link-disabled">
|
<span class="dev-link dev-link-disabled">
|
||||||
|
|||||||
62
public_web/templates/dev/partials/ability_modal.html
Normal file
62
public_web/templates/dev/partials/ability_modal.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<!-- Ability Selection Modal -->
|
||||||
|
|
||||||
|
<div class="modal-overlay" onclick="closeModal()">
|
||||||
|
<div class="modal-content" onclick="event.stopPropagation()">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<h3 style="margin: 0; color: #f59e0b;">Select Ability</h3>
|
||||||
|
<button onclick="closeModal()" style="background: none; border: none; color: #9ca3af; font-size: 1.5rem; cursor: pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if abilities %}
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
|
{% for ability in abilities %}
|
||||||
|
<button style="
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem;
|
||||||
|
background: {{ '#2a2a3a' if ability.available else '#1a1a2a' }};
|
||||||
|
border: 1px solid {{ '#4a4a5a' if ability.available else '#3a3a4a' }};
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: {{ 'pointer' if ability.available else 'not-allowed' }};
|
||||||
|
opacity: {{ '1' if ability.available else '0.5' }};
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.2s;
|
||||||
|
"
|
||||||
|
{% if ability.available %}
|
||||||
|
hx-post="{{ url_for('dev.combat_action', session_id=session_id) }}"
|
||||||
|
hx-vals='{"action_type": "ability", "ability_id": "{{ ability.id }}"}'
|
||||||
|
hx-target="#combat-log"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
onclick="closeModal()"
|
||||||
|
{% else %}
|
||||||
|
disabled
|
||||||
|
{% endif %}>
|
||||||
|
<div>
|
||||||
|
<div style="color: #e5e7eb; font-weight: 500;">{{ ability.name }}</div>
|
||||||
|
{% if ability.description %}
|
||||||
|
<div style="color: #9ca3af; font-size: 0.8rem; margin-top: 0.25rem;">{{ ability.description[:100] }}{% if ability.description|length > 100 %}...{% endif %}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div style="text-align: right;">
|
||||||
|
{% if ability.mp_cost > 0 %}
|
||||||
|
<div style="color: #60a5fa; font-size: 0.85rem;">{{ ability.mp_cost }} MP</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if ability.cooldown > 0 %}
|
||||||
|
<div style="color: #f59e0b; font-size: 0.75rem;">CD: {{ ability.cooldown }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="text-align: center; color: #6b7280; padding: 2rem;">
|
||||||
|
No abilities available.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button class="modal-close" onclick="closeModal()" style="width: 100%; margin-top: 1rem;">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
19
public_web/templates/dev/partials/combat_debug_log.html
Normal file
19
public_web/templates/dev/partials/combat_debug_log.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!-- Combat Debug Log Entry Partial - appended to combat log -->
|
||||||
|
|
||||||
|
{% for entry in combat_log %}
|
||||||
|
<div class="log-entry log-entry--{{ entry.type }}">
|
||||||
|
{% if entry.actor %}
|
||||||
|
<span class="log-actor">{{ entry.actor }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="log-message">{{ entry.message }}</span>
|
||||||
|
{% if entry.damage %}
|
||||||
|
<span class="log-damage">-{{ entry.damage }} HP</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.heal %}
|
||||||
|
<span class="log-heal">+{{ entry.heal }} HP</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.is_crit %}
|
||||||
|
<span class="log-crit">CRITICAL!</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
32
public_web/templates/dev/partials/combat_defeat.html
Normal file
32
public_web/templates/dev/partials/combat_defeat.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<!-- Combat Defeat Screen -->
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 2rem;">
|
||||||
|
<div style="font-size: 3rem; margin-bottom: 1rem;">💀</div>
|
||||||
|
<h2 style="color: #ef4444; margin-bottom: 1rem;">Defeat</h2>
|
||||||
|
<p style="color: #d1d5db; margin-bottom: 2rem;">You have been defeated in battle...</p>
|
||||||
|
|
||||||
|
<!-- Penalties -->
|
||||||
|
{% if gold_lost and gold_lost > 0 %}
|
||||||
|
<div style="background: rgba(127, 29, 29, 0.3); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem;">
|
||||||
|
<div style="color: #fecaca;">
|
||||||
|
<span style="color: #ef4444; font-weight: 600;">-{{ gold_lost }} gold</span> lost
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p style="color: #9ca3af; font-size: 0.9rem; margin-bottom: 2rem;">
|
||||||
|
Your progress has been saved. You can try again or return to town.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div style="display: flex; gap: 1rem; justify-content: center;">
|
||||||
|
<a href="{{ url_for('dev.combat_hub') }}"
|
||||||
|
style="padding: 0.75rem 1.5rem; background: #ef4444; color: white; border-radius: 6px; text-decoration: none;">
|
||||||
|
Try Again
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('dev.story_hub') }}"
|
||||||
|
style="padding: 0.75rem 1.5rem; background: #6b7280; color: white; border-radius: 6px; text-decoration: none;">
|
||||||
|
Return to Town
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
88
public_web/templates/dev/partials/combat_items_sheet.html
Normal file
88
public_web/templates/dev/partials/combat_items_sheet.html
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<!-- Combat Items Bottom Sheet -->
|
||||||
|
|
||||||
|
<div class="sheet-backdrop" onclick="closeCombatSheet()"></div>
|
||||||
|
<div class="combat-items-sheet open">
|
||||||
|
<div class="sheet-header">
|
||||||
|
<h3>Use Item</h3>
|
||||||
|
<button class="sheet-close" onclick="closeCombatSheet()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-body">
|
||||||
|
{% if has_consumables %}
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.75rem;">
|
||||||
|
{% for item in consumables %}
|
||||||
|
<button style="
|
||||||
|
padding: 1rem;
|
||||||
|
background: #2a2a3a;
|
||||||
|
border: 1px solid #4a4a5a;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.2s;
|
||||||
|
"
|
||||||
|
hx-get="{{ url_for('dev.combat_item_detail', session_id=session_id, item_id=item.item_id) }}"
|
||||||
|
hx-target="#item-detail"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div style="color: #e5e7eb; font-weight: 500; margin-bottom: 0.25rem;">{{ item.name }}</div>
|
||||||
|
<div style="color:
|
||||||
|
{% if item.rarity == 'uncommon' %}#10b981
|
||||||
|
{% elif item.rarity == 'rare' %}#3b82f6
|
||||||
|
{% elif item.rarity == 'epic' %}#a78bfa
|
||||||
|
{% elif item.rarity == 'legendary' %}#f59e0b
|
||||||
|
{% else %}#9ca3af{% endif %};
|
||||||
|
font-size: 0.75rem; text-transform: capitalize;">
|
||||||
|
{{ item.rarity }}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item Detail Panel -->
|
||||||
|
<div id="item-detail" style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #4a4a5a;">
|
||||||
|
<div style="text-align: center; color: #6b7280; font-size: 0.9rem;">
|
||||||
|
Select an item to see details
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="text-align: center; color: #6b7280; padding: 2rem;">
|
||||||
|
No consumable items in inventory.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.detail-info {
|
||||||
|
background: #1a1a2a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-name {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-effect {
|
||||||
|
color: #10b981;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-btn:hover {
|
||||||
|
background: #059669;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
84
public_web/templates/dev/partials/combat_state.html
Normal file
84
public_web/templates/dev/partials/combat_state.html
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<!-- Combat State Partial - refreshable via HTMX -->
|
||||||
|
|
||||||
|
<div class="state-section">
|
||||||
|
<h4>Encounter Info</h4>
|
||||||
|
<div class="state-item">
|
||||||
|
<div class="state-label">Round</div>
|
||||||
|
<div class="state-value">{{ encounter.round_number or 1 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-item">
|
||||||
|
<div class="state-label">Status</div>
|
||||||
|
<div class="state-value">{{ encounter.status or 'active' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-item">
|
||||||
|
<div class="state-label">Current Turn</div>
|
||||||
|
<div class="state-value">
|
||||||
|
{% if is_player_turn %}
|
||||||
|
<span style="color: #60a5fa;">Your Turn</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #f87171;">Enemy Turn</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player Card -->
|
||||||
|
{% if player_combatant %}
|
||||||
|
<div class="state-section">
|
||||||
|
<h4>Player</h4>
|
||||||
|
<div class="combatant-card player {% if player_combatant.combatant_id == current_turn_id %}active{% endif %} {% if player_combatant.current_hp <= 0 %}defeated{% endif %}">
|
||||||
|
<div class="combatant-name">{{ player_combatant.name }}</div>
|
||||||
|
|
||||||
|
<!-- HP Bar -->
|
||||||
|
{% set hp_percent = (player_combatant.current_hp / player_combatant.max_hp * 100) if player_combatant.max_hp > 0 else 0 %}
|
||||||
|
<div class="resource-bar">
|
||||||
|
<div class="resource-bar-fill hp {% if hp_percent < 25 %}low{% endif %}"
|
||||||
|
style="width: {{ hp_percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="resource-text">
|
||||||
|
<span>HP</span>
|
||||||
|
<span>{{ player_combatant.current_hp }}/{{ player_combatant.max_hp }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MP Bar -->
|
||||||
|
{% if player_combatant.max_mp and player_combatant.max_mp > 0 %}
|
||||||
|
{% set mp_percent = (player_combatant.current_mp / player_combatant.max_mp * 100) if player_combatant.max_mp > 0 else 0 %}
|
||||||
|
<div class="resource-bar" style="margin-top: 0.5rem;">
|
||||||
|
<div class="resource-bar-fill mp" style="width: {{ mp_percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="resource-text">
|
||||||
|
<span>MP</span>
|
||||||
|
<span>{{ player_combatant.current_mp }}/{{ player_combatant.max_mp }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Enemy Cards -->
|
||||||
|
{% if enemy_combatants %}
|
||||||
|
<div class="state-section">
|
||||||
|
<h4>Enemies ({{ enemy_combatants | length }})</h4>
|
||||||
|
{% for enemy in enemy_combatants %}
|
||||||
|
<div class="combatant-card enemy {% if enemy.combatant_id == current_turn_id %}active{% endif %} {% if enemy.current_hp <= 0 %}defeated{% endif %}">
|
||||||
|
<div class="combatant-name">
|
||||||
|
{{ enemy.name }}
|
||||||
|
{% if enemy.current_hp <= 0 %}
|
||||||
|
<span style="color: #6b7280; font-size: 0.75rem;">(Defeated)</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HP Bar -->
|
||||||
|
{% set enemy_hp_percent = (enemy.current_hp / enemy.max_hp * 100) if enemy.max_hp > 0 else 0 %}
|
||||||
|
<div class="resource-bar">
|
||||||
|
<div class="resource-bar-fill hp {% if enemy_hp_percent < 25 %}low{% endif %}"
|
||||||
|
style="width: {{ enemy_hp_percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="resource-text">
|
||||||
|
<span>HP</span>
|
||||||
|
<span>{{ enemy.current_hp }}/{{ enemy.max_hp }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
68
public_web/templates/dev/partials/combat_victory.html
Normal file
68
public_web/templates/dev/partials/combat_victory.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<!-- Combat Victory Screen -->
|
||||||
|
|
||||||
|
<div style="text-align: center; padding: 2rem;">
|
||||||
|
<div style="font-size: 3rem; margin-bottom: 1rem;">🏆</div>
|
||||||
|
<h2 style="color: #10b981; margin-bottom: 1rem;">Victory!</h2>
|
||||||
|
<p style="color: #d1d5db; margin-bottom: 2rem;">You have defeated your enemies!</p>
|
||||||
|
|
||||||
|
<!-- Rewards Section -->
|
||||||
|
{% if rewards %}
|
||||||
|
<div style="background: #2a2a3a; border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem; text-align: left;">
|
||||||
|
<h3 style="color: #f59e0b; margin-top: 0; margin-bottom: 1rem;">Rewards</h3>
|
||||||
|
|
||||||
|
{% if rewards.experience %}
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
|
||||||
|
<span style="color: #9ca3af;">Experience</span>
|
||||||
|
<span style="color: #a78bfa; font-weight: 600;">+{{ rewards.experience }} XP</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if rewards.gold %}
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
|
||||||
|
<span style="color: #9ca3af;">Gold</span>
|
||||||
|
<span style="color: #fbbf24; font-weight: 600;">+{{ rewards.gold }} gold</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if rewards.level_ups %}
|
||||||
|
<div style="background: rgba(168, 85, 247, 0.2); border-radius: 6px; padding: 1rem; margin-top: 1rem;">
|
||||||
|
<div style="color: #a78bfa; font-weight: 600;">Level Up!</div>
|
||||||
|
<div style="color: #d1d5db; font-size: 0.9rem;">You have reached a new level!</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if rewards.items %}
|
||||||
|
<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #4a4a5a;">
|
||||||
|
<div style="color: #9ca3af; font-size: 0.85rem; margin-bottom: 0.5rem;">Loot Obtained:</div>
|
||||||
|
{% for item in rewards.items %}
|
||||||
|
<div style="display: flex; align-items: center; padding: 0.5rem; background: #1a1a2a; border-radius: 4px; margin-bottom: 0.25rem;">
|
||||||
|
<span style="color: #e5e7eb;">{{ item.name }}</span>
|
||||||
|
{% if item.rarity and item.rarity != 'common' %}
|
||||||
|
<span style="margin-left: 0.5rem; font-size: 0.75rem; color:
|
||||||
|
{% if item.rarity == 'uncommon' %}#10b981
|
||||||
|
{% elif item.rarity == 'rare' %}#3b82f6
|
||||||
|
{% elif item.rarity == 'epic' %}#a78bfa
|
||||||
|
{% elif item.rarity == 'legendary' %}#f59e0b
|
||||||
|
{% else %}#9ca3af{% endif %};">
|
||||||
|
({{ item.rarity }})
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div style="display: flex; gap: 1rem; justify-content: center;">
|
||||||
|
<a href="{{ url_for('dev.combat_hub') }}"
|
||||||
|
style="padding: 0.75rem 1.5rem; background: #3b82f6; color: white; border-radius: 6px; text-decoration: none;">
|
||||||
|
Back to Combat Hub
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('dev.story_hub') }}"
|
||||||
|
style="padding: 0.75rem 1.5rem; background: #6b7280; color: white; border-radius: 6px; text-decoration: none;">
|
||||||
|
Continue Adventure
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
296
public_web/templates/game/combat.html
Normal file
296
public_web/templates/game/combat.html
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Combat - Code of Conquest{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/combat.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/inventory.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="combat-page">
|
||||||
|
<div class="combat-container">
|
||||||
|
{# ===== COMBAT HEADER ===== #}
|
||||||
|
<header class="combat-header">
|
||||||
|
<h1 class="combat-title">
|
||||||
|
<span class="combat-title-icon">⚔</span>
|
||||||
|
Combat Encounter
|
||||||
|
</h1>
|
||||||
|
<div class="combat-round">
|
||||||
|
<span class="round-counter">Round <strong>{{ encounter.round_number }}</strong></span>
|
||||||
|
{% if is_player_turn %}
|
||||||
|
<span class="turn-indicator turn-indicator--player">Your Turn</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="turn-indicator turn-indicator--enemy">Enemy Turn</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{# ===== LEFT COLUMN: COMBATANTS ===== #}
|
||||||
|
<aside class="combatant-panel">
|
||||||
|
{# Player Section #}
|
||||||
|
<div class="combatant-section">
|
||||||
|
<h2 class="combatant-section-title">Your Party</h2>
|
||||||
|
{% for combatant in encounter.combatants if combatant.is_player %}
|
||||||
|
<div class="combatant-card combatant-card--player {% if combatant.combatant_id == current_turn_id %}combatant-card--active{% endif %} {% if combatant.current_hp <= 0 %}combatant-card--defeated{% endif %}">
|
||||||
|
<div class="combatant-header">
|
||||||
|
<span class="combatant-name">{{ combatant.name }}</span>
|
||||||
|
<span class="combatant-level">Lv.{{ combatant.level|default(1) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="combatant-resources">
|
||||||
|
{% set hp_percent = ((combatant.current_hp / combatant.max_hp) * 100)|round|int %}
|
||||||
|
<div class="resource-bar resource-bar--hp {% if hp_percent < 25 %}low{% endif %}">
|
||||||
|
<div class="resource-bar-label">
|
||||||
|
<span class="resource-bar-name">HP</span>
|
||||||
|
<span class="resource-bar-value">{{ combatant.current_hp }} / {{ combatant.max_hp }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="resource-bar-track">
|
||||||
|
<div class="resource-bar-fill" style="width: {{ hp_percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set mp_percent = ((combatant.current_mp / combatant.max_mp) * 100)|round|int if combatant.max_mp > 0 else 0 %}
|
||||||
|
<div class="resource-bar resource-bar--mp">
|
||||||
|
<div class="resource-bar-label">
|
||||||
|
<span class="resource-bar-name">MP</span>
|
||||||
|
<span class="resource-bar-value">{{ combatant.current_mp }} / {{ combatant.max_mp }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="resource-bar-track">
|
||||||
|
<div class="resource-bar-fill" style="width: {{ mp_percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Enemies Section #}
|
||||||
|
<div class="combatant-section">
|
||||||
|
<h2 class="combatant-section-title">Enemies</h2>
|
||||||
|
{% for combatant in encounter.combatants if not combatant.is_player %}
|
||||||
|
<div class="combatant-card combatant-card--enemy {% if combatant.combatant_id == current_turn_id %}combatant-card--active{% endif %} {% if combatant.current_hp <= 0 %}combatant-card--defeated{% endif %}">
|
||||||
|
<div class="combatant-header">
|
||||||
|
<span class="combatant-name">{{ combatant.name }}</span>
|
||||||
|
<span class="combatant-level">{% if combatant.current_hp <= 0 %}Defeated{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="combatant-resources">
|
||||||
|
{% set hp_percent = ((combatant.current_hp / combatant.max_hp) * 100)|round|int %}
|
||||||
|
<div class="resource-bar resource-bar--hp {% if hp_percent < 25 %}low{% endif %}">
|
||||||
|
<div class="resource-bar-label">
|
||||||
|
<span class="resource-bar-name">HP</span>
|
||||||
|
<span class="resource-bar-value">{{ combatant.current_hp }} / {{ combatant.max_hp }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="resource-bar-track">
|
||||||
|
<div class="resource-bar-fill" style="width: {{ hp_percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{# ===== CENTER COLUMN: COMBAT LOG + ACTIONS ===== #}
|
||||||
|
<main class="combat-main">
|
||||||
|
{# Combat Log #}
|
||||||
|
<div id="combat-log" class="combat-log" role="log" aria-live="polite" aria-label="Combat log">
|
||||||
|
{% include "game/partials/combat_log.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Combat Actions #}
|
||||||
|
<div id="combat-actions" class="combat-actions">
|
||||||
|
{% include "game/partials/combat_actions.html" %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{# ===== RIGHT COLUMN: TURN ORDER + EFFECTS ===== #}
|
||||||
|
<aside class="combat-sidebar">
|
||||||
|
{# Turn Order #}
|
||||||
|
<div class="turn-order">
|
||||||
|
<h2 class="turn-order__title">Turn Order</h2>
|
||||||
|
<div class="turn-order__list">
|
||||||
|
{% for combatant_id in encounter.turn_order %}
|
||||||
|
{% set combatant = encounter.combatants|selectattr('combatant_id', 'equalto', combatant_id)|first %}
|
||||||
|
{% if combatant %}
|
||||||
|
<div class="turn-order__item {% if combatant.is_player %}turn-order__item--player{% else %}turn-order__item--enemy{% endif %} {% if combatant_id == current_turn_id %}turn-order__item--active{% endif %} {% if combatant.current_hp <= 0 %}turn-order__item--defeated{% endif %}">
|
||||||
|
<span class="turn-order__position">{{ loop.index }}</span>
|
||||||
|
<span class="turn-order__name">{{ combatant.name }}</span>
|
||||||
|
{% if combatant_id == current_turn_id %}
|
||||||
|
<span class="turn-order__check" title="Current turn">➤</span>
|
||||||
|
{% elif combatant.current_hp <= 0 %}
|
||||||
|
<span class="turn-order__check" title="Defeated">✗</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Active Effects #}
|
||||||
|
<div class="effects-panel">
|
||||||
|
<h2 class="effects-panel__title">Active Effects</h2>
|
||||||
|
{% if player_combatant and player_combatant.active_effects %}
|
||||||
|
<div class="effects-list">
|
||||||
|
{% for effect in player_combatant.active_effects %}
|
||||||
|
<div class="effect-item effect-item--{{ effect.effect_type|default('buff') }}">
|
||||||
|
<span class="effect-icon">
|
||||||
|
{% if effect.effect_type == 'shield' %}🛡
|
||||||
|
{% elif effect.effect_type == 'buff' %}⬆
|
||||||
|
{% elif effect.effect_type == 'debuff' %}⬇
|
||||||
|
{% elif effect.effect_type == 'dot' %}🔥
|
||||||
|
{% elif effect.effect_type == 'hot' %}❤
|
||||||
|
{% else %}★
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="effect-name">{{ effect.name }}</span>
|
||||||
|
<span class="effect-duration">{{ effect.duration }} {% if effect.duration == 1 %}turn{% else %}turns{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="effects-empty">No active effects</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Modal Container for Ability selection #}
|
||||||
|
<div id="modal-container"></div>
|
||||||
|
|
||||||
|
{# Combat Items Sheet Container #}
|
||||||
|
<div id="combat-sheet-container"></div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Auto-scroll combat log to bottom on new entries
|
||||||
|
function scrollCombatLog() {
|
||||||
|
const log = document.getElementById('combat-log');
|
||||||
|
if (log) {
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', scrollCombatLog);
|
||||||
|
|
||||||
|
// Scroll after HTMX swaps
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||||
|
if (event.detail.target.id === 'combat-log' ||
|
||||||
|
event.detail.target.closest('#combat-log')) {
|
||||||
|
scrollCombatLog();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal function
|
||||||
|
function closeModal() {
|
||||||
|
const container = document.getElementById('modal-container');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close combat items sheet
|
||||||
|
function closeCombatSheet() {
|
||||||
|
const container = document.getElementById('combat-sheet-container');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal/sheet on Escape key
|
||||||
|
document.addEventListener('keydown', function(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeModal();
|
||||||
|
closeCombatSheet();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enemy turn handling with proper chaining for multiple enemies
|
||||||
|
let enemyTurnPending = false;
|
||||||
|
|
||||||
|
function triggerEnemyTurn() {
|
||||||
|
// Prevent duplicate requests
|
||||||
|
if (enemyTurnPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enemyTurnPending = true;
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
// Use fetch instead of htmx.ajax for better control over response handling
|
||||||
|
fetch('{{ url_for("combat.combat_enemy_turn", session_id=session_id) }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HX-Request': 'true'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin'
|
||||||
|
})
|
||||||
|
.then(function(response) {
|
||||||
|
const hasMoreEnemies = response.headers.get('HX-Trigger')?.includes('enemyTurn');
|
||||||
|
return response.text().then(function(html) {
|
||||||
|
return { html: html, hasMoreEnemies: hasMoreEnemies };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
// Append the log entry
|
||||||
|
const combatLog = document.getElementById('combat-log');
|
||||||
|
if (combatLog) {
|
||||||
|
combatLog.insertAdjacentHTML('beforeend', data.html);
|
||||||
|
combatLog.scrollTop = combatLog.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
enemyTurnPending = false;
|
||||||
|
|
||||||
|
if (data.hasMoreEnemies) {
|
||||||
|
// More enemies to go - trigger next enemy turn
|
||||||
|
triggerEnemyTurn();
|
||||||
|
} else {
|
||||||
|
// All enemies done - refresh page to update UI
|
||||||
|
setTimeout(function() {
|
||||||
|
window.location.reload();
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(error) {
|
||||||
|
console.error('Enemy turn failed:', error);
|
||||||
|
enemyTurnPending = false;
|
||||||
|
// Refresh anyway to recover from error state
|
||||||
|
setTimeout(function() {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle player action triggering enemy turn
|
||||||
|
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
const response = event.detail.xhr;
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
const triggers = response.getResponseHeader('HX-Trigger') || '';
|
||||||
|
|
||||||
|
// Only trigger enemy turn from player actions (not from our fetch calls)
|
||||||
|
if (triggers.includes('enemyTurn') && !enemyTurnPending) {
|
||||||
|
triggerEnemyTurn();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle combat end redirect
|
||||||
|
document.body.addEventListener('htmx:beforeSwap', function(event) {
|
||||||
|
// If the response indicates combat ended, handle accordingly
|
||||||
|
const response = event.detail.xhr;
|
||||||
|
if (response && response.getResponseHeader('X-Combat-Ended')) {
|
||||||
|
// Let the full page swap happen for victory/defeat screen
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-trigger enemy turn on page load if it's not the player's turn
|
||||||
|
{% if not is_player_turn %}
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Small delay to let the page render first
|
||||||
|
triggerEnemyTurn();
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user