diff --git a/packages/data-layer/src/monitor_data/middleware/auth.py b/packages/data-layer/src/monitor_data/middleware/auth.py index f9be2e3..a11b6bd 100644 --- a/packages/data-layer/src/monitor_data/middleware/auth.py +++ b/packages/data-layer/src/monitor_data/middleware/auth.py @@ -114,11 +114,6 @@ "mongodb_list_proposed_changes": ["*"], "mongodb_update_proposed_change": ["CanonKeeper"], # ========================================================================= - # MONGODB OPERATIONS - Resolutions - # ========================================================================= - "mongodb_create_resolution": ["Resolver"], - "mongodb_get_resolution": ["*"], - # ========================================================================= # MONGODB OPERATIONS - Memories # ========================================================================= "mongodb_create_memory": ["MemoryManager"], @@ -185,6 +180,14 @@ "mongodb_add_combat_log_entry": ["Orchestrator", "CanonKeeper"], "mongodb_set_combat_outcome": ["Orchestrator", "CanonKeeper"], # ========================================================================= + # MONGODB OPERATIONS - Resolutions (DL-24) + # ========================================================================= + "mongodb_create_resolution": ["Orchestrator", "CanonKeeper"], + "mongodb_get_resolution": ["*"], + "mongodb_list_resolutions": ["*"], + "mongodb_update_resolution": ["Orchestrator", "CanonKeeper"], + "mongodb_delete_resolution": ["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 c903c7d..07dad1e 100644 --- a/packages/data-layer/src/monitor_data/schemas/__init__.py +++ b/packages/data-layer/src/monitor_data/schemas/__init__.py @@ -49,6 +49,23 @@ MultiverseUpdate, MultiverseResponse, ) +from monitor_data.schemas.resolutions import ( + ActionType, + ResolutionType, + SuccessLevel, + EffectType, + Modifier, + RollResult, + ContestedRoll, + CardDraw, + Mechanics, + Effect, + ResolutionCreate, + ResolutionUpdate, + ResolutionResponse, + ResolutionFilter, + ResolutionListResponse, +) # from monitor_data.schemas.entities import * # from monitor_data.schemas.facts import * @@ -85,4 +102,20 @@ "MultiverseCreate", "MultiverseUpdate", "MultiverseResponse", + # Resolution schemas + "ActionType", + "ResolutionType", + "SuccessLevel", + "EffectType", + "Modifier", + "RollResult", + "ContestedRoll", + "CardDraw", + "Mechanics", + "Effect", + "ResolutionCreate", + "ResolutionUpdate", + "ResolutionResponse", + "ResolutionFilter", + "ResolutionListResponse", ] diff --git a/packages/data-layer/src/monitor_data/schemas/resolutions.py b/packages/data-layer/src/monitor_data/schemas/resolutions.py new file mode 100644 index 0000000..c7ce86b --- /dev/null +++ b/packages/data-layer/src/monitor_data/schemas/resolutions.py @@ -0,0 +1,267 @@ +""" +Pydantic schemas for Turn Resolution operations (DL-24). + +LAYER: 1 (data-layer) +IMPORTS FROM: External libraries (pydantic, uuid, datetime, enum) and base schemas +CALLED BY: mongodb_tools.py + +These schemas define the data contracts for storing mechanical resolution records +for player/NPC actions during gameplay. Pure data storage - resolution logic (dice +rolling, success evaluation) lives in the agents layer. +""" + +from datetime import datetime +from enum import Enum +from typing import Optional, List, Dict, Any +from uuid import UUID + +from pydantic import BaseModel, Field + + +# ============================================================================= +# ENUMS +# ============================================================================= + + +class ActionType(str, Enum): + """Type of action being resolved.""" + + COMBAT = "combat" + SKILL = "skill" + SOCIAL = "social" + EXPLORATION = "exploration" + MAGIC = "magic" + OTHER = "other" + + +class ResolutionType(str, Enum): + """Mechanism used for resolution.""" + + DICE = "dice" + CARD = "card" + NARRATIVE = "narrative" + DETERMINISTIC = "deterministic" + CONTESTED = "contested" + + +class SuccessLevel(str, Enum): + """Outcome level of the resolution.""" + + CRITICAL_SUCCESS = "critical_success" + SUCCESS = "success" + PARTIAL_SUCCESS = "partial_success" + FAILURE = "failure" + CRITICAL_FAILURE = "critical_failure" + + +class EffectType(str, Enum): + """Type of effect applied by a resolution.""" + + DAMAGE = "damage" + HEALING = "healing" + CONDITION = "condition" + BUFF = "buff" + DEBUFF = "debuff" + RESOURCE_CHANGE = "resource_change" + STAT_CHANGE = "stat_change" + POSITION_CHANGE = "position_change" + OTHER = "other" + + +# ============================================================================= +# MECHANICS SCHEMAS +# ============================================================================= + + +class Modifier(BaseModel): + """A modifier applied to a roll or check.""" + + source: str = Field(max_length=200, description="What provides this modifier") + value: int = Field(description="Numeric modifier value") + reason: str = Field( + max_length=500, description="Why this modifier applies (for audit trail)" + ) + + +class RollResult(BaseModel): + """Result of a dice roll.""" + + raw_rolls: List[int] = Field(description="All dice rolled (before keep/drop logic)") + kept_rolls: List[int] = Field( + default_factory=list, + description="Dice kept after keep/drop logic (may equal raw_rolls)", + ) + total: int = Field(description="Final total after modifiers") + natural: int = Field( + default=0, + description="Total of dice only, before modifiers (for critical detection)", + ) + critical: bool = Field(default=False, description="Whether this was a critical") + fumble: bool = Field(default=False, description="Whether this was a fumble/botch") + + +class ContestedRoll(BaseModel): + """Data for a contested resolution (opposed rolls).""" + + opponent_id: UUID = Field(description="Entity ID of the opponent") + opponent_roll: RollResult + opponent_modifiers: List[Modifier] = Field(default_factory=list) + margin_of_victory: int = Field( + description="Difference between winner and loser totals" + ) + + +class CardDraw(BaseModel): + """Data for card-based resolution.""" + + cards_drawn: List[str] = Field( + description="Cards drawn (suit and rank, e.g. 'Hearts-King')" + ) + total_value: int = Field(description="Numeric value of the draw") + special: Optional[str] = Field( + None, max_length=200, description="Special result (e.g., 'Red Joker')" + ) + + +class Mechanics(BaseModel): + """Mechanical details of the resolution.""" + + game_system_id: Optional[UUID] = Field( + None, description="Reference to game system rules (DL-20)" + ) + formula: str = Field( + max_length=200, description="Formula used (e.g., '2d20kh1+5 vs DC 15')" + ) + modifiers: List[Modifier] = Field( + default_factory=list, description="All modifiers applied" + ) + target: Optional[int] = Field(None, description="Target number or DC if applicable") + roll: Optional[RollResult] = Field( + None, description="Roll result for dice-based resolutions" + ) + contested: Optional[ContestedRoll] = Field( + None, description="Opposed roll data for contested resolutions" + ) + card_draw: Optional[CardDraw] = Field( + None, description="Card draw data for card-based resolutions" + ) + + +# ============================================================================= +# EFFECT SCHEMAS +# ============================================================================= + + +class Effect(BaseModel): + """An effect applied as a result of the resolution.""" + + effect_type: EffectType + target_id: UUID = Field(description="Entity affected by this effect") + magnitude: int = Field( + default=0, description="Numeric magnitude (damage, healing, etc.)" + ) + damage_type: Optional[str] = Field( + None, max_length=100, description="Type of damage (fire, cold, etc.)" + ) + condition: Optional[str] = Field( + None, max_length=100, description="Condition applied (stunned, prone, etc.)" + ) + duration: Optional[int] = Field( + None, ge=0, description="Duration in rounds/turns if applicable" + ) + description: str = Field( + max_length=500, description="Human-readable description of the effect" + ) + metadata: Dict[str, Any] = Field( + default_factory=dict, description="Additional effect-specific data" + ) + + +# ============================================================================= +# RESOLUTION CRUD SCHEMAS +# ============================================================================= + + +class ResolutionCreate(BaseModel): + """Request to create a resolution record.""" + + turn_id: UUID + scene_id: UUID + story_id: UUID + actor_id: UUID = Field(description="Entity performing the action") + action: str = Field( + max_length=500, description="Description of the action attempted" + ) + action_type: ActionType + resolution_type: ResolutionType + mechanics: Mechanics + success_level: SuccessLevel + margin: Optional[int] = Field( + None, description="Margin of success/failure if applicable" + ) + effects: List[Effect] = Field( + default_factory=list, description="Effects applied by this resolution" + ) + description: Optional[str] = Field( + None, max_length=1000, description="Narrative description of the outcome" + ) + gm_notes: Optional[str] = Field( + None, max_length=1000, description="GM-only notes about the resolution" + ) + + +class ResolutionUpdate(BaseModel): + """Request to update a resolution record.""" + + effects: Optional[List[Effect]] = None + description: Optional[str] = Field(None, max_length=1000) + gm_notes: Optional[str] = Field(None, max_length=1000) + + +class ResolutionResponse(BaseModel): + """Response with resolution data.""" + + id: UUID + turn_id: UUID + scene_id: UUID + story_id: UUID + actor_id: UUID + action: str + action_type: ActionType + resolution_type: ResolutionType + mechanics: Mechanics + success_level: SuccessLevel + margin: Optional[int] + effects: List[Effect] + description: Optional[str] + gm_notes: Optional[str] + created_at: datetime + updated_at: Optional[datetime] + + model_config = {"from_attributes": True} + + +# ============================================================================= +# QUERY SCHEMAS +# ============================================================================= + + +class ResolutionFilter(BaseModel): + """Filter parameters for listing resolutions.""" + + scene_id: Optional[UUID] = None + turn_id: Optional[UUID] = None + actor_id: Optional[UUID] = None + action_type: Optional[ActionType] = None + success_level: Optional[SuccessLevel] = None + limit: int = Field(default=50, ge=1, le=100) + offset: int = Field(default=0, ge=0) + + +class ResolutionListResponse(BaseModel): + """Response for list operations.""" + + resolutions: List[ResolutionResponse] + 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 770354e..14b05aa 100644 --- a/packages/data-layer/src/monitor_data/tools/mongodb_tools.py +++ b/packages/data-layer/src/monitor_data/tools/mongodb_tools.py @@ -67,6 +67,13 @@ CombatOutcome, Condition, ) +from monitor_data.schemas.resolutions import ( + ResolutionCreate, + ResolutionUpdate, + ResolutionResponse, + ResolutionFilter, + ResolutionListResponse, +) # ============================================================================= @@ -1700,3 +1707,254 @@ def mongodb_set_combat_outcome(params: SetCombatOutcome) -> CombatResponse: ) return updated + + +# ============================================================================= +# RESOLUTION TOOLS (DL-24) +# ============================================================================= + + +def _convert_resolution_doc_to_response( + resolution_doc: Dict[str, Any], +) -> ResolutionResponse: + """ + Convert a resolution document from MongoDB to a ResolutionResponse object. + + Args: + resolution_doc: Resolution data from MongoDB + + Returns: + ResolutionResponse object + """ + from monitor_data.schemas.resolutions import ( + ActionType, + ResolutionType, + SuccessLevel, + Mechanics, + Effect, + ) + + return ResolutionResponse( + id=UUID(resolution_doc["resolution_id"]), + turn_id=UUID(resolution_doc["turn_id"]), + scene_id=UUID(resolution_doc["scene_id"]), + story_id=UUID(resolution_doc["story_id"]), + actor_id=UUID(resolution_doc["actor_id"]), + action=resolution_doc["action"], + action_type=ActionType(resolution_doc["action_type"]), + resolution_type=ResolutionType(resolution_doc["resolution_type"]), + mechanics=Mechanics(**resolution_doc["mechanics"]), + success_level=SuccessLevel(resolution_doc["success_level"]), + margin=resolution_doc.get("margin"), + effects=[Effect(**e) for e in resolution_doc.get("effects", [])], + description=resolution_doc.get("description"), + gm_notes=resolution_doc.get("gm_notes"), + created_at=resolution_doc["created_at"], + updated_at=resolution_doc.get("updated_at"), + ) + + +def mongodb_create_resolution(params: ResolutionCreate) -> ResolutionResponse: + """ + Create a new resolution record. + + Args: + params: Resolution creation parameters + + Returns: + ResolutionResponse with created resolution data + + Raises: + ValueError: If turn_id, scene_id, or story_id doesn't exist + """ + mongodb = get_mongodb_client() + resolutions_collection = mongodb.get_collection("resolutions") + + # Validate turn 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 turn exists in the scene + turn_found = False + for turn in scene.get("turns", []): + if turn.get("turn_id") == str(params.turn_id): + turn_found = True + break + if not turn_found: + raise ValueError(f"Turn {params.turn_id} not found in scene {params.scene_id}") + + # Validate story exists (via Neo4j) + neo4j_client = get_neo4j_client() + story_exists = neo4j_client.execute_read( + "MATCH (s:Story {id: $story_id}) RETURN s.id AS story_id", + {"story_id": str(params.story_id)}, + ) + if not story_exists: + raise ValueError(f"Story {params.story_id} not found") + + now = datetime.now(timezone.utc) + resolution_id = uuid4() + + resolution_doc = { + "resolution_id": str(resolution_id), + "turn_id": str(params.turn_id), + "scene_id": str(params.scene_id), + "story_id": str(params.story_id), + "actor_id": str(params.actor_id), + "action": params.action, + "action_type": params.action_type.value, + "resolution_type": params.resolution_type.value, + "mechanics": params.mechanics.model_dump(mode="json"), + "success_level": params.success_level.value, + "margin": params.margin, + "effects": [e.model_dump(mode="json") for e in params.effects], + "description": params.description, + "gm_notes": params.gm_notes, + "created_at": now, + "updated_at": None, + } + + resolutions_collection.insert_one(resolution_doc) + + return _convert_resolution_doc_to_response(resolution_doc) + + +def mongodb_get_resolution(resolution_id: UUID) -> Optional[ResolutionResponse]: + """ + Get a resolution by ID. + + Args: + resolution_id: Resolution UUID + + Returns: + ResolutionResponse or None if not found + """ + mongodb = get_mongodb_client() + resolutions_collection = mongodb.get_collection("resolutions") + + resolution_doc = resolutions_collection.find_one( + {"resolution_id": str(resolution_id)} + ) + if not resolution_doc: + return None + + return _convert_resolution_doc_to_response(resolution_doc) + + +def mongodb_list_resolutions(params: ResolutionFilter) -> ResolutionListResponse: + """ + List resolutions with filtering. + + Args: + params: Filter parameters + + Returns: + ResolutionListResponse with matching resolutions + """ + mongodb = get_mongodb_client() + resolutions_collection = mongodb.get_collection("resolutions") + + # Build query + query: Dict[str, Any] = {} + if params.scene_id: + query["scene_id"] = str(params.scene_id) + if params.turn_id: + query["turn_id"] = str(params.turn_id) + if params.actor_id: + query["actor_id"] = str(params.actor_id) + if params.action_type: + query["action_type"] = params.action_type.value + if params.success_level: + query["success_level"] = params.success_level.value + + # Count total + total = resolutions_collection.count_documents(query) + + # Get page + cursor = ( + resolutions_collection.find(query) + .sort("created_at", -1) + .skip(params.offset) + .limit(params.limit) + ) + + resolutions = [_convert_resolution_doc_to_response(doc) for doc in cursor] + + return ResolutionListResponse( + resolutions=resolutions, + total=total, + limit=params.limit, + offset=params.offset, + ) + + +def mongodb_update_resolution( + resolution_id: UUID, params: ResolutionUpdate +) -> ResolutionResponse: + """ + Update a resolution record. + + Args: + resolution_id: Resolution UUID + params: Fields to update + + Returns: + Updated ResolutionResponse + + Raises: + ValueError: If resolution not found + """ + mongodb = get_mongodb_client() + resolutions_collection = mongodb.get_collection("resolutions") + + # Build update dict + update_dict: Dict[str, Any] = {} + if params.effects is not None: + update_dict["effects"] = [e.model_dump(mode="json") for e in params.effects] + if params.description is not None: + update_dict["description"] = params.description + if params.gm_notes is not None: + update_dict["gm_notes"] = params.gm_notes + + if not update_dict: + # No updates provided, just return current state + resolution = mongodb_get_resolution(resolution_id) + if not resolution: + raise ValueError(f"Resolution {resolution_id} not found") + return resolution + + now = datetime.now(timezone.utc) + update_dict["updated_at"] = now + + result = resolutions_collection.update_one( + {"resolution_id": str(resolution_id)}, {"$set": update_dict} + ) + + if result.matched_count == 0: + raise ValueError(f"Resolution {resolution_id} not found") + + updated = mongodb_get_resolution(resolution_id) + if not updated: + raise ValueError(f"Resolution {resolution_id} not found after update") + + return updated + + +def mongodb_delete_resolution(resolution_id: UUID) -> bool: + """ + Delete a resolution record. + + Args: + resolution_id: Resolution UUID + + Returns: + True if deleted, False if not found + """ + mongodb = get_mongodb_client() + resolutions_collection = mongodb.get_collection("resolutions") + + result = resolutions_collection.delete_one({"resolution_id": str(resolution_id)}) + + return result.deleted_count > 0 diff --git a/packages/data-layer/tests/test_tools/test_resolution_tools.py b/packages/data-layer/tests/test_tools/test_resolution_tools.py new file mode 100644 index 0000000..ff67b54 --- /dev/null +++ b/packages/data-layer/tests/test_tools/test_resolution_tools.py @@ -0,0 +1,868 @@ +""" +Tests for Resolution MongoDB tools (DL-24). + +Tests all resolution CRUD operations including dice rolls, +contested resolutions, card draws, and effects tracking. +""" + +from datetime import datetime, timezone +from unittest.mock import Mock, MagicMock, patch +from uuid import uuid4 + +import pytest + +from monitor_data.schemas.resolutions import ( + ResolutionCreate, + ResolutionUpdate, + ResolutionFilter, + ActionType, + ResolutionType, + SuccessLevel, + EffectType, + Modifier, + RollResult, + ContestedRoll, + CardDraw, + Mechanics, + Effect, +) +from monitor_data.tools.mongodb_tools import ( + mongodb_create_resolution, + mongodb_get_resolution, + mongodb_list_resolutions, + mongodb_update_resolution, + mongodb_delete_resolution, +) + + +# ============================================================================= +# TEST: mongodb_create_resolution +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_resolution_success( + mock_get_mongodb: Mock, + mock_get_neo4j: Mock, +): + """Test creating a resolution record.""" + turn_id = uuid4() + scene_id = uuid4() + story_id = uuid4() + actor_id = uuid4() + resolution_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + mock_scenes = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.side_effect = lambda name: ( + mock_resolutions if name == "resolutions" else mock_scenes + ) + + # Mock scene with turn + mock_scenes.find_one.return_value = { + "scene_id": str(scene_id), + "turns": [{"turn_id": str(turn_id)}], + } + + # Mock Neo4j + mock_neo4j = MagicMock() + mock_get_neo4j.return_value = mock_neo4j + mock_neo4j.execute_read.return_value = [{"story_id": str(story_id)}] + + # Test data - simple dice roll + mechanics = Mechanics( + formula="1d20+5 vs DC 15", + modifiers=[ + Modifier(source="Strength", value=3, reason="Strength modifier"), + Modifier(source="Proficiency", value=2, reason="Proficiency bonus"), + ], + target=15, + roll=RollResult( + raw_rolls=[18], + kept_rolls=[18], + total=23, + natural=18, + critical=False, + fumble=False, + ), + ) + + effects = [ + Effect( + effect_type=EffectType.DAMAGE, + target_id=uuid4(), + magnitude=10, + damage_type="slashing", + description="Sword damage", + ) + ] + + params = ResolutionCreate( + turn_id=turn_id, + scene_id=scene_id, + story_id=story_id, + actor_id=actor_id, + action="Fighter attacks with longsword", + action_type=ActionType.COMBAT, + resolution_type=ResolutionType.DICE, + mechanics=mechanics, + success_level=SuccessLevel.SUCCESS, + margin=8, + effects=effects, + description="The longsword strikes true, dealing 10 damage", + ) + + with patch("monitor_data.tools.mongodb_tools.uuid4", return_value=resolution_id): + result = mongodb_create_resolution(params) + + assert result.id == resolution_id + assert result.turn_id == turn_id + assert result.scene_id == scene_id + assert result.story_id == story_id + assert result.actor_id == actor_id + assert result.action == "Fighter attacks with longsword" + assert result.action_type == ActionType.COMBAT + assert result.resolution_type == ResolutionType.DICE + assert result.success_level == SuccessLevel.SUCCESS + assert result.margin == 8 + assert len(result.effects) == 1 + assert result.mechanics.roll.total == 23 + mock_resolutions.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_resolution_scene_not_found( + mock_get_mongodb: Mock, + mock_get_neo4j: Mock, +): + """Test creating resolution with invalid scene_id.""" + turn_id = uuid4() + scene_id = uuid4() + + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + mock_scenes = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.side_effect = lambda name: ( + mock_resolutions if name == "resolutions" else mock_scenes + ) + + # Scene does not exist + mock_scenes.find_one.return_value = None + + params = ResolutionCreate( + turn_id=turn_id, + scene_id=scene_id, + story_id=uuid4(), + actor_id=uuid4(), + action="Test action", + action_type=ActionType.SKILL, + resolution_type=ResolutionType.DICE, + mechanics=Mechanics(formula="1d20", target=10), + success_level=SuccessLevel.SUCCESS, + ) + + with pytest.raises(ValueError, match=f"Scene {scene_id} not found"): + mongodb_create_resolution(params) + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_resolution_turn_not_found( + mock_get_mongodb: Mock, + mock_get_neo4j: Mock, +): + """Test creating resolution with invalid turn_id.""" + turn_id = uuid4() + scene_id = uuid4() + + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + mock_scenes = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.side_effect = lambda name: ( + mock_resolutions if name == "resolutions" else mock_scenes + ) + + # Scene exists but turn doesn't + mock_scenes.find_one.return_value = { + "scene_id": str(scene_id), + "turns": [{"turn_id": str(uuid4())}], # Different turn_id + } + + params = ResolutionCreate( + turn_id=turn_id, + scene_id=scene_id, + story_id=uuid4(), + actor_id=uuid4(), + action="Test action", + action_type=ActionType.SKILL, + resolution_type=ResolutionType.DICE, + mechanics=Mechanics(formula="1d20", target=10), + success_level=SuccessLevel.SUCCESS, + ) + + with pytest.raises( + ValueError, match=f"Turn {turn_id} not found in scene {scene_id}" + ): + mongodb_create_resolution(params) + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_resolution_story_not_found( + mock_get_mongodb: Mock, + mock_get_neo4j: Mock, +): + """Test creating resolution with invalid story_id.""" + turn_id = uuid4() + scene_id = uuid4() + story_id = uuid4() + + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + mock_scenes = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.side_effect = lambda name: ( + mock_resolutions if name == "resolutions" else mock_scenes + ) + + # Scene and turn exist + mock_scenes.find_one.return_value = { + "scene_id": str(scene_id), + "turns": [{"turn_id": str(turn_id)}], + } + + # Story doesn't exist in Neo4j + mock_get_neo4j.return_value = MagicMock(execute_read=MagicMock(return_value=[])) + + params = ResolutionCreate( + turn_id=turn_id, + scene_id=scene_id, + story_id=story_id, + actor_id=uuid4(), + action="Test action", + action_type=ActionType.SKILL, + resolution_type=ResolutionType.DICE, + mechanics=Mechanics(formula="1d20", target=10), + success_level=SuccessLevel.SUCCESS, + ) + + with pytest.raises(ValueError, match=f"Story {story_id} not found"): + mongodb_create_resolution(params) + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_resolution_contested( + mock_get_mongodb: Mock, + mock_get_neo4j: Mock, +): + """Test creating a contested resolution.""" + turn_id = uuid4() + scene_id = uuid4() + story_id = uuid4() + actor_id = uuid4() + opponent_id = uuid4() + resolution_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + mock_scenes = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.side_effect = lambda name: ( + mock_resolutions if name == "resolutions" else mock_scenes + ) + + mock_scenes.find_one.return_value = { + "scene_id": str(scene_id), + "turns": [{"turn_id": str(turn_id)}], + } + + mock_neo4j = MagicMock() + mock_get_neo4j.return_value = mock_neo4j + mock_neo4j.execute_read.return_value = [{"story_id": str(story_id)}] + + # Contested roll data + mechanics = Mechanics( + formula="1d20+3 vs 1d20+2", + modifiers=[Modifier(source="Athletics", value=3, reason="Athletics skill")], + roll=RollResult(raw_rolls=[15], kept_rolls=[15], total=18, natural=15), + contested=ContestedRoll( + opponent_id=opponent_id, + opponent_roll=RollResult( + raw_rolls=[12], kept_rolls=[12], total=14, natural=12 + ), + opponent_modifiers=[ + Modifier(source="Athletics", value=2, reason="Athletics skill") + ], + margin_of_victory=4, + ), + ) + + params = ResolutionCreate( + turn_id=turn_id, + scene_id=scene_id, + story_id=story_id, + actor_id=actor_id, + action="Grapple attempt", + action_type=ActionType.COMBAT, + resolution_type=ResolutionType.CONTESTED, + mechanics=mechanics, + success_level=SuccessLevel.SUCCESS, + margin=4, + effects=[ + Effect( + effect_type=EffectType.CONDITION, + target_id=opponent_id, + magnitude=0, + condition="grappled", + description="Target is grappled", + ) + ], + ) + + with patch("monitor_data.tools.mongodb_tools.uuid4", return_value=resolution_id): + result = mongodb_create_resolution(params) + + assert result.resolution_type == ResolutionType.CONTESTED + assert result.mechanics.contested is not None + assert result.mechanics.contested.opponent_id == opponent_id + assert result.mechanics.contested.margin_of_victory == 4 + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_resolution_card_draw( + mock_get_mongodb: Mock, + mock_get_neo4j: Mock, +): + """Test creating a card-based resolution.""" + turn_id = uuid4() + scene_id = uuid4() + story_id = uuid4() + resolution_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + mock_scenes = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.side_effect = lambda name: ( + mock_resolutions if name == "resolutions" else mock_scenes + ) + + mock_scenes.find_one.return_value = { + "scene_id": str(scene_id), + "turns": [{"turn_id": str(turn_id)}], + } + + mock_neo4j = MagicMock() + mock_get_neo4j.return_value = mock_neo4j + mock_neo4j.execute_read.return_value = [{"story_id": str(story_id)}] + + # Card draw data + mechanics = Mechanics( + formula="Draw 2 cards, highest wins", + card_draw=CardDraw( + cards_drawn=["Hearts-King", "Spades-Queen"], + total_value=23, # King=13 + Queen=10 + special="High card: King of Hearts", + ), + ) + + params = ResolutionCreate( + turn_id=turn_id, + scene_id=scene_id, + story_id=story_id, + actor_id=uuid4(), + action="Initiative draw", + action_type=ActionType.OTHER, + resolution_type=ResolutionType.CARD, + mechanics=mechanics, + success_level=SuccessLevel.SUCCESS, + ) + + with patch("monitor_data.tools.mongodb_tools.uuid4", return_value=resolution_id): + result = mongodb_create_resolution(params) + + assert result.resolution_type == ResolutionType.CARD + assert result.mechanics.card_draw is not None + assert len(result.mechanics.card_draw.cards_drawn) == 2 + assert result.mechanics.card_draw.total_value == 23 + + +# ============================================================================= +# TEST: mongodb_get_resolution +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_resolution_success(mock_get_mongodb: Mock): + """Test getting a resolution by ID.""" + resolution_id = uuid4() + + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_resolutions + + # Mock resolution document + resolution_doc = { + "resolution_id": str(resolution_id), + "turn_id": str(uuid4()), + "scene_id": str(uuid4()), + "story_id": str(uuid4()), + "actor_id": str(uuid4()), + "action": "Attack", + "action_type": "combat", + "resolution_type": "dice", + "mechanics": { + "formula": "1d20+5", + "modifiers": [], + "target": 15, + "roll": { + "raw_rolls": [12], + "kept_rolls": [12], + "total": 17, + "natural": 12, + "critical": False, + "fumble": False, + }, + }, + "success_level": "success", + "margin": 2, + "effects": [], + "description": None, + "gm_notes": None, + "created_at": datetime.now(timezone.utc), + "updated_at": None, + } + + mock_resolutions.find_one.return_value = resolution_doc + + result = mongodb_get_resolution(resolution_id) + + assert result is not None + assert result.id == resolution_id + assert result.action == "Attack" + assert result.success_level == SuccessLevel.SUCCESS + mock_resolutions.find_one.assert_called_once_with( + {"resolution_id": str(resolution_id)} + ) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_resolution_not_found(mock_get_mongodb: Mock): + """Test getting non-existent resolution.""" + resolution_id = uuid4() + + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_resolutions + mock_resolutions.find_one.return_value = None + + result = mongodb_get_resolution(resolution_id) + + assert result is None + + +# ============================================================================= +# TEST: mongodb_list_resolutions +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_resolutions_all(mock_get_mongodb: Mock): + """Test listing all resolutions.""" + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_resolutions + + # Mock count and find + mock_resolutions.count_documents.return_value = 2 + mock_cursor = MagicMock() + mock_resolutions.find.return_value = mock_cursor + mock_cursor.sort.return_value = mock_cursor + mock_cursor.skip.return_value = mock_cursor + mock_cursor.limit.return_value = [ + { + "resolution_id": str(uuid4()), + "turn_id": str(uuid4()), + "scene_id": str(uuid4()), + "story_id": str(uuid4()), + "actor_id": str(uuid4()), + "action": "Action 1", + "action_type": "combat", + "resolution_type": "dice", + "mechanics": {"formula": "1d20", "modifiers": []}, + "success_level": "success", + "margin": None, + "effects": [], + "created_at": datetime.now(timezone.utc), + }, + { + "resolution_id": str(uuid4()), + "turn_id": str(uuid4()), + "scene_id": str(uuid4()), + "story_id": str(uuid4()), + "actor_id": str(uuid4()), + "action": "Action 2", + "action_type": "skill", + "resolution_type": "dice", + "mechanics": {"formula": "1d20", "modifiers": []}, + "success_level": "failure", + "margin": None, + "effects": [], + "created_at": datetime.now(timezone.utc), + }, + ] + + params = ResolutionFilter() + result = mongodb_list_resolutions(params) + + assert len(result.resolutions) == 2 + assert result.total == 2 + mock_resolutions.count_documents.assert_called_once_with({}) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_resolutions_by_scene(mock_get_mongodb: Mock): + """Test listing resolutions filtered by scene_id.""" + scene_id = uuid4() + + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_resolutions + + mock_resolutions.count_documents.return_value = 1 + mock_cursor = MagicMock() + mock_resolutions.find.return_value = mock_cursor + mock_cursor.sort.return_value = mock_cursor + mock_cursor.skip.return_value = mock_cursor + mock_cursor.limit.return_value = [ + { + "resolution_id": str(uuid4()), + "turn_id": str(uuid4()), + "scene_id": str(scene_id), + "story_id": str(uuid4()), + "actor_id": str(uuid4()), + "action": "Scene action", + "action_type": "combat", + "resolution_type": "dice", + "mechanics": {"formula": "1d20", "modifiers": []}, + "success_level": "success", + "margin": None, + "effects": [], + "created_at": datetime.now(timezone.utc), + } + ] + + params = ResolutionFilter(scene_id=scene_id) + result = mongodb_list_resolutions(params) + + assert len(result.resolutions) == 1 + assert result.resolutions[0].scene_id == scene_id + mock_resolutions.count_documents.assert_called_once_with( + {"scene_id": str(scene_id)} + ) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_resolutions_by_turn(mock_get_mongodb: Mock): + """Test listing resolutions filtered by turn_id.""" + turn_id = uuid4() + + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_resolutions + + mock_resolutions.count_documents.return_value = 1 + mock_cursor = MagicMock() + mock_resolutions.find.return_value = mock_cursor + mock_cursor.sort.return_value = mock_cursor + mock_cursor.skip.return_value = mock_cursor + mock_cursor.limit.return_value = [] + + params = ResolutionFilter(turn_id=turn_id) + mongodb_list_resolutions(params) + + mock_resolutions.count_documents.assert_called_once_with({"turn_id": str(turn_id)}) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_resolutions_by_actor(mock_get_mongodb: Mock): + """Test listing resolutions filtered by actor_id.""" + actor_id = uuid4() + + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_resolutions + + mock_resolutions.count_documents.return_value = 0 + mock_cursor = MagicMock() + mock_resolutions.find.return_value = mock_cursor + mock_cursor.sort.return_value = mock_cursor + mock_cursor.skip.return_value = mock_cursor + mock_cursor.limit.return_value = [] + + params = ResolutionFilter(actor_id=actor_id) + mongodb_list_resolutions(params) + + mock_resolutions.count_documents.assert_called_once_with( + {"actor_id": str(actor_id)} + ) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_resolutions_by_action_type(mock_get_mongodb: Mock): + """Test listing resolutions filtered by action_type.""" + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_resolutions + + mock_resolutions.count_documents.return_value = 0 + mock_cursor = MagicMock() + mock_resolutions.find.return_value = mock_cursor + mock_cursor.sort.return_value = mock_cursor + mock_cursor.skip.return_value = mock_cursor + mock_cursor.limit.return_value = [] + + params = ResolutionFilter(action_type=ActionType.COMBAT) + mongodb_list_resolutions(params) + + mock_resolutions.count_documents.assert_called_once_with({"action_type": "combat"}) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_resolutions_by_success_level(mock_get_mongodb: Mock): + """Test listing resolutions filtered by success_level.""" + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_resolutions + + mock_resolutions.count_documents.return_value = 0 + mock_cursor = MagicMock() + mock_resolutions.find.return_value = mock_cursor + mock_cursor.sort.return_value = mock_cursor + mock_cursor.skip.return_value = mock_cursor + mock_cursor.limit.return_value = [] + + params = ResolutionFilter(success_level=SuccessLevel.CRITICAL_SUCCESS) + mongodb_list_resolutions(params) + + mock_resolutions.count_documents.assert_called_once_with( + {"success_level": "critical_success"} + ) + + +# ============================================================================= +# TEST: mongodb_update_resolution +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_resolution_success(mock_get_mongodb: Mock): + """Test updating a resolution.""" + resolution_id = uuid4() + target_id = uuid4() + + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_resolutions + + # Mock update result + mock_result = MagicMock() + mock_result.matched_count = 1 + mock_resolutions.update_one.return_value = mock_result + + # Mock get after update + updated_doc = { + "resolution_id": str(resolution_id), + "turn_id": str(uuid4()), + "scene_id": str(uuid4()), + "story_id": str(uuid4()), + "actor_id": str(uuid4()), + "action": "Attack", + "action_type": "combat", + "resolution_type": "dice", + "mechanics": {"formula": "1d20", "modifiers": []}, + "success_level": "success", + "margin": None, + "effects": [ + { + "effect_type": "damage", + "target_id": str(target_id), + "magnitude": 15, + "description": "Updated damage", + } + ], + "description": "Updated description", + "gm_notes": "Secret notes", + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + } + mock_resolutions.find_one.return_value = updated_doc + + # Update with new effects + new_effects = [ + Effect( + effect_type=EffectType.DAMAGE, + target_id=target_id, + magnitude=15, + description="Updated damage", + ) + ] + + params = ResolutionUpdate( + effects=new_effects, + description="Updated description", + gm_notes="Secret notes", + ) + + result = mongodb_update_resolution(resolution_id, params) + + assert result.id == resolution_id + assert len(result.effects) == 1 + assert result.effects[0].magnitude == 15 + assert result.description == "Updated description" + assert result.gm_notes == "Secret notes" + mock_resolutions.update_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_resolution_not_found(mock_get_mongodb: Mock): + """Test updating non-existent resolution.""" + resolution_id = uuid4() + + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_resolutions + + mock_result = MagicMock() + mock_result.matched_count = 0 + mock_resolutions.update_one.return_value = mock_result + + params = ResolutionUpdate(description="New description") + + with pytest.raises(ValueError, match=f"Resolution {resolution_id} not found"): + mongodb_update_resolution(resolution_id, params) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_resolution_no_changes(mock_get_mongodb: Mock): + """Test updating resolution with no changes.""" + resolution_id = uuid4() + + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_resolutions + + # Mock current state + current_doc = { + "resolution_id": str(resolution_id), + "turn_id": str(uuid4()), + "scene_id": str(uuid4()), + "story_id": str(uuid4()), + "actor_id": str(uuid4()), + "action": "Attack", + "action_type": "combat", + "resolution_type": "dice", + "mechanics": {"formula": "1d20", "modifiers": []}, + "success_level": "success", + "margin": None, + "effects": [], + "description": None, + "gm_notes": None, + "created_at": datetime.now(timezone.utc), + "updated_at": None, + } + mock_resolutions.find_one.return_value = current_doc + + params = ResolutionUpdate() # No updates + result = mongodb_update_resolution(resolution_id, params) + + assert result.id == resolution_id + # Should not call update_one when no changes + mock_resolutions.update_one.assert_not_called() + + +# ============================================================================= +# TEST: mongodb_delete_resolution +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_delete_resolution_success(mock_get_mongodb: Mock): + """Test deleting a resolution.""" + resolution_id = uuid4() + + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_resolutions + + mock_result = MagicMock() + mock_result.deleted_count = 1 + mock_resolutions.delete_one.return_value = mock_result + + result = mongodb_delete_resolution(resolution_id) + + assert result is True + mock_resolutions.delete_one.assert_called_once_with( + {"resolution_id": str(resolution_id)} + ) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_delete_resolution_not_found(mock_get_mongodb: Mock): + """Test deleting non-existent resolution.""" + resolution_id = uuid4() + + mock_mongodb = MagicMock() + mock_resolutions = MagicMock() + + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_resolutions + + mock_result = MagicMock() + mock_result.deleted_count = 0 + mock_resolutions.delete_one.return_value = mock_result + + result = mongodb_delete_resolution(resolution_id) + + assert result is False