first commit
This commit is contained in:
302
api/app/api/game_mechanics.py
Normal file
302
api/app/api/game_mechanics.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Game Mechanics API Blueprint
|
||||
|
||||
This module provides API endpoints for game mechanics that determine
|
||||
outcomes before AI narration:
|
||||
- Skill checks (perception, persuasion, stealth, etc.)
|
||||
- Search/loot actions
|
||||
- Dice rolls
|
||||
|
||||
These endpoints return structured results that can be used for UI
|
||||
dice animations and then passed to AI for narrative description.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.outcome_service import outcome_service
|
||||
from app.services.character_service import get_character_service, CharacterNotFound
|
||||
from app.game_logic.dice import SkillType, Difficulty
|
||||
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
|
||||
game_mechanics_bp = Blueprint('game_mechanics', __name__, url_prefix='/api/v1/game')
|
||||
|
||||
|
||||
# Valid skill types for API validation
|
||||
VALID_SKILL_TYPES = [skill.name.lower() for skill in SkillType]
|
||||
|
||||
# Valid difficulty names
|
||||
VALID_DIFFICULTIES = ["trivial", "easy", "medium", "hard", "very_hard", "nearly_impossible"]
|
||||
|
||||
|
||||
@game_mechanics_bp.route('/check', methods=['POST'])
|
||||
@require_auth
|
||||
def perform_check():
|
||||
"""
|
||||
Perform a skill check or search action.
|
||||
|
||||
This endpoint determines the outcome of chance-based actions before
|
||||
they are passed to AI for narration. The result includes all dice
|
||||
roll details for UI display.
|
||||
|
||||
Request JSON:
|
||||
{
|
||||
"character_id": "...",
|
||||
"check_type": "search" | "skill",
|
||||
"skill": "perception", // Required for skill checks
|
||||
"dc": 15, // Optional, can use difficulty instead
|
||||
"difficulty": "medium", // Optional, alternative to dc
|
||||
"location_type": "forest", // For search checks
|
||||
"context": {} // Optional additional context
|
||||
}
|
||||
|
||||
Returns:
|
||||
For search checks:
|
||||
{
|
||||
"check_result": {
|
||||
"roll": 14,
|
||||
"modifier": 3,
|
||||
"total": 17,
|
||||
"dc": 15,
|
||||
"success": true,
|
||||
"margin": 2
|
||||
},
|
||||
"items_found": [...],
|
||||
"gold_found": 5
|
||||
}
|
||||
|
||||
For skill checks:
|
||||
{
|
||||
"check_result": {...},
|
||||
"context": {
|
||||
"skill_used": "persuasion",
|
||||
"stat_used": "charisma",
|
||||
...
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response(
|
||||
message="Request body is required",
|
||||
details={"field": "body", "issue": "Missing JSON body"}
|
||||
)
|
||||
|
||||
# Validate required fields
|
||||
character_id = data.get("character_id")
|
||||
check_type = data.get("check_type")
|
||||
|
||||
if not character_id:
|
||||
return validation_error_response(
|
||||
message="Character ID is required",
|
||||
details={"field": "character_id", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
if not check_type:
|
||||
return validation_error_response(
|
||||
message="Check type is required",
|
||||
details={"field": "check_type", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
if check_type not in ["search", "skill"]:
|
||||
return validation_error_response(
|
||||
message="Invalid check type",
|
||||
details={"field": "check_type", "issue": "Must be 'search' or 'skill'"}
|
||||
)
|
||||
|
||||
# Get character and verify ownership
|
||||
try:
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if character.user_id != user["user_id"]:
|
||||
return error_response(
|
||||
status_code=403,
|
||||
message="You don't have permission to access this character",
|
||||
error_code="FORBIDDEN"
|
||||
)
|
||||
except CharacterNotFound:
|
||||
return not_found_response(
|
||||
message=f"Character not found: {character_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("character_fetch_error", error=str(e), character_id=character_id)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to fetch character",
|
||||
error_code="CHARACTER_FETCH_ERROR"
|
||||
)
|
||||
|
||||
# Determine DC from difficulty name or direct value
|
||||
dc = data.get("dc")
|
||||
difficulty = data.get("difficulty")
|
||||
|
||||
if dc is None and difficulty:
|
||||
if difficulty.lower() not in VALID_DIFFICULTIES:
|
||||
return validation_error_response(
|
||||
message="Invalid difficulty",
|
||||
details={
|
||||
"field": "difficulty",
|
||||
"issue": f"Must be one of: {', '.join(VALID_DIFFICULTIES)}"
|
||||
}
|
||||
)
|
||||
dc = outcome_service.get_dc_for_difficulty(difficulty)
|
||||
elif dc is None:
|
||||
# Default to medium difficulty
|
||||
dc = Difficulty.MEDIUM.value
|
||||
|
||||
# Validate DC range
|
||||
if not isinstance(dc, int) or dc < 1 or dc > 35:
|
||||
return validation_error_response(
|
||||
message="Invalid DC value",
|
||||
details={"field": "dc", "issue": "DC must be an integer between 1 and 35"}
|
||||
)
|
||||
|
||||
# Get optional bonus
|
||||
bonus = data.get("bonus", 0)
|
||||
if not isinstance(bonus, int):
|
||||
bonus = 0
|
||||
|
||||
# Perform the check based on type
|
||||
try:
|
||||
if check_type == "search":
|
||||
# Search check uses perception
|
||||
location_type = data.get("location_type", "default")
|
||||
outcome = outcome_service.determine_search_outcome(
|
||||
character=character,
|
||||
location_type=location_type,
|
||||
dc=dc,
|
||||
bonus=bonus
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"search_check_performed",
|
||||
user_id=user["user_id"],
|
||||
character_id=character_id,
|
||||
location_type=location_type,
|
||||
success=outcome.check_result.success
|
||||
)
|
||||
|
||||
return success_response(result=outcome.to_dict())
|
||||
|
||||
else: # skill check
|
||||
skill = data.get("skill")
|
||||
if not skill:
|
||||
return validation_error_response(
|
||||
message="Skill is required for skill checks",
|
||||
details={"field": "skill", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
skill_lower = skill.lower()
|
||||
if skill_lower not in VALID_SKILL_TYPES:
|
||||
return validation_error_response(
|
||||
message="Invalid skill type",
|
||||
details={
|
||||
"field": "skill",
|
||||
"issue": f"Must be one of: {', '.join(VALID_SKILL_TYPES)}"
|
||||
}
|
||||
)
|
||||
|
||||
# Convert to SkillType enum
|
||||
skill_type = SkillType[skill.upper()]
|
||||
|
||||
# Get additional context
|
||||
context = data.get("context", {})
|
||||
|
||||
outcome = outcome_service.determine_skill_check_outcome(
|
||||
character=character,
|
||||
skill_type=skill_type,
|
||||
dc=dc,
|
||||
bonus=bonus,
|
||||
context=context
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"skill_check_performed",
|
||||
user_id=user["user_id"],
|
||||
character_id=character_id,
|
||||
skill=skill_lower,
|
||||
success=outcome.check_result.success
|
||||
)
|
||||
|
||||
return success_response(result=outcome.to_dict())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"check_error",
|
||||
error=str(e),
|
||||
check_type=check_type,
|
||||
character_id=character_id
|
||||
)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to perform check",
|
||||
error_code="CHECK_ERROR"
|
||||
)
|
||||
|
||||
|
||||
@game_mechanics_bp.route('/skills', methods=['GET'])
|
||||
def list_skills():
|
||||
"""
|
||||
List all available skill types.
|
||||
|
||||
Returns the skill types available for skill checks,
|
||||
along with their associated base stats.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"name": "perception",
|
||||
"stat": "wisdom",
|
||||
"description": "..."
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
skills = []
|
||||
for skill in SkillType:
|
||||
skills.append({
|
||||
"name": skill.name.lower(),
|
||||
"stat": skill.value,
|
||||
})
|
||||
|
||||
return success_response(result={"skills": skills})
|
||||
|
||||
|
||||
@game_mechanics_bp.route('/difficulties', methods=['GET'])
|
||||
def list_difficulties():
|
||||
"""
|
||||
List all difficulty levels and their DC values.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"difficulties": [
|
||||
{"name": "trivial", "dc": 5},
|
||||
{"name": "easy", "dc": 10},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
difficulties = []
|
||||
for diff in Difficulty:
|
||||
difficulties.append({
|
||||
"name": diff.name.lower(),
|
||||
"dc": diff.value,
|
||||
})
|
||||
|
||||
return success_response(result={"difficulties": difficulties})
|
||||
Reference in New Issue
Block a user