From 585474f8e2cf1d773d5a8e9e58035f95dccf7e25 Mon Sep 17 00:00:00 2001 From: spuentesp Date: Sun, 4 Jan 2026 20:03:13 -0300 Subject: [PATCH] feat(data-layer): DL-25 - Manage Combat State MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements complete MongoDB combat management system for initiative-based encounters with participant tracking, combat log, and outcome recording. ## Combat Schemas (combat.py) New schemas: - CombatCreate/Update/Response: Full CRUD for combat encounters - CombatParticipant: Entity participation with initiative, conditions, resources - Condition: Temporary status effects (Stunned, Blessed, etc.) - CombatEnvironment: Terrain, lighting, hazards, cover positions - CombatLogEntry: Round-by-round action tracking with resolution links - CombatOutcome: Final results (victory/defeat, survivors, loot, XP) - CombatFilter/ListResponse: Flexible querying by scene/story/status ## Combat Enums (base.py) - CombatStatus: INITIALIZING, INITIATIVE, ACTIVE, PAUSED, RESOLVED - CombatSide: PC, ALLY, ENEMY, NEUTRAL ## MongoDB Tools (mongodb_tools.py) Implemented 10 combat operations: **Combat CRUD:** - mongodb_create_combat: Create encounter with participants/environment - mongodb_get_combat: Retrieve full combat state with all data - mongodb_list_combats: Filter by scene_id, story_id, status - mongodb_update_combat: Update status, round, turn_order, current_turn_index - mongodb_delete_combat: Remove encounter record **Participant Management:** - mongodb_add_combat_participant: Add entity to combat - mongodb_update_combat_participant: Update initiative, conditions, resources, position - mongodb_remove_combat_participant: Remove entity from combat **Combat Tracking:** - mongodb_add_combat_log_entry: Append action to combat log - mongodb_set_combat_outcome: Set final result (auto-sets status to RESOLVED) ## Authority Matrix (auth.py) All 10 combat tools added: - Create/Update/Delete: ["Orchestrator", "CanonKeeper"] - Read operations: ["*"] ## Tests (test_combat_tools.py) Created 21 comprehensive tests covering: - Combat creation with scene/story validation - Full state retrieval and filtering - Round/status updates and turn tracking - Participant lifecycle (add/update/remove) - Initiative, condition, and resource management - Combat log appending - Outcome recording with auto-resolution All 236 tests passing ✅ Implements: DL-25 (CRITICAL priority) Depends on: DL-4 (Scenes/Turns ✅), DL-2 (Entities ✅) Blocks: P-16 (Combat encounter management) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../src/monitor_data/middleware/auth.py | 13 + .../src/monitor_data/schemas/__init__.py | 4 + .../src/monitor_data/schemas/base.py | 19 + .../src/monitor_data/schemas/combat.py | 250 +++++ .../src/monitor_data/tools/mongodb_tools.py | 568 ++++++++++- .../tests/test_tools/test_combat_tools.py | 905 ++++++++++++++++++ 6 files changed, 1758 insertions(+), 1 deletion(-) create mode 100644 packages/data-layer/src/monitor_data/schemas/combat.py create mode 100644 packages/data-layer/tests/test_tools/test_combat_tools.py diff --git a/packages/data-layer/src/monitor_data/middleware/auth.py b/packages/data-layer/src/monitor_data/middleware/auth.py index a9b8d198..f9be2e38 100644 --- a/packages/data-layer/src/monitor_data/middleware/auth.py +++ b/packages/data-layer/src/monitor_data/middleware/auth.py @@ -172,6 +172,19 @@ "neo4j_update_party_formation": ["Orchestrator"], "neo4j_delete_party": ["CanonKeeper"], # ========================================================================= + # MONGODB OPERATIONS - Combat (DL-25) + # ========================================================================= + "mongodb_create_combat": ["Orchestrator", "CanonKeeper"], + "mongodb_get_combat": ["*"], + "mongodb_list_combats": ["*"], + "mongodb_update_combat": ["Orchestrator", "CanonKeeper"], + "mongodb_delete_combat": ["CanonKeeper"], + "mongodb_add_combat_participant": ["Orchestrator", "CanonKeeper"], + "mongodb_update_combat_participant": ["Orchestrator", "CanonKeeper"], + "mongodb_remove_combat_participant": ["Orchestrator", "CanonKeeper"], + "mongodb_add_combat_log_entry": ["Orchestrator", "CanonKeeper"], + "mongodb_set_combat_outcome": ["Orchestrator", "CanonKeeper"], + # ========================================================================= # COMPOSITE OPERATIONS # ========================================================================= "composite_get_entity_full": ["*"], diff --git a/packages/data-layer/src/monitor_data/schemas/__init__.py b/packages/data-layer/src/monitor_data/schemas/__init__.py index f67e4008..c903c7d8 100644 --- a/packages/data-layer/src/monitor_data/schemas/__init__.py +++ b/packages/data-layer/src/monitor_data/schemas/__init__.py @@ -35,6 +35,8 @@ ProposalType, Speaker, PartyStatus, + CombatStatus, + CombatSide, CanonicalMetadata, BaseResponse, ) @@ -71,6 +73,8 @@ "ProposalType", "Speaker", "PartyStatus", + "CombatStatus", + "CombatSide", "CanonicalMetadata", "BaseResponse", # Universe schemas diff --git a/packages/data-layer/src/monitor_data/schemas/base.py b/packages/data-layer/src/monitor_data/schemas/base.py index d4703222..4a6e81d1 100644 --- a/packages/data-layer/src/monitor_data/schemas/base.py +++ b/packages/data-layer/src/monitor_data/schemas/base.py @@ -226,6 +226,25 @@ class PartyStatus(str, Enum): RESTING = "resting" +class CombatStatus(str, Enum): + """Combat encounter status.""" + + INITIALIZING = "initializing" + INITIATIVE = "initiative" + ACTIVE = "active" + PAUSED = "paused" + RESOLVED = "resolved" + + +class CombatSide(str, Enum): + """Participant side in combat.""" + + PC = "pc" + ALLY = "ally" + ENEMY = "enemy" + NEUTRAL = "neutral" + + # ============================================================================= # BASE MODELS # ============================================================================= diff --git a/packages/data-layer/src/monitor_data/schemas/combat.py b/packages/data-layer/src/monitor_data/schemas/combat.py new file mode 100644 index 00000000..25f2e5c6 --- /dev/null +++ b/packages/data-layer/src/monitor_data/schemas/combat.py @@ -0,0 +1,250 @@ +""" +Pydantic schemas for Combat operations (DL-25). + +LAYER: 1 (data-layer) +IMPORTS FROM: External libraries (pydantic, uuid, datetime) and base schemas +CALLED BY: mongodb_tools.py + +These schemas define the data contracts for Combat CRUD operations. +Combat encounters manage initiative, turn order, participants, and combat flow. +""" + +from datetime import datetime +from typing import Optional, List, Dict, Any +from uuid import UUID + +from pydantic import BaseModel, Field + +from monitor_data.schemas.base import CombatStatus, CombatSide + + +# ============================================================================= +# CONDITION SCHEMAS +# ============================================================================= + + +class Condition(BaseModel): + """A temporary condition affecting a combatant.""" + + name: str = Field( + max_length=100, description="Condition name (e.g., 'Stunned', 'Blessed')" + ) + source: str = Field(max_length=200, description="What caused this condition") + duration_type: str = Field( + max_length=50, + description="e.g., 'rounds', 'until_save', 'permanent', 'concentration'", + ) + duration_remaining: Optional[int] = Field( + None, ge=0, description="Remaining rounds/turns" + ) + metadata: Dict[str, Any] = Field( + default_factory=dict, + description="Additional condition data (save DC, effect details, etc.)", + ) + + +# ============================================================================= +# PARTICIPANT SCHEMAS +# ============================================================================= + + +class CombatParticipant(BaseModel): + """A participant in combat.""" + + entity_id: UUID + name: str = Field(max_length=200, description="Display name") + side: CombatSide + initiative_value: Optional[float] = Field( + None, description="Initiative score for turn order" + ) + is_active: bool = Field(default=True, description="Whether participant can act") + conditions: List[Condition] = Field(default_factory=list) + resources: Dict[str, Any] = Field( + default_factory=dict, description="Resource snapshot (HP, spell slots, etc.)" + ) + position: Optional[Dict[str, Any]] = Field( + None, description="Position data (coordinates, zone, etc.)" + ) + + +class AddCombatParticipant(BaseModel): + """Request to add a participant to combat.""" + + encounter_id: UUID + entity_id: UUID + name: str = Field(max_length=200) + side: CombatSide + initiative_value: Optional[float] = None + resources: Optional[Dict[str, Any]] = None + + +class UpdateCombatParticipant(BaseModel): + """Request to update a combat participant.""" + + encounter_id: UUID + entity_id: UUID + initiative_value: Optional[float] = None + is_active: Optional[bool] = None + conditions: Optional[List[Condition]] = None + resources: Optional[Dict[str, Any]] = None + position: Optional[Dict[str, Any]] = None + + +class RemoveCombatParticipant(BaseModel): + """Request to remove a participant from combat.""" + + encounter_id: UUID + entity_id: UUID + + +# ============================================================================= +# ENVIRONMENT SCHEMAS +# ============================================================================= + + +class CombatEnvironment(BaseModel): + """Environmental factors affecting combat.""" + + terrain: str = Field(default="normal", max_length=100) + lighting: str = Field(default="normal", max_length=100) + hazards: List[Dict[str, Any]] = Field( + default_factory=list, + description="Environmental hazards (fire, ice, traps, etc.)", + ) + cover_positions: List[Dict[str, Any]] = Field( + default_factory=list, description="Available cover locations" + ) + metadata: Dict[str, Any] = Field(default_factory=dict) + + +# ============================================================================= +# COMBAT LOG SCHEMAS +# ============================================================================= + + +class CombatLogEntry(BaseModel): + """A single entry in the combat log.""" + + round: int = Field(ge=1) + turn: int = Field(ge=1) + actor_id: UUID + action: str = Field(max_length=500, description="Action taken") + resolution_id: Optional[UUID] = Field( + None, description="Link to resolution document" + ) + summary: str = Field(max_length=1000, description="Human-readable summary") + timestamp: datetime + + +class AddCombatLogEntry(BaseModel): + """Request to add a combat log entry.""" + + encounter_id: UUID + round: int = Field(ge=1) + turn: int = Field(ge=1) + actor_id: UUID + action: str = Field(max_length=500) + resolution_id: Optional[UUID] = None + summary: str = Field(max_length=1000) + + +# ============================================================================= +# COMBAT OUTCOME SCHEMAS +# ============================================================================= + + +class CombatOutcome(BaseModel): + """Final outcome of a combat encounter.""" + + result: str = Field( + max_length=50, description="e.g., 'victory', 'defeat', 'retreat', 'negotiated'" + ) + winning_side: Optional[CombatSide] = None + survivors: List[UUID] = Field(default_factory=list) + casualties: List[UUID] = Field(default_factory=list) + loot: List[Dict[str, Any]] = Field(default_factory=list) + xp_awarded: Optional[int] = Field(None, ge=0) + metadata: Dict[str, Any] = Field(default_factory=dict) + + +class SetCombatOutcome(BaseModel): + """Request to set combat outcome.""" + + encounter_id: UUID + result: str = Field(max_length=50) + winning_side: Optional[CombatSide] = None + survivors: Optional[List[UUID]] = None + casualties: Optional[List[UUID]] = None + loot: Optional[List[Dict[str, Any]]] = None + xp_awarded: Optional[int] = Field(None, ge=0) + + +# ============================================================================= +# COMBAT CRUD SCHEMAS +# ============================================================================= + + +class CombatCreate(BaseModel): + """Request to create a combat encounter.""" + + scene_id: UUID + story_id: UUID + participants: List[CombatParticipant] = Field( + default_factory=list, description="Initial participants" + ) + environment: Optional[CombatEnvironment] = None + + +class CombatUpdate(BaseModel): + """Request to update a combat encounter.""" + + status: Optional[CombatStatus] = None + round: Optional[int] = Field(None, ge=1) + turn_order: Optional[List[UUID]] = Field( + None, description="Ordered list of entity_ids for initiative order" + ) + current_turn_index: Optional[int] = Field(None, ge=0) + + +class CombatResponse(BaseModel): + """Response with combat encounter data.""" + + id: UUID + scene_id: UUID + story_id: UUID + status: CombatStatus + round: int = Field(default=0, ge=0) + turn_order: List[UUID] = Field(default_factory=list) + current_turn_index: int = Field(default=0, ge=0) + participants: List[CombatParticipant] + environment: CombatEnvironment + combat_log: List[CombatLogEntry] = Field(default_factory=list) + outcome: Optional[CombatOutcome] = None + created_at: datetime + updated_at: Optional[datetime] = None + + model_config = {"from_attributes": True} + + +# ============================================================================= +# QUERY SCHEMAS +# ============================================================================= + + +class CombatFilter(BaseModel): + """Filter parameters for listing combat encounters.""" + + scene_id: Optional[UUID] = None + story_id: Optional[UUID] = None + status: Optional[str] = None + limit: int = Field(default=50, ge=1, le=100) + offset: int = Field(default=0, ge=0) + + +class CombatListResponse(BaseModel): + """Response for list operations.""" + + combats: List[CombatResponse] + total: int + limit: int + offset: int diff --git a/packages/data-layer/src/monitor_data/tools/mongodb_tools.py b/packages/data-layer/src/monitor_data/tools/mongodb_tools.py index 9b58470f..e995ffc2 100644 --- a/packages/data-layer/src/monitor_data/tools/mongodb_tools.py +++ b/packages/data-layer/src/monitor_data/tools/mongodb_tools.py @@ -33,7 +33,12 @@ Evidence, DecisionMetadata, ) -from monitor_data.schemas.base import SceneStatus, ProposalStatus +from monitor_data.schemas.base import ( + SceneStatus, + ProposalStatus, + CombatStatus, + CombatSide, +) from monitor_data.schemas.story_outlines import ( StoryOutlineCreate, StoryOutlineUpdate, @@ -45,6 +50,23 @@ MysteryClue, BeatStatus, ) +from monitor_data.schemas.combat import ( + CombatCreate, + CombatUpdate, + CombatResponse, + CombatFilter, + CombatListResponse, + CombatParticipant, + AddCombatParticipant, + UpdateCombatParticipant, + RemoveCombatParticipant, + CombatEnvironment, + AddCombatLogEntry, + CombatLogEntry, + SetCombatOutcome, + CombatOutcome, + Condition, +) # ============================================================================= @@ -1134,3 +1156,547 @@ def mongodb_update_story_outline( raise ValueError(f"Story outline {story_id} not found after update") return updated + + +# ============================================================================= +# COMBAT OPERATIONS (DL-25) +# ============================================================================= + + +def _convert_combat_doc_to_response(combat_doc: Dict[str, Any]) -> CombatResponse: + """ + Convert a combat document from MongoDB to a CombatResponse object. + + Args: + combat_doc: Combat data from MongoDB document + + Returns: + CombatResponse object + """ + # Convert participants + participants = [ + CombatParticipant( + entity_id=UUID(p["entity_id"]), + name=p["name"], + side=CombatSide(p["side"]), + initiative_value=p.get("initiative_value"), + is_active=p.get("is_active", True), + conditions=[Condition(**c) for c in p.get("conditions", [])], + resources=p.get("resources", {}), + position=p.get("position"), + ) + for p in combat_doc.get("participants", []) + ] + + # Convert environment + env_data = combat_doc.get("environment", {}) + environment = CombatEnvironment(**env_data) if env_data else CombatEnvironment() + + # Convert combat log + combat_log = [ + CombatLogEntry( + round=entry["round"], + turn=entry["turn"], + actor_id=UUID(entry["actor_id"]), + action=entry["action"], + resolution_id=( + UUID(entry["resolution_id"]) if entry.get("resolution_id") else None + ), + summary=entry["summary"], + timestamp=entry["timestamp"], + ) + for entry in combat_doc.get("combat_log", []) + ] + + # Convert outcome + outcome_data = combat_doc.get("outcome") + outcome = None + if outcome_data: + outcome = CombatOutcome( + result=outcome_data["result"], + winning_side=( + CombatSide(outcome_data["winning_side"]) + if outcome_data.get("winning_side") + else None + ), + survivors=[UUID(sid) for sid in outcome_data.get("survivors", [])], + casualties=[UUID(cid) for cid in outcome_data.get("casualties", [])], + loot=outcome_data.get("loot", []), + xp_awarded=outcome_data.get("xp_awarded"), + metadata=outcome_data.get("metadata", {}), + ) + + return CombatResponse( + id=UUID(combat_doc["encounter_id"]), + scene_id=UUID(combat_doc["scene_id"]), + story_id=UUID(combat_doc["story_id"]), + status=CombatStatus(combat_doc.get("status", "initializing")), + round=combat_doc.get("round", 0), + turn_order=[UUID(tid) for tid in combat_doc.get("turn_order", [])], + current_turn_index=combat_doc.get("current_turn_index", 0), + participants=participants, + environment=environment, + combat_log=combat_log, + outcome=outcome, + created_at=combat_doc["created_at"], + updated_at=combat_doc.get("updated_at"), + ) + + +def mongodb_create_combat(params: CombatCreate) -> CombatResponse: + """ + Create a new combat encounter. + + Args: + params: Combat creation parameters + + Returns: + CombatResponse with created combat data + + Raises: + ValueError: If scene_id or story_id doesn't exist + """ + mongodb = get_mongodb_client() + combats_collection = mongodb.get_collection("combat_encounters") + + # Validate scene exists + scenes_collection = mongodb.get_collection("scenes") + scene = scenes_collection.find_one({"scene_id": str(params.scene_id)}) + if not scene: + raise ValueError(f"Scene {params.scene_id} not found") + + # Validate story exists (via Neo4j) + neo4j = get_neo4j_client() + with neo4j.session() as session: # type: ignore[attr-defined] + result = session.run( + "MATCH (s:Story {id: $story_id}) RETURN s", story_id=str(params.story_id) + ) + if not result.single(): + raise ValueError(f"Story {params.story_id} not found") + + now = datetime.now(timezone.utc) + encounter_id = uuid4() + + # Prepare environment + environment = params.environment if params.environment else CombatEnvironment() + + combat_doc = { + "encounter_id": str(encounter_id), + "scene_id": str(params.scene_id), + "story_id": str(params.story_id), + "status": "initializing", + "round": 0, + "turn_order": [], + "current_turn_index": 0, + "participants": [p.model_dump(mode="json") for p in params.participants], + "environment": environment.model_dump(mode="json"), + "combat_log": [], + "outcome": None, + "created_at": now, + "updated_at": None, + } + + combats_collection.insert_one(combat_doc) + + return _convert_combat_doc_to_response(combat_doc) + + +def mongodb_get_combat(encounter_id: UUID) -> Optional[CombatResponse]: + """ + Get a combat encounter by ID. + + Args: + encounter_id: Combat encounter UUID + + Returns: + CombatResponse or None if not found + """ + mongodb = get_mongodb_client() + combats_collection = mongodb.get_collection("combat_encounters") + + combat_doc = combats_collection.find_one({"encounter_id": str(encounter_id)}) + if not combat_doc: + return None + + return _convert_combat_doc_to_response(combat_doc) + + +def mongodb_list_combats(params: CombatFilter) -> CombatListResponse: + """ + List combat encounters with filtering. + + Args: + params: Filter parameters + + Returns: + CombatListResponse with matching combats + """ + mongodb = get_mongodb_client() + combats_collection = mongodb.get_collection("combat_encounters") + + # Build query + query: Dict[str, Any] = {} + if params.scene_id: + query["scene_id"] = str(params.scene_id) + if params.story_id: + query["story_id"] = str(params.story_id) + if params.status: + query["status"] = params.status + + # Count total + total = combats_collection.count_documents(query) + + # Get page + cursor = ( + combats_collection.find(query) + .sort("created_at", -1) + .skip(params.offset) + .limit(params.limit) + ) + + combats = [_convert_combat_doc_to_response(doc) for doc in cursor] + + return CombatListResponse( + combats=combats, + total=total, + limit=params.limit, + offset=params.offset, + ) + + +def mongodb_update_combat(encounter_id: UUID, params: CombatUpdate) -> CombatResponse: + """ + Update a combat encounter. + + Args: + encounter_id: Combat encounter UUID + params: Update parameters + + Returns: + Updated CombatResponse + + Raises: + ValueError: If combat not found + """ + mongodb = get_mongodb_client() + combats_collection = mongodb.get_collection("combat_encounters") + + # Verify combat exists + combat = combats_collection.find_one({"encounter_id": str(encounter_id)}) + if not combat: + raise ValueError(f"Combat encounter {encounter_id} not found") + + now = datetime.now(timezone.utc) + update_doc: Dict[str, Any] = {"updated_at": now} + + if params.status is not None: + update_doc["status"] = params.status.value + if params.round is not None: + update_doc["round"] = params.round + if params.turn_order is not None: + update_doc["turn_order"] = [str(tid) for tid in params.turn_order] + if params.current_turn_index is not None: + update_doc["current_turn_index"] = params.current_turn_index + + combats_collection.update_one( + {"encounter_id": str(encounter_id)}, {"$set": update_doc} + ) + + updated = mongodb_get_combat(encounter_id) + if not updated: + raise ValueError(f"Combat encounter {encounter_id} not found after update") + + return updated + + +def mongodb_delete_combat(encounter_id: UUID) -> bool: + """ + Delete a combat encounter. + + Args: + encounter_id: Combat encounter UUID + + Returns: + True if deleted, False if not found + """ + mongodb = get_mongodb_client() + combats_collection = mongodb.get_collection("combat_encounters") + + result = combats_collection.delete_one({"encounter_id": str(encounter_id)}) + return result.deleted_count > 0 + + +def mongodb_add_combat_participant(params: AddCombatParticipant) -> CombatResponse: + """ + Add a participant to a combat encounter. + + Args: + params: Participant data + + Returns: + Updated CombatResponse + + Raises: + ValueError: If combat not found or entity already participating + """ + mongodb = get_mongodb_client() + combats_collection = mongodb.get_collection("combat_encounters") + + # Verify combat exists + combat = combats_collection.find_one({"encounter_id": str(params.encounter_id)}) + if not combat: + raise ValueError(f"Combat encounter {params.encounter_id} not found") + + # Check if entity already participating + for p in combat.get("participants", []): + if p["entity_id"] == str(params.entity_id): + raise ValueError(f"Entity {params.entity_id} is already in combat") + + # Create participant + participant = CombatParticipant( + entity_id=params.entity_id, + name=params.name, + side=params.side, + initiative_value=params.initiative_value, + is_active=True, + conditions=[], + resources=params.resources if params.resources else {}, + position=None, + ) + + now = datetime.now(timezone.utc) + combats_collection.update_one( + {"encounter_id": str(params.encounter_id)}, + { + "$push": {"participants": participant.model_dump(mode="json")}, + "$set": {"updated_at": now}, + }, + ) + + updated = mongodb_get_combat(params.encounter_id) + if not updated: + raise ValueError( + f"Combat encounter {params.encounter_id} not found after update" + ) + + return updated + + +def mongodb_update_combat_participant( + params: UpdateCombatParticipant, +) -> CombatResponse: + """ + Update a combat participant. + + Args: + params: Participant update data + + Returns: + Updated CombatResponse + + Raises: + ValueError: If combat or participant not found + """ + mongodb = get_mongodb_client() + combats_collection = mongodb.get_collection("combat_encounters") + + # Verify combat exists + combat = combats_collection.find_one({"encounter_id": str(params.encounter_id)}) + if not combat: + raise ValueError(f"Combat encounter {params.encounter_id} not found") + + # Find participant index + participants = combat.get("participants", []) + participant_idx = None + for i, p in enumerate(participants): + if p["entity_id"] == str(params.entity_id): + participant_idx = i + break + + if participant_idx is None: + raise ValueError( + f"Participant {params.entity_id} not found in combat {params.encounter_id}" + ) + + # Build update + now = datetime.now(timezone.utc) + update_fields: Dict[str, Any] = {} + + if params.initiative_value is not None: + update_fields[f"participants.{participant_idx}.initiative_value"] = ( + params.initiative_value + ) + if params.is_active is not None: + update_fields[f"participants.{participant_idx}.is_active"] = params.is_active + if params.conditions is not None: + update_fields[f"participants.{participant_idx}.conditions"] = [ + c.model_dump(mode="json") for c in params.conditions + ] + if params.resources is not None: + update_fields[f"participants.{participant_idx}.resources"] = params.resources + if params.position is not None: + update_fields[f"participants.{participant_idx}.position"] = params.position + + update_fields["updated_at"] = now + + combats_collection.update_one( + {"encounter_id": str(params.encounter_id)}, {"$set": update_fields} + ) + + updated = mongodb_get_combat(params.encounter_id) + if not updated: + raise ValueError( + f"Combat encounter {params.encounter_id} not found after update" + ) + + return updated + + +def mongodb_remove_combat_participant( + params: RemoveCombatParticipant, +) -> CombatResponse: + """ + Remove a participant from a combat encounter. + + Args: + params: Removal parameters + + Returns: + Updated CombatResponse + + Raises: + ValueError: If combat or participant not found + """ + mongodb = get_mongodb_client() + combats_collection = mongodb.get_collection("combat_encounters") + + # Verify combat exists + combat = combats_collection.find_one({"encounter_id": str(params.encounter_id)}) + if not combat: + raise ValueError(f"Combat encounter {params.encounter_id} not found") + + # Verify participant exists + participants = combat.get("participants", []) + found = any(p["entity_id"] == str(params.entity_id) for p in participants) + if not found: + raise ValueError( + f"Participant {params.entity_id} not found in combat {params.encounter_id}" + ) + + now = datetime.now(timezone.utc) + combats_collection.update_one( + {"encounter_id": str(params.encounter_id)}, + { + "$pull": {"participants": {"entity_id": str(params.entity_id)}}, + "$set": {"updated_at": now}, + }, + ) + + updated = mongodb_get_combat(params.encounter_id) + if not updated: + raise ValueError( + f"Combat encounter {params.encounter_id} not found after update" + ) + + return updated + + +def mongodb_add_combat_log_entry(params: AddCombatLogEntry) -> CombatResponse: + """ + Add an entry to the combat log. + + Args: + params: Log entry data + + Returns: + Updated CombatResponse + + Raises: + ValueError: If combat not found + """ + mongodb = get_mongodb_client() + combats_collection = mongodb.get_collection("combat_encounters") + + # Verify combat exists + combat = combats_collection.find_one({"encounter_id": str(params.encounter_id)}) + if not combat: + raise ValueError(f"Combat encounter {params.encounter_id} not found") + + now = datetime.now(timezone.utc) + log_entry = CombatLogEntry( + round=params.round, + turn=params.turn, + actor_id=params.actor_id, + action=params.action, + resolution_id=params.resolution_id, + summary=params.summary, + timestamp=now, + ) + + combats_collection.update_one( + {"encounter_id": str(params.encounter_id)}, + { + "$push": {"combat_log": log_entry.model_dump(mode="json")}, + "$set": {"updated_at": now}, + }, + ) + + updated = mongodb_get_combat(params.encounter_id) + if not updated: + raise ValueError( + f"Combat encounter {params.encounter_id} not found after update" + ) + + return updated + + +def mongodb_set_combat_outcome(params: SetCombatOutcome) -> CombatResponse: + """ + Set the final outcome of a combat encounter. + + Args: + params: Outcome data + + Returns: + Updated CombatResponse + + Raises: + ValueError: If combat not found + """ + mongodb = get_mongodb_client() + combats_collection = mongodb.get_collection("combat_encounters") + + # Verify combat exists + combat = combats_collection.find_one({"encounter_id": str(params.encounter_id)}) + if not combat: + raise ValueError(f"Combat encounter {params.encounter_id} not found") + + outcome = CombatOutcome( + result=params.result, + winning_side=params.winning_side, + survivors=params.survivors if params.survivors else [], + casualties=params.casualties if params.casualties else [], + loot=params.loot if params.loot else [], + xp_awarded=params.xp_awarded, + metadata={}, + ) + + now = datetime.now(timezone.utc) + combats_collection.update_one( + {"encounter_id": str(params.encounter_id)}, + { + "$set": { + "outcome": outcome.model_dump(mode="json"), + "status": "resolved", + "updated_at": now, + } + }, + ) + + updated = mongodb_get_combat(params.encounter_id) + if not updated: + raise ValueError( + f"Combat encounter {params.encounter_id} not found after update" + ) + + return updated diff --git a/packages/data-layer/tests/test_tools/test_combat_tools.py b/packages/data-layer/tests/test_tools/test_combat_tools.py new file mode 100644 index 00000000..e727163f --- /dev/null +++ b/packages/data-layer/tests/test_tools/test_combat_tools.py @@ -0,0 +1,905 @@ +""" +Tests for Combat MongoDB tools (DL-25). + +Tests all combat CRUD operations, participant management, +combat log, and outcome tracking. +""" + +from datetime import datetime, timezone +from unittest.mock import Mock, MagicMock, patch +from uuid import uuid4 + +import pytest + +from monitor_data.schemas.combat import ( + CombatCreate, + CombatUpdate, + CombatResponse, + CombatFilter, + CombatParticipant, + AddCombatParticipant, + UpdateCombatParticipant, + RemoveCombatParticipant, + CombatEnvironment, + AddCombatLogEntry, + SetCombatOutcome, + Condition, +) +from monitor_data.schemas.base import CombatStatus, CombatSide +from monitor_data.tools.mongodb_tools import ( + mongodb_create_combat, + mongodb_get_combat, + mongodb_list_combats, + mongodb_update_combat, + mongodb_delete_combat, + mongodb_add_combat_participant, + mongodb_update_combat_participant, + mongodb_remove_combat_participant, + mongodb_add_combat_log_entry, + mongodb_set_combat_outcome, +) + + +# ============================================================================= +# TEST: mongodb_create_combat +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_combat_success( + mock_get_mongodb: Mock, + mock_get_neo4j: Mock, +): + """Test creating a combat encounter.""" + scene_id = uuid4() + story_id = uuid4() + encounter_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_combats = MagicMock() + mock_scenes = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.side_effect = lambda name: ( + mock_combats if name == "combat_encounters" else mock_scenes + ) + + # Mock scene exists + mock_scenes.find_one.return_value = {"scene_id": str(scene_id)} + + # Mock Neo4j + mock_neo4j = MagicMock() + mock_session = MagicMock() + mock_result = MagicMock() + mock_single = MagicMock() + + mock_get_neo4j.return_value = mock_neo4j + mock_neo4j.session.return_value.__enter__.return_value = mock_session + mock_session.run.return_value = mock_result + mock_result.single.return_value = mock_single + + # Test data + participants = [ + CombatParticipant( + entity_id=uuid4(), + name="Fighter", + side=CombatSide.PC, + initiative_value=15.5, + is_active=True, + conditions=[], + resources={"hp": 50}, + position=None, + ) + ] + + params = CombatCreate( + scene_id=scene_id, + story_id=story_id, + participants=participants, + environment=CombatEnvironment(terrain="forest", lighting="dim"), + ) + + with patch("monitor_data.tools.mongodb_tools.uuid4", return_value=encounter_id): + result = mongodb_create_combat(params) + + assert result.id == encounter_id + assert result.scene_id == scene_id + assert result.story_id == story_id + assert result.status == CombatStatus.INITIALIZING + assert result.round == 0 + assert len(result.participants) == 1 + assert result.participants[0].name == "Fighter" + assert result.environment.terrain == "forest" + mock_combats.insert_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_combat_scene_not_found( + mock_get_mongodb: Mock, + mock_get_neo4j: Mock, +): + """Test creating combat with invalid scene_id.""" + scene_id = uuid4() + story_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + mock_scenes = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.side_effect = lambda name: ( + mock_combats if name == "combat_encounters" else mock_scenes + ) + + # Scene does not exist + mock_scenes.find_one.return_value = None + + params = CombatCreate( + scene_id=scene_id, + story_id=story_id, + ) + + with pytest.raises(ValueError, match=f"Scene {scene_id} not found"): + mongodb_create_combat(params) + + +# ============================================================================= +# TEST: mongodb_get_combat +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_combat_success(mock_get_mongodb: Mock): + """Test retrieving a combat encounter.""" + encounter_id = uuid4() + scene_id = uuid4() + story_id = uuid4() + entity_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + combat_doc = { + "encounter_id": str(encounter_id), + "scene_id": str(scene_id), + "story_id": str(story_id), + "status": "active", + "round": 3, + "turn_order": [str(entity_id)], + "current_turn_index": 0, + "participants": [ + { + "entity_id": str(entity_id), + "name": "Wizard", + "side": "pc", + "initiative_value": 18.0, + "is_active": True, + "conditions": [], + "resources": {"hp": 30, "spell_slots": {"1": 2}}, + "position": {"x": 5, "y": 10}, + } + ], + "environment": { + "terrain": "dungeon", + "lighting": "dark", + "hazards": [], + "cover_positions": [], + "metadata": {}, + }, + "combat_log": [], + "outcome": None, + "created_at": datetime.now(timezone.utc), + "updated_at": None, + } + + mock_combats.find_one.return_value = combat_doc + + result = mongodb_get_combat(encounter_id) + + assert result is not None + assert result.id == encounter_id + assert result.status == CombatStatus.ACTIVE + assert result.round == 3 + assert len(result.participants) == 1 + assert result.participants[0].name == "Wizard" + assert result.environment.terrain == "dungeon" + mock_combats.find_one.assert_called_once_with({"encounter_id": str(encounter_id)}) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_combat_not_found(mock_get_mongodb: Mock): + """Test retrieving non-existent combat.""" + encounter_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + mock_combats.find_one.return_value = None + + result = mongodb_get_combat(encounter_id) + + assert result is None + + +# ============================================================================= +# TEST: mongodb_list_combats +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_combats_by_scene(mock_get_mongodb: Mock): + """Test listing combats filtered by scene_id.""" + scene_id = uuid4() + encounter1_id = uuid4() + encounter2_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + mock_cursor = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + combat_docs = [ + { + "encounter_id": str(encounter1_id), + "scene_id": str(scene_id), + "story_id": str(uuid4()), + "status": "active", + "round": 1, + "turn_order": [], + "current_turn_index": 0, + "participants": [], + "environment": { + "terrain": "normal", + "lighting": "normal", + "hazards": [], + "cover_positions": [], + "metadata": {}, + }, + "combat_log": [], + "outcome": None, + "created_at": datetime.now(timezone.utc), + "updated_at": None, + }, + { + "encounter_id": str(encounter2_id), + "scene_id": str(scene_id), + "story_id": str(uuid4()), + "status": "resolved", + "round": 5, + "turn_order": [], + "current_turn_index": 0, + "participants": [], + "environment": { + "terrain": "normal", + "lighting": "normal", + "hazards": [], + "cover_positions": [], + "metadata": {}, + }, + "combat_log": [], + "outcome": None, + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + }, + ] + + mock_combats.count_documents.return_value = 2 + mock_combats.find.return_value = mock_cursor + mock_cursor.sort.return_value = mock_cursor + mock_cursor.skip.return_value = mock_cursor + mock_cursor.limit.return_value = combat_docs + + params = CombatFilter(scene_id=scene_id, limit=50, offset=0) + result = mongodb_list_combats(params) + + assert result.total == 2 + assert len(result.combats) == 2 + assert result.combats[0].id == encounter1_id + assert result.combats[1].id == encounter2_id + mock_combats.count_documents.assert_called_once_with({"scene_id": str(scene_id)}) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_combats_by_status(mock_get_mongodb: Mock): + """Test listing combats filtered by status.""" + encounter_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + mock_cursor = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + combat_docs = [ + { + "encounter_id": str(encounter_id), + "scene_id": str(uuid4()), + "story_id": str(uuid4()), + "status": "active", + "round": 2, + "turn_order": [], + "current_turn_index": 0, + "participants": [], + "environment": { + "terrain": "normal", + "lighting": "normal", + "hazards": [], + "cover_positions": [], + "metadata": {}, + }, + "combat_log": [], + "outcome": None, + "created_at": datetime.now(timezone.utc), + "updated_at": None, + }, + ] + + mock_combats.count_documents.return_value = 1 + mock_combats.find.return_value = mock_cursor + mock_cursor.sort.return_value = mock_cursor + mock_cursor.skip.return_value = mock_cursor + mock_cursor.limit.return_value = combat_docs + + params = CombatFilter(status="active", limit=50, offset=0) + result = mongodb_list_combats(params) + + assert result.total == 1 + assert len(result.combats) == 1 + assert result.combats[0].status == CombatStatus.ACTIVE + mock_combats.count_documents.assert_called_once_with({"status": "active"}) + + +# ============================================================================= +# TEST: mongodb_update_combat +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_combat") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_combat_round(mock_get_mongodb: Mock, mock_get_combat: Mock): + """Test updating combat round.""" + encounter_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + # Mock combat exists + mock_combats.find_one.return_value = {"encounter_id": str(encounter_id)} + + # Mock updated result + updated_combat = Mock(spec=CombatResponse) + updated_combat.round = 5 + mock_get_combat.return_value = updated_combat + + params = CombatUpdate(round=5) + result = mongodb_update_combat(encounter_id, params) + + assert result.round == 5 + mock_combats.update_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_combat") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_combat_status(mock_get_mongodb: Mock, mock_get_combat: Mock): + """Test updating combat status.""" + encounter_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + # Mock combat exists + mock_combats.find_one.return_value = {"encounter_id": str(encounter_id)} + + # Mock updated result + updated_combat = Mock(spec=CombatResponse) + updated_combat.status = CombatStatus.PAUSED + mock_get_combat.return_value = updated_combat + + params = CombatUpdate(status=CombatStatus.PAUSED) + result = mongodb_update_combat(encounter_id, params) + + assert result.status == CombatStatus.PAUSED + mock_combats.update_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_combat_not_found(mock_get_mongodb: Mock): + """Test updating non-existent combat.""" + encounter_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + # Combat does not exist + mock_combats.find_one.return_value = None + + params = CombatUpdate(round=3) + + with pytest.raises(ValueError, match=f"Combat encounter {encounter_id} not found"): + mongodb_update_combat(encounter_id, params) + + +# ============================================================================= +# TEST: mongodb_delete_combat +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_delete_combat_success(mock_get_mongodb: Mock): + """Test deleting a combat encounter.""" + encounter_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + mock_result = Mock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + mock_result.deleted_count = 1 + mock_combats.delete_one.return_value = mock_result + + result = mongodb_delete_combat(encounter_id) + + assert result is True + mock_combats.delete_one.assert_called_once_with({"encounter_id": str(encounter_id)}) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_delete_combat_not_found(mock_get_mongodb: Mock): + """Test deleting non-existent combat.""" + encounter_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + mock_result = Mock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + mock_result.deleted_count = 0 + mock_combats.delete_one.return_value = mock_result + + result = mongodb_delete_combat(encounter_id) + + assert result is False + + +# ============================================================================= +# TEST: mongodb_add_combat_participant +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_combat") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_add_combat_participant_success(mock_get_mongodb: Mock, mock_get_combat: Mock): + """Test adding a participant to combat.""" + encounter_id = uuid4() + entity_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + # Mock combat exists with no participants + mock_combats.find_one.return_value = { + "encounter_id": str(encounter_id), + "participants": [], + } + + # Mock updated result + updated_combat = Mock(spec=CombatResponse) + updated_combat.participants = [ + CombatParticipant( + entity_id=entity_id, + name="Rogue", + side=CombatSide.PC, + initiative_value=20.0, + is_active=True, + conditions=[], + resources={"hp": 40}, + position=None, + ) + ] + mock_get_combat.return_value = updated_combat + + params = AddCombatParticipant( + encounter_id=encounter_id, + entity_id=entity_id, + name="Rogue", + side=CombatSide.PC, + initiative_value=20.0, + ) + + result = mongodb_add_combat_participant(params) + + assert len(result.participants) == 1 + assert result.participants[0].name == "Rogue" + mock_combats.update_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_add_combat_participant_already_exists(mock_get_mongodb: Mock): + """Test adding duplicate participant.""" + encounter_id = uuid4() + entity_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + # Mock combat exists with participant already in it + mock_combats.find_one.return_value = { + "encounter_id": str(encounter_id), + "participants": [{"entity_id": str(entity_id), "name": "Rogue"}], + } + + params = AddCombatParticipant( + encounter_id=encounter_id, + entity_id=entity_id, + name="Rogue", + side=CombatSide.PC, + ) + + with pytest.raises(ValueError, match=f"Entity {entity_id} is already in combat"): + mongodb_add_combat_participant(params) + + +# ============================================================================= +# TEST: mongodb_update_combat_participant +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_combat") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_participant_initiative(mock_get_mongodb: Mock, mock_get_combat: Mock): + """Test updating participant initiative.""" + encounter_id = uuid4() + entity_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + # Mock combat with participant + mock_combats.find_one.return_value = { + "encounter_id": str(encounter_id), + "participants": [ + {"entity_id": str(entity_id), "name": "Cleric", "initiative_value": 12.0} + ], + } + + # Mock updated result + updated_combat = Mock(spec=CombatResponse) + updated_participant = Mock() + updated_participant.initiative_value = 18.5 + updated_combat.participants = [updated_participant] + mock_get_combat.return_value = updated_combat + + params = UpdateCombatParticipant( + encounter_id=encounter_id, + entity_id=entity_id, + initiative_value=18.5, + ) + + result = mongodb_update_combat_participant(params) + + assert result.participants[0].initiative_value == 18.5 + mock_combats.update_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_combat") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_participant_conditions(mock_get_mongodb: Mock, mock_get_combat: Mock): + """Test updating participant conditions.""" + encounter_id = uuid4() + entity_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + # Mock combat with participant + mock_combats.find_one.return_value = { + "encounter_id": str(encounter_id), + "participants": [ + {"entity_id": str(entity_id), "name": "Paladin", "conditions": []} + ], + } + + # Mock updated result + updated_combat = Mock(spec=CombatResponse) + updated_participant = Mock() + updated_participant.conditions = [ + Condition( + name="Blessed", + source="Cleric", + duration_type="rounds", + duration_remaining=3, + ) + ] + updated_combat.participants = [updated_participant] + mock_get_combat.return_value = updated_combat + + conditions = [ + Condition( + name="Blessed", + source="Cleric", + duration_type="rounds", + duration_remaining=3, + ) + ] + params = UpdateCombatParticipant( + encounter_id=encounter_id, + entity_id=entity_id, + conditions=conditions, + ) + + result = mongodb_update_combat_participant(params) + + assert len(result.participants[0].conditions) == 1 + assert result.participants[0].conditions[0].name == "Blessed" + mock_combats.update_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_combat") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_participant_resources(mock_get_mongodb: Mock, mock_get_combat: Mock): + """Test updating participant resources.""" + encounter_id = uuid4() + entity_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + # Mock combat with participant + mock_combats.find_one.return_value = { + "encounter_id": str(encounter_id), + "participants": [ + {"entity_id": str(entity_id), "name": "Barbarian", "resources": {"hp": 100}} + ], + } + + # Mock updated result + updated_combat = Mock(spec=CombatResponse) + updated_participant = Mock() + updated_participant.resources = {"hp": 75, "rage": 2} + updated_combat.participants = [updated_participant] + mock_get_combat.return_value = updated_combat + + params = UpdateCombatParticipant( + encounter_id=encounter_id, + entity_id=entity_id, + resources={"hp": 75, "rage": 2}, + ) + + result = mongodb_update_combat_participant(params) + + assert result.participants[0].resources == {"hp": 75, "rage": 2} + mock_combats.update_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_participant_not_found(mock_get_mongodb: Mock): + """Test updating non-existent participant.""" + encounter_id = uuid4() + entity_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + # Combat exists but participant does not + mock_combats.find_one.return_value = { + "encounter_id": str(encounter_id), + "participants": [], + } + + params = UpdateCombatParticipant( + encounter_id=encounter_id, + entity_id=entity_id, + initiative_value=15.0, + ) + + with pytest.raises(ValueError, match=f"Participant {entity_id} not found"): + mongodb_update_combat_participant(params) + + +# ============================================================================= +# TEST: mongodb_remove_combat_participant +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_combat") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_remove_participant_success(mock_get_mongodb: Mock, mock_get_combat: Mock): + """Test removing a participant from combat.""" + encounter_id = uuid4() + entity_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + # Mock combat with participant + mock_combats.find_one.return_value = { + "encounter_id": str(encounter_id), + "participants": [{"entity_id": str(entity_id), "name": "Goblin"}], + } + + # Mock updated result + updated_combat = Mock(spec=CombatResponse) + updated_combat.participants = [] + mock_get_combat.return_value = updated_combat + + params = RemoveCombatParticipant( + encounter_id=encounter_id, + entity_id=entity_id, + ) + + result = mongodb_remove_combat_participant(params) + + assert len(result.participants) == 0 + mock_combats.update_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_remove_participant_not_found(mock_get_mongodb: Mock): + """Test removing non-existent participant.""" + encounter_id = uuid4() + entity_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + # Combat exists but participant does not + mock_combats.find_one.return_value = { + "encounter_id": str(encounter_id), + "participants": [], + } + + params = RemoveCombatParticipant( + encounter_id=encounter_id, + entity_id=entity_id, + ) + + with pytest.raises(ValueError, match=f"Participant {entity_id} not found"): + mongodb_remove_combat_participant(params) + + +# ============================================================================= +# TEST: mongodb_add_combat_log_entry +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_combat") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_add_log_entry_success(mock_get_mongodb: Mock, mock_get_combat: Mock): + """Test adding a combat log entry.""" + encounter_id = uuid4() + actor_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + # Mock combat exists + mock_combats.find_one.return_value = {"encounter_id": str(encounter_id)} + + # Mock updated result + updated_combat = Mock(spec=CombatResponse) + log_entry = Mock() + log_entry.round = 2 + log_entry.turn = 3 + log_entry.action = "Attack with longsword" + log_entry.summary = "Fighter attacks goblin - HIT for 8 damage" + updated_combat.combat_log = [log_entry] + mock_get_combat.return_value = updated_combat + + params = AddCombatLogEntry( + encounter_id=encounter_id, + round=2, + turn=3, + actor_id=actor_id, + action="Attack with longsword", + summary="Fighter attacks goblin - HIT for 8 damage", + ) + + result = mongodb_add_combat_log_entry(params) + + assert len(result.combat_log) == 1 + assert result.combat_log[0].action == "Attack with longsword" + mock_combats.update_one.assert_called_once() + + +# ============================================================================= +# TEST: mongodb_set_combat_outcome +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_combat") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_set_outcome_success(mock_get_mongodb: Mock, mock_get_combat: Mock): + """Test setting combat outcome.""" + encounter_id = uuid4() + survivor_id = uuid4() + casualty_id = uuid4() + + mock_mongodb = MagicMock() + mock_combats = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_combats + + # Mock combat exists + mock_combats.find_one.return_value = {"encounter_id": str(encounter_id)} + + # Mock updated result + updated_combat = Mock(spec=CombatResponse) + outcome = Mock() + outcome.result = "victory" + outcome.winning_side = CombatSide.PC + outcome.survivors = [survivor_id] + outcome.casualties = [casualty_id] + outcome.xp_awarded = 450 + updated_combat.outcome = outcome + updated_combat.status = CombatStatus.RESOLVED + mock_get_combat.return_value = updated_combat + + params = SetCombatOutcome( + encounter_id=encounter_id, + result="victory", + winning_side=CombatSide.PC, + survivors=[survivor_id], + casualties=[casualty_id], + xp_awarded=450, + ) + + result = mongodb_set_combat_outcome(params) + + assert result.outcome is not None + assert result.outcome.result == "victory" + assert result.outcome.xp_awarded == 450 + assert result.status == CombatStatus.RESOLVED + mock_combats.update_one.assert_called_once()