Combat foundation complete
This commit is contained in:
@@ -251,6 +251,117 @@ def get_combat_state(session_id: str):
|
||||
)
|
||||
|
||||
|
||||
@combat_bp.route('/<session_id>/check', methods=['GET'])
|
||||
@require_auth
|
||||
def check_existing_combat(session_id: str):
|
||||
"""
|
||||
Check if session has an existing active combat encounter.
|
||||
|
||||
Used to detect stale combat sessions when a player tries to start new
|
||||
combat. Returns a summary of the existing combat if present.
|
||||
|
||||
Path Parameters:
|
||||
session_id: Game session ID
|
||||
|
||||
Returns:
|
||||
If in combat:
|
||||
{
|
||||
"has_active_combat": true,
|
||||
"encounter_id": "enc_abc123",
|
||||
"round_number": 3,
|
||||
"status": "active",
|
||||
"players": [
|
||||
{"name": "Hero", "current_hp": 45, "max_hp": 100, "is_alive": true}
|
||||
],
|
||||
"enemies": [
|
||||
{"name": "Goblin", "current_hp": 10, "max_hp": 25, "is_alive": true}
|
||||
]
|
||||
}
|
||||
|
||||
If not in combat:
|
||||
{
|
||||
"has_active_combat": false
|
||||
}
|
||||
"""
|
||||
user = get_current_user()
|
||||
|
||||
try:
|
||||
combat_service = get_combat_service()
|
||||
result = combat_service.check_existing_combat(session_id, user.id)
|
||||
|
||||
if result:
|
||||
return success_response(result)
|
||||
else:
|
||||
return success_response({"has_active_combat": False})
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to check existing combat",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
message="Failed to check combat status",
|
||||
code="COMBAT_CHECK_ERROR"
|
||||
)
|
||||
|
||||
|
||||
@combat_bp.route('/<session_id>/abandon', methods=['POST'])
|
||||
@require_auth
|
||||
def abandon_combat(session_id: str):
|
||||
"""
|
||||
Abandon an existing combat encounter without completing it.
|
||||
|
||||
Deletes the encounter from the database and clears the session reference.
|
||||
No rewards are distributed. Used when a player wants to discard a stale
|
||||
combat session and start fresh.
|
||||
|
||||
Path Parameters:
|
||||
session_id: Game session ID
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"message": "Combat abandoned"
|
||||
}
|
||||
|
||||
or if no combat existed:
|
||||
{
|
||||
"success": false,
|
||||
"message": "No active combat to abandon"
|
||||
}
|
||||
"""
|
||||
user = get_current_user()
|
||||
|
||||
try:
|
||||
combat_service = get_combat_service()
|
||||
abandoned = combat_service.abandon_combat(session_id, user.id)
|
||||
|
||||
if abandoned:
|
||||
logger.info("Combat abandoned via API",
|
||||
session_id=session_id)
|
||||
return success_response({
|
||||
"success": True,
|
||||
"message": "Combat abandoned"
|
||||
})
|
||||
else:
|
||||
return success_response({
|
||||
"success": False,
|
||||
"message": "No active combat to abandon"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to abandon combat",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
message="Failed to abandon combat",
|
||||
code="COMBAT_ABANDON_ERROR"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Action Execution Endpoints
|
||||
# =============================================================================
|
||||
@@ -518,6 +629,20 @@ def attempt_flee(session_id: str):
|
||||
try:
|
||||
combat_service = get_combat_service()
|
||||
|
||||
# If combatant_id not provided, auto-detect player combatant
|
||||
if not combatant_id:
|
||||
encounter = combat_service.get_combat_state(session_id, user.id)
|
||||
if encounter:
|
||||
for combatant in encounter.combatants:
|
||||
if combatant.is_player:
|
||||
combatant_id = combatant.combatant_id
|
||||
break
|
||||
if not combatant_id:
|
||||
return validation_error_response(
|
||||
message="Could not determine player combatant",
|
||||
details={"field": "combatant_id", "issue": "No player found in combat"}
|
||||
)
|
||||
|
||||
action = CombatAction(
|
||||
action_type="flee",
|
||||
target_ids=[],
|
||||
@@ -629,6 +754,142 @@ def end_combat(session_id: str):
|
||||
# Utility Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@combat_bp.route('/encounters', methods=['GET'])
|
||||
@require_auth
|
||||
def get_encounters():
|
||||
"""
|
||||
Get random encounter options for the current location.
|
||||
|
||||
Generates multiple encounter groups appropriate for the player's
|
||||
current location and character level. Used by the "Search for Monsters"
|
||||
feature on the story page.
|
||||
|
||||
Query Parameters:
|
||||
session_id: Game session ID (required)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"location_name": "Thornwood Forest",
|
||||
"location_type": "wilderness",
|
||||
"encounters": [
|
||||
{
|
||||
"group_id": "enc_abc123",
|
||||
"enemies": ["goblin", "goblin", "goblin_scout"],
|
||||
"enemy_names": ["Goblin Scout", "Goblin Scout", "Goblin Scout"],
|
||||
"display_name": "3 Goblin Scouts",
|
||||
"challenge": "Easy"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Errors:
|
||||
400: Missing session_id
|
||||
404: Session not found or no character
|
||||
404: No enemies found for location
|
||||
"""
|
||||
from app.services.session_service import get_session_service
|
||||
from app.services.character_service import get_character_service
|
||||
from app.services.encounter_generator import get_encounter_generator
|
||||
|
||||
user = get_current_user()
|
||||
|
||||
# Get session_id from query params
|
||||
session_id = request.args.get("session_id")
|
||||
if not session_id:
|
||||
return validation_error_response(
|
||||
message="session_id query parameter is required",
|
||||
details={"field": "session_id", "issue": "Missing required parameter"}
|
||||
)
|
||||
|
||||
try:
|
||||
# Get session to find location and character
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user.id)
|
||||
|
||||
if not session:
|
||||
return not_found_response(message=f"Session not found: {session_id}")
|
||||
|
||||
# Get character level
|
||||
character_id = session.get_character_id()
|
||||
if not character_id:
|
||||
return not_found_response(message="No character found in session")
|
||||
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id, user.id)
|
||||
|
||||
if not character:
|
||||
return not_found_response(message=f"Character not found: {character_id}")
|
||||
|
||||
# Get location info from game state
|
||||
location_name = session.game_state.current_location
|
||||
location_type = session.game_state.location_type.value
|
||||
|
||||
# Map location types to enemy location_tags
|
||||
# Some location types may need mapping to available enemy tags
|
||||
location_type_mapping = {
|
||||
"town": "town",
|
||||
"village": "town", # Treat village same as town
|
||||
"tavern": "tavern",
|
||||
"wilderness": "wilderness",
|
||||
"forest": "forest",
|
||||
"dungeon": "dungeon",
|
||||
"ruins": "ruins",
|
||||
"crypt": "crypt",
|
||||
"road": "road",
|
||||
"safe_area": "town", # Safe areas might have rats/vermin
|
||||
"library": "dungeon", # Libraries might have undead guardians
|
||||
}
|
||||
mapped_location = location_type_mapping.get(location_type, location_type)
|
||||
|
||||
# Generate encounters
|
||||
encounter_generator = get_encounter_generator()
|
||||
encounters = encounter_generator.generate_encounters(
|
||||
location_type=mapped_location,
|
||||
character_level=character.level,
|
||||
num_encounters=4
|
||||
)
|
||||
|
||||
# If no encounters found, try wilderness as fallback
|
||||
if not encounters and mapped_location != "wilderness":
|
||||
encounters = encounter_generator.generate_encounters(
|
||||
location_type="wilderness",
|
||||
character_level=character.level,
|
||||
num_encounters=4
|
||||
)
|
||||
|
||||
if not encounters:
|
||||
return not_found_response(
|
||||
message=f"No enemies found for location type: {location_type}"
|
||||
)
|
||||
|
||||
# Format response
|
||||
response_data = {
|
||||
"location_name": location_name,
|
||||
"location_type": location_type,
|
||||
"encounters": [enc.to_dict() for enc in encounters]
|
||||
}
|
||||
|
||||
logger.info("Generated encounter options",
|
||||
session_id=session_id,
|
||||
location_type=location_type,
|
||||
character_level=character.level,
|
||||
num_encounters=len(encounters))
|
||||
|
||||
return success_response(response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to generate encounters",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
message="Failed to generate encounters",
|
||||
code="ENCOUNTER_GENERATION_ERROR"
|
||||
)
|
||||
|
||||
|
||||
@combat_bp.route('/enemies', methods=['GET'])
|
||||
def list_enemies():
|
||||
"""
|
||||
|
||||
@@ -50,6 +50,10 @@ tags:
|
||||
- rogue
|
||||
- armed
|
||||
|
||||
location_tags:
|
||||
- wilderness
|
||||
- road
|
||||
|
||||
base_damage: 8
|
||||
crit_chance: 0.12
|
||||
flee_chance: 0.45
|
||||
|
||||
@@ -47,6 +47,10 @@ tags:
|
||||
- large
|
||||
- pack
|
||||
|
||||
location_tags:
|
||||
- forest
|
||||
- wilderness
|
||||
|
||||
base_damage: 10
|
||||
crit_chance: 0.10
|
||||
flee_chance: 0.40
|
||||
|
||||
@@ -40,6 +40,11 @@ tags:
|
||||
- goblinoid
|
||||
- small
|
||||
|
||||
location_tags:
|
||||
- forest
|
||||
- wilderness
|
||||
- dungeon
|
||||
|
||||
base_damage: 4
|
||||
crit_chance: 0.05
|
||||
flee_chance: 0.60
|
||||
|
||||
@@ -80,6 +80,11 @@ tags:
|
||||
- elite
|
||||
- armed
|
||||
|
||||
location_tags:
|
||||
- forest
|
||||
- wilderness
|
||||
- dungeon
|
||||
|
||||
base_damage: 14
|
||||
crit_chance: 0.15
|
||||
flee_chance: 0.25
|
||||
|
||||
@@ -51,6 +51,11 @@ tags:
|
||||
- small
|
||||
- scout
|
||||
|
||||
location_tags:
|
||||
- forest
|
||||
- wilderness
|
||||
- dungeon
|
||||
|
||||
base_damage: 3
|
||||
crit_chance: 0.08
|
||||
flee_chance: 0.70
|
||||
|
||||
@@ -47,6 +47,11 @@ tags:
|
||||
- caster
|
||||
- small
|
||||
|
||||
location_tags:
|
||||
- forest
|
||||
- wilderness
|
||||
- dungeon
|
||||
|
||||
base_damage: 3
|
||||
crit_chance: 0.08
|
||||
flee_chance: 0.55
|
||||
|
||||
@@ -65,6 +65,11 @@ tags:
|
||||
- warrior
|
||||
- armed
|
||||
|
||||
location_tags:
|
||||
- forest
|
||||
- wilderness
|
||||
- dungeon
|
||||
|
||||
base_damage: 8
|
||||
crit_chance: 0.10
|
||||
flee_chance: 0.45
|
||||
|
||||
@@ -53,6 +53,10 @@ tags:
|
||||
- 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
|
||||
@@ -47,6 +47,11 @@ tags:
|
||||
- armed
|
||||
- fearless
|
||||
|
||||
location_tags:
|
||||
- crypt
|
||||
- ruins
|
||||
- dungeon
|
||||
|
||||
base_damage: 9
|
||||
crit_chance: 0.08
|
||||
flee_chance: 0.50
|
||||
|
||||
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
|
||||
@@ -66,6 +66,18 @@ items:
|
||||
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
|
||||
# ==========================================================================
|
||||
|
||||
@@ -86,7 +86,12 @@ class Effect:
|
||||
|
||||
elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
||||
# 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
|
||||
if self.effect_type == EffectType.BUFF:
|
||||
result["message"] = f"{self.name} increases {result['stat_affected']} by {result['stat_modifier']}"
|
||||
@@ -159,9 +164,17 @@ class Effect:
|
||||
Dictionary containing all effect data
|
||||
"""
|
||||
data = asdict(self)
|
||||
data["effect_type"] = self.effect_type.value
|
||||
# Handle effect_type (could be Enum or string)
|
||||
if hasattr(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:
|
||||
data["stat_affected"] = self.stat_affected.value
|
||||
if hasattr(self.stat_affected, 'value'):
|
||||
data["stat_affected"] = self.stat_affected.value
|
||||
else:
|
||||
data["stat_affected"] = self.stat_affected
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
@@ -193,16 +206,21 @@ class Effect:
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""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]:
|
||||
stat_str = safe_value(self.stat_affected) if self.stat_affected else 'N/A'
|
||||
return (
|
||||
f"Effect({self.name}, {self.effect_type.value}, "
|
||||
f"{self.stat_affected.value if self.stat_affected else 'N/A'} "
|
||||
f"Effect({self.name}, {safe_value(self.effect_type)}, "
|
||||
f"{stat_str} "
|
||||
f"{'+' if self.effect_type == EffectType.BUFF else '-'}{self.power * self.stacks}, "
|
||||
f"{self.duration}t, {self.stacks}x)"
|
||||
)
|
||||
else:
|
||||
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"duration={self.duration}t, stacks={self.stacks}x)"
|
||||
)
|
||||
|
||||
@@ -130,6 +130,7 @@ class EnemyTemplate:
|
||||
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:
|
||||
@@ -149,6 +150,7 @@ class EnemyTemplate:
|
||||
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
|
||||
@@ -194,6 +196,10 @@ class EnemyTemplate:
|
||||
"""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.
|
||||
@@ -213,6 +219,7 @@ class EnemyTemplate:
|
||||
"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,
|
||||
@@ -259,6 +266,7 @@ class EnemyTemplate:
|
||||
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),
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.services.database_service import get_database_service
|
||||
from app.services.appwrite_service import AppwriteService
|
||||
from app.services.class_loader import get_class_loader
|
||||
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
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -173,6 +174,23 @@ class CharacterService:
|
||||
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
|
||||
character_dict = character.to_dict()
|
||||
character_json = json.dumps(character_dict)
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.models.stats import Stats
|
||||
from app.models.abilities import Ability, AbilityLoader
|
||||
from app.models.effects import Effect
|
||||
from app.models.items import Item
|
||||
from app.models.enums import CombatStatus, AbilityType, DamageType, EffectType
|
||||
from app.models.enums import CombatStatus, AbilityType, DamageType, EffectType, StatType
|
||||
from app.services.damage_calculator import DamageCalculator, DamageResult
|
||||
from app.services.enemy_loader import EnemyLoader, get_enemy_loader
|
||||
from app.services.session_service import get_session_service
|
||||
@@ -94,6 +94,7 @@ class ActionResult:
|
||||
combat_status: Final combat status if ended
|
||||
next_combatant_id: ID of combatant whose turn is next
|
||||
turn_effects: Effects that triggered at turn start/end
|
||||
rewards: Combat rewards if victory (XP, gold, items)
|
||||
"""
|
||||
|
||||
success: bool
|
||||
@@ -105,6 +106,7 @@ class ActionResult:
|
||||
next_combatant_id: Optional[str] = None
|
||||
next_is_player: bool = True # True if next turn is player's
|
||||
turn_effects: List[Dict[str, Any]] = field(default_factory=list)
|
||||
rewards: Optional[Dict[str, Any]] = None # Populated on victory
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for API response."""
|
||||
@@ -127,6 +129,7 @@ class ActionResult:
|
||||
"next_combatant_id": self.next_combatant_id,
|
||||
"next_is_player": self.next_is_player,
|
||||
"turn_effects": self.turn_effects,
|
||||
"rewards": self.rewards,
|
||||
}
|
||||
|
||||
|
||||
@@ -451,6 +454,113 @@ class CombatService:
|
||||
|
||||
return rewards
|
||||
|
||||
def check_existing_combat(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Check if a session has an existing active combat encounter.
|
||||
|
||||
Returns combat summary if exists, None otherwise.
|
||||
|
||||
Args:
|
||||
session_id: Game session ID
|
||||
user_id: User ID for authorization
|
||||
|
||||
Returns:
|
||||
Dictionary with combat summary if in combat, None otherwise
|
||||
"""
|
||||
logger.info("Checking for existing combat",
|
||||
session_id=session_id)
|
||||
|
||||
session = self.session_service.get_session(session_id, user_id)
|
||||
|
||||
if not session.is_in_combat():
|
||||
return None
|
||||
|
||||
# Get encounter details
|
||||
encounter = self.get_combat_state(session_id, user_id)
|
||||
if not encounter:
|
||||
return None
|
||||
|
||||
# Build summary of combatants
|
||||
players = []
|
||||
enemies = []
|
||||
for combatant in encounter.combatants:
|
||||
combatant_info = {
|
||||
"name": combatant.name,
|
||||
"current_hp": combatant.current_hp,
|
||||
"max_hp": combatant.max_hp,
|
||||
"is_alive": combatant.is_alive(),
|
||||
}
|
||||
if combatant.is_player:
|
||||
players.append(combatant_info)
|
||||
else:
|
||||
enemies.append(combatant_info)
|
||||
|
||||
return {
|
||||
"has_active_combat": True,
|
||||
"encounter_id": encounter.encounter_id,
|
||||
"round_number": encounter.round_number,
|
||||
"status": encounter.status.value,
|
||||
"players": players,
|
||||
"enemies": enemies,
|
||||
}
|
||||
|
||||
def abandon_combat(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str
|
||||
) -> bool:
|
||||
"""
|
||||
Abandon an existing combat encounter without completing it.
|
||||
|
||||
Deletes the encounter from the database and clears the session
|
||||
reference. No rewards are distributed.
|
||||
|
||||
Args:
|
||||
session_id: Game session ID
|
||||
user_id: User ID for authorization
|
||||
|
||||
Returns:
|
||||
True if combat was abandoned, False if no combat existed
|
||||
"""
|
||||
logger.info("Abandoning combat",
|
||||
session_id=session_id)
|
||||
|
||||
session = self.session_service.get_session(session_id, user_id)
|
||||
|
||||
if not session.is_in_combat():
|
||||
logger.info("No combat to abandon",
|
||||
session_id=session_id)
|
||||
return False
|
||||
|
||||
encounter_id = session.active_combat_encounter_id
|
||||
|
||||
# Delete encounter from repository
|
||||
if encounter_id:
|
||||
try:
|
||||
self.combat_repository.delete_encounter(encounter_id)
|
||||
logger.info("Deleted encounter from repository",
|
||||
encounter_id=encounter_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to delete encounter from repository",
|
||||
encounter_id=encounter_id,
|
||||
error=str(e))
|
||||
|
||||
# Clear session combat references
|
||||
session.active_combat_encounter_id = None
|
||||
session.combat_encounter = None # Clear legacy field too
|
||||
session.update_activity()
|
||||
self.session_service.update_session(session)
|
||||
|
||||
logger.info("Combat abandoned",
|
||||
session_id=session_id,
|
||||
encounter_id=encounter_id)
|
||||
|
||||
return True
|
||||
|
||||
# =========================================================================
|
||||
# Action Execution
|
||||
# =========================================================================
|
||||
@@ -549,6 +659,7 @@ class CombatService:
|
||||
if status == CombatStatus.VICTORY:
|
||||
rewards = self._calculate_rewards(encounter, session, user_id)
|
||||
result.message += f" Victory! Earned {rewards.experience} XP and {rewards.gold} gold."
|
||||
result.rewards = rewards.to_dict()
|
||||
|
||||
# End encounter in repository
|
||||
if session.active_combat_encounter_id:
|
||||
@@ -699,6 +810,11 @@ class CombatService:
|
||||
result.combat_ended = True
|
||||
result.combat_status = status
|
||||
|
||||
# Calculate and distribute rewards on victory
|
||||
if status == CombatStatus.VICTORY:
|
||||
rewards = self._calculate_rewards(encounter, session, user_id)
|
||||
result.rewards = rewards.to_dict()
|
||||
|
||||
# End encounter in repository
|
||||
if session.active_combat_encounter_id:
|
||||
self.combat_repository.end_encounter(
|
||||
@@ -946,7 +1062,7 @@ class CombatService:
|
||||
effect_type=EffectType.BUFF,
|
||||
duration=1,
|
||||
power=5, # +5 defense
|
||||
stat_affected="constitution",
|
||||
stat_affected=StatType.CONSTITUTION,
|
||||
source="defend_action",
|
||||
)
|
||||
combatant.add_effect(defense_buff)
|
||||
|
||||
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
|
||||
@@ -177,6 +177,46 @@ class EnemyLoader:
|
||||
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,
|
||||
|
||||
@@ -15,7 +15,7 @@ import yaml
|
||||
|
||||
from app.models.items import Item
|
||||
from app.models.effects import Effect
|
||||
from app.models.enums import ItemType, ItemRarity, EffectType
|
||||
from app.models.enums import ItemType, ItemRarity, EffectType, DamageType
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -178,6 +178,20 @@ class StaticItemLoader:
|
||||
# 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),
|
||||
@@ -188,6 +202,17 @@ class StaticItemLoader:
|
||||
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]:
|
||||
|
||||
Reference in New Issue
Block a user