From b31663fa3f0c9f80e6216c232b37f809dc4365a6 Mon Sep 17 00:00:00 2001 From: spuentesp Date: Wed, 7 Jan 2026 00:58:59 -0300 Subject: [PATCH 1/3] feat(data-layer): DL-20 - Game Systems & Rules Management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements MongoDB CRUD for game system definitions and rule overrides. Pure data storage - rule execution logic lives in agents layer. ## Game Systems (MongoDB) New schemas (game_systems.py): - CoreMechanicType, SuccessType, RuleOverrideScope enums - CoreMechanic: type, formula, success_type, critical rules - AttributeDefinition, SkillDefinition, ResourceDefinition - GameSystemCreate/Update/Response schemas - Built-in system protection (is_builtin flag) MongoDB tools (mongodb_tools.py): - mongodb_create_game_system: Create custom systems - mongodb_get_game_system: Retrieve system by ID - mongodb_list_game_systems: List with builtin filtering - mongodb_update_game_system: Update custom systems only - mongodb_delete_game_system: Delete custom systems only - _ensure_builtin_systems_seeded: Auto-seed on first access - _load_builtin_game_systems: Load from JSON seed file ## Rule Overrides (MongoDB) New schemas (game_systems.py): - RuleOverrideCreate/Update/Response - Scopes: one_time, scene, story, universe - Tracking: times_used, active status MongoDB tools (mongodb_tools.py): - mongodb_create_rule_override: Create scoped overrides - mongodb_get_rule_override: Retrieve by ID - mongodb_list_rule_overrides: Filter by scope/ID/active - mongodb_update_rule_override: Update usage/status - mongodb_delete_rule_override: Remove override ## Built-in Systems Seed Data (builtin_systems.json) Three pre-configured systems: 1. D&D 5e: d20 system with 6 attributes, 18 skills, HP/AC 2. Fate Core: Fudge dice (4dF) with 6 approaches, aspects 3. PbtA: 2d6+stat with 5 stats, partial success mechanics Protected from modification/deletion via is_builtin flag. ## Tests Added 23 comprehensive tests (test_game_system_tools.py): - Game system CRUD (11 tests): create, get, list, update, delete - Builtin protection (3 tests): prevent manual creation/edit/delete - Rule override CRUD (8 tests): full lifecycle + filtering - Seeding (2 tests): auto-seed on first access, prevent re-seed - All 357 tests passing ## Architecture - Pure data storage in MongoDB (no dice rolling/rule execution) - Rule interpretation logic reserved for agents layer - Built-in systems seeded lazily on first mongodb_get/list call - Custom systems fully mutable, builtins immutable Implements: DL-20, supports RS-1 (rules engine) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../monitor_data/data/builtin_systems.json | 265 +++++ .../src/monitor_data/schemas/__init__.py | 33 + .../src/monitor_data/schemas/game_systems.py | 259 +++++ .../src/monitor_data/tools/mongodb_tools.py | 514 ++++++++++ .../test_tools/test_game_system_tools.py | 906 ++++++++++++++++++ 5 files changed, 1977 insertions(+) create mode 100644 packages/data-layer/src/monitor_data/data/builtin_systems.json create mode 100644 packages/data-layer/src/monitor_data/schemas/game_systems.py create mode 100644 packages/data-layer/tests/test_tools/test_game_system_tools.py diff --git a/packages/data-layer/src/monitor_data/data/builtin_systems.json b/packages/data-layer/src/monitor_data/data/builtin_systems.json new file mode 100644 index 00000000..13e4990e --- /dev/null +++ b/packages/data-layer/src/monitor_data/data/builtin_systems.json @@ -0,0 +1,265 @@ +[ + { + "name": "D&D 5e", + "description": "Fifth Edition Dungeons & Dragons - d20 System with ability scores, skills, and class-based progression", + "version": "5th Edition", + "core_mechanic": { + "type": "d20", + "formula": "1d20 + modifiers vs DC", + "success_type": "meet_or_beat", + "success_threshold": "Roll meets or beats target DC", + "critical_success": "Natural 20 on attack rolls", + "critical_failure": "Natural 1 on attack rolls" + }, + "attributes": [ + { + "name": "Strength", + "abbreviation": "STR", + "min_value": 1, + "max_value": 30, + "default_value": 10, + "modifier_formula": "(VALUE - 10) / 2" + }, + { + "name": "Dexterity", + "abbreviation": "DEX", + "min_value": 1, + "max_value": 30, + "default_value": 10, + "modifier_formula": "(VALUE - 10) / 2" + }, + { + "name": "Constitution", + "abbreviation": "CON", + "min_value": 1, + "max_value": 30, + "default_value": 10, + "modifier_formula": "(VALUE - 10) / 2" + }, + { + "name": "Intelligence", + "abbreviation": "INT", + "min_value": 1, + "max_value": 30, + "default_value": 10, + "modifier_formula": "(VALUE - 10) / 2" + }, + { + "name": "Wisdom", + "abbreviation": "WIS", + "min_value": 1, + "max_value": 30, + "default_value": 10, + "modifier_formula": "(VALUE - 10) / 2" + }, + { + "name": "Charisma", + "abbreviation": "CHA", + "min_value": 1, + "max_value": 30, + "default_value": 10, + "modifier_formula": "(VALUE - 10) / 2" + } + ], + "skills": [ + {"name": "Acrobatics", "abbreviation": "Acr", "linked_attribute": "Dexterity", "description": "Balance, tumbling, aerial maneuvers"}, + {"name": "Animal Handling", "abbreviation": "AnH", "linked_attribute": "Wisdom", "description": "Calm or train animals"}, + {"name": "Arcana", "abbreviation": "Arc", "linked_attribute": "Intelligence", "description": "Recall lore about spells, magic items, symbols"}, + {"name": "Athletics", "abbreviation": "Ath", "linked_attribute": "Strength", "description": "Climbing, jumping, swimming"}, + {"name": "Deception", "abbreviation": "Dec", "linked_attribute": "Charisma", "description": "Hide the truth verbally or through actions"}, + {"name": "History", "abbreviation": "His", "linked_attribute": "Intelligence", "description": "Recall lore about historical events"}, + {"name": "Insight", "abbreviation": "Ins", "linked_attribute": "Wisdom", "description": "Determine true intentions of a creature"}, + {"name": "Intimidation", "abbreviation": "Itm", "linked_attribute": "Charisma", "description": "Influence through threats or hostile actions"}, + {"name": "Investigation", "abbreviation": "Inv", "linked_attribute": "Intelligence", "description": "Find clues and make deductions"}, + {"name": "Medicine", "abbreviation": "Med", "linked_attribute": "Wisdom", "description": "Stabilize or diagnose ailments"}, + {"name": "Nature", "abbreviation": "Nat", "linked_attribute": "Intelligence", "description": "Recall lore about terrain, plants, animals, weather"}, + {"name": "Perception", "abbreviation": "Per", "linked_attribute": "Wisdom", "description": "Spot, hear, or detect something"}, + {"name": "Performance", "abbreviation": "Prf", "linked_attribute": "Charisma", "description": "Delight an audience with music, dance, or acting"}, + {"name": "Persuasion", "abbreviation": "Prs", "linked_attribute": "Charisma", "description": "Influence with tact, grace, or good nature"}, + {"name": "Religion", "abbreviation": "Rel", "linked_attribute": "Intelligence", "description": "Recall lore about deities, rites, prayers"}, + {"name": "Sleight of Hand", "abbreviation": "SoH", "linked_attribute": "Dexterity", "description": "Pickpocketing, concealing objects"}, + {"name": "Stealth", "abbreviation": "Ste", "linked_attribute": "Dexterity", "description": "Conceal yourself from enemies"}, + {"name": "Survival", "abbreviation": "Sur", "linked_attribute": "Wisdom", "description": "Track, hunt, navigate wilderness"} + ], + "resources": [ + { + "name": "Hit Points", + "abbreviation": "HP", + "calculation": "class_hit_die + CON_modifier", + "min_value": 0, + "recovers_on": "long rest" + }, + { + "name": "Armor Class", + "abbreviation": "AC", + "calculation": "10 + DEX_modifier + armor_bonus", + "min_value": 1, + "recovers_on": null + } + ], + "custom_dice": {}, + "is_builtin": true + }, + { + "name": "Fate Core", + "description": "Fate Core System - narrative-focused with aspects, fate points, and Fudge dice", + "version": "Core", + "core_mechanic": { + "type": "dice_pool", + "formula": "4dF + skill vs difficulty", + "success_type": "meet_or_beat", + "success_threshold": "Roll meets or beats difficulty (0-8 ladder)", + "critical_success": "Succeed with style (3+ shifts)", + "critical_failure": null + }, + "attributes": [ + { + "name": "Careful", + "abbreviation": "CAR", + "min_value": 0, + "max_value": 8, + "default_value": 0, + "modifier_formula": null + }, + { + "name": "Clever", + "abbreviation": "CLV", + "min_value": 0, + "max_value": 8, + "default_value": 0, + "modifier_formula": null + }, + { + "name": "Flashy", + "abbreviation": "FLS", + "min_value": 0, + "max_value": 8, + "default_value": 0, + "modifier_formula": null + }, + { + "name": "Forceful", + "abbreviation": "FOR", + "min_value": 0, + "max_value": 8, + "default_value": 0, + "modifier_formula": null + }, + { + "name": "Quick", + "abbreviation": "QCK", + "min_value": 0, + "max_value": 8, + "default_value": 0, + "modifier_formula": null + }, + { + "name": "Sneaky", + "abbreviation": "SNK", + "min_value": 0, + "max_value": 8, + "default_value": 0, + "modifier_formula": null + } + ], + "skills": [], + "resources": [ + { + "name": "Fate Points", + "abbreviation": "FP", + "calculation": "refresh_rate", + "min_value": 0, + "recovers_on": "session start" + }, + { + "name": "Stress", + "abbreviation": "STR", + "calculation": "varies by approach", + "min_value": 0, + "recovers_on": "scene end" + } + ], + "custom_dice": { + "fudge_die": { + "sides": 3, + "values": [-1, 0, 1], + "notation": "dF" + } + }, + "is_builtin": true + }, + { + "name": "Powered by the Apocalypse", + "description": "PbtA framework - 2d6+stat with partial success mechanics and fictional positioning", + "version": "Generic", + "core_mechanic": { + "type": "dice_pool", + "formula": "2d6 + stat", + "success_type": "meet_or_beat", + "success_threshold": "10+ full success, 7-9 partial success, 6- failure", + "critical_success": "12+ is often a critical/special success", + "critical_failure": null + }, + "attributes": [ + { + "name": "Cool", + "abbreviation": "CL", + "min_value": -3, + "max_value": 3, + "default_value": 0, + "modifier_formula": null + }, + { + "name": "Hard", + "abbreviation": "HD", + "min_value": -3, + "max_value": 3, + "default_value": 0, + "modifier_formula": null + }, + { + "name": "Hot", + "abbreviation": "HT", + "min_value": -3, + "max_value": 3, + "default_value": 0, + "modifier_formula": null + }, + { + "name": "Sharp", + "abbreviation": "SH", + "min_value": -3, + "max_value": 3, + "default_value": 0, + "modifier_formula": null + }, + { + "name": "Weird", + "abbreviation": "WD", + "min_value": -3, + "max_value": 3, + "default_value": 0, + "modifier_formula": null + } + ], + "skills": [], + "resources": [ + { + "name": "Harm", + "abbreviation": "HRM", + "calculation": "static", + "min_value": 0, + "recovers_on": "downtime/medical attention" + }, + { + "name": "Experience", + "abbreviation": "XP", + "calculation": "static", + "min_value": 0, + "recovers_on": null + } + ], + "custom_dice": {}, + "is_builtin": true + } +] diff --git a/packages/data-layer/src/monitor_data/schemas/__init__.py b/packages/data-layer/src/monitor_data/schemas/__init__.py index f69e3ef6..dbba4365 100644 --- a/packages/data-layer/src/monitor_data/schemas/__init__.py +++ b/packages/data-layer/src/monitor_data/schemas/__init__.py @@ -110,6 +110,23 @@ MemorySearchResult, MemorySearchResponse, ) +from monitor_data.schemas.game_systems import ( + CoreMechanicType, + SuccessType, + RuleOverrideScope, + CoreMechanic, + AttributeDefinition, + SkillDefinition, + ResourceDefinition, + GameSystemCreate, + GameSystemUpdate, + GameSystemResponse, + GameSystemListResponse, + RuleOverrideCreate, + RuleOverrideUpdate, + RuleOverrideResponse, + RuleOverrideListResponse, +) # from monitor_data.schemas.sources import * # from monitor_data.schemas.queries import * @@ -195,4 +212,20 @@ "MemorySearchRequest", "MemorySearchResult", "MemorySearchResponse", + # Game Systems schemas + "CoreMechanicType", + "SuccessType", + "RuleOverrideScope", + "CoreMechanic", + "AttributeDefinition", + "SkillDefinition", + "ResourceDefinition", + "GameSystemCreate", + "GameSystemUpdate", + "GameSystemResponse", + "GameSystemListResponse", + "RuleOverrideCreate", + "RuleOverrideUpdate", + "RuleOverrideResponse", + "RuleOverrideListResponse", ] diff --git a/packages/data-layer/src/monitor_data/schemas/game_systems.py b/packages/data-layer/src/monitor_data/schemas/game_systems.py new file mode 100644 index 00000000..0efeeba9 --- /dev/null +++ b/packages/data-layer/src/monitor_data/schemas/game_systems.py @@ -0,0 +1,259 @@ +""" +Pydantic schemas for Game Systems & Rules operations (DL-20). + +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 game system definitions and +rule overrides. Pure data storage - rule execution 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 CoreMechanicType(str, Enum): + """Core mechanic type used by the game system.""" + + D20 = "d20" + DICE_POOL = "dice_pool" + PERCENTILE = "percentile" + CARD = "card" + NARRATIVE = "narrative" + + +class SuccessType(str, Enum): + """Method for determining success.""" + + MEET_OR_BEAT = "meet_or_beat" + COUNT_SUCCESSES = "count_successes" + HIGHEST_WINS = "highest_wins" + + +class RuleOverrideScope(str, Enum): + """Scope of a rule override.""" + + ONE_TIME = "one_time" + SCENE = "scene" + STORY = "story" + UNIVERSE = "universe" + + +# ============================================================================= +# CORE MECHANIC SCHEMAS +# ============================================================================= + + +class CoreMechanic(BaseModel): + """Core mechanic definition for a game system.""" + + type: CoreMechanicType + formula: str = Field( + max_length=200, + description="Base formula for resolution (e.g., '1d20+MOD vs DC')", + ) + success_type: SuccessType + success_threshold: Optional[str] = Field( + None, max_length=200, description="How success is determined" + ) + critical_success: Optional[str] = Field( + None, max_length=200, description="Conditions for critical success" + ) + critical_failure: Optional[str] = Field( + None, max_length=200, description="Conditions for critical failure" + ) + + +# ============================================================================= +# ATTRIBUTE/SKILL/RESOURCE SCHEMAS +# ============================================================================= + + +class AttributeDefinition(BaseModel): + """Definition of a character attribute.""" + + name: str = Field(max_length=100, description="Attribute name (e.g., 'Strength')") + abbreviation: str = Field(max_length=10, description="Short form (e.g., 'STR')") + min_value: int = Field(description="Minimum value for this attribute") + max_value: int = Field(description="Maximum value for this attribute") + default_value: int = Field(description="Default starting value") + modifier_formula: Optional[str] = Field( + None, + max_length=200, + description="Formula for calculating modifier (e.g., '(VALUE-10)/2')", + ) + + +class SkillDefinition(BaseModel): + """Definition of a character skill.""" + + name: str = Field(max_length=100, description="Skill name (e.g., 'Stealth')") + abbreviation: Optional[str] = Field( + None, max_length=10, description="Short form if applicable" + ) + linked_attribute: Optional[str] = Field( + None, + max_length=100, + description="Attribute this skill is based on (e.g., 'Dexterity')", + ) + description: Optional[str] = Field( + None, max_length=500, description="What this skill represents" + ) + + +class ResourceDefinition(BaseModel): + """Definition of a character resource (HP, mana, etc.).""" + + name: str = Field(max_length=100, description="Resource name (e.g., 'Hit Points')") + abbreviation: str = Field(max_length=10, description="Short form (e.g., 'HP')") + calculation: Optional[str] = Field( + None, + max_length=200, + description="Formula for calculating max value (e.g., 'CON*10')", + ) + min_value: int = Field(default=0, description="Minimum value (usually 0)") + recovers_on: Optional[str] = Field( + None, + max_length=100, + description="When this resource recovers (e.g., 'long rest')", + ) + + +# ============================================================================= +# GAME SYSTEM CRUD SCHEMAS +# ============================================================================= + + +class GameSystemCreate(BaseModel): + """Request to create a game system.""" + + name: str = Field(max_length=200, description="System name (e.g., 'D&D 5e')") + description: str = Field( + max_length=1000, description="Brief description of the system" + ) + version: Optional[str] = Field( + None, max_length=50, description="Version of the system (e.g., '5th Edition')" + ) + core_mechanic: CoreMechanic + attributes: List[AttributeDefinition] = Field( + default_factory=list, description="Core attributes for this system" + ) + skills: List[SkillDefinition] = Field( + default_factory=list, description="Available skills" + ) + resources: List[ResourceDefinition] = Field( + default_factory=list, description="Character resources (HP, mana, etc.)" + ) + custom_dice: Dict[str, Any] = Field( + default_factory=dict, + description="Custom dice definitions (e.g., Fate dice, Genesys symbols)", + ) + is_builtin: bool = Field( + default=False, description="Whether this is a built-in system" + ) + + +class GameSystemUpdate(BaseModel): + """Request to update a game system.""" + + name: Optional[str] = Field(None, max_length=200) + description: Optional[str] = Field(None, max_length=1000) + version: Optional[str] = Field(None, max_length=50) + core_mechanic: Optional[CoreMechanic] = None + attributes: Optional[List[AttributeDefinition]] = None + skills: Optional[List[SkillDefinition]] = None + resources: Optional[List[ResourceDefinition]] = None + custom_dice: Optional[Dict[str, Any]] = None + + +class GameSystemResponse(BaseModel): + """Response with game system data.""" + + id: UUID + name: str + description: str + version: Optional[str] + core_mechanic: CoreMechanic + attributes: List[AttributeDefinition] + skills: List[SkillDefinition] + resources: List[ResourceDefinition] + custom_dice: Dict[str, Any] + is_builtin: bool + created_at: datetime + updated_at: Optional[datetime] + + model_config = {"from_attributes": True} + + +class GameSystemListResponse(BaseModel): + """Response for list operations.""" + + systems: List[GameSystemResponse] + total: int + limit: int + offset: int + + +# ============================================================================= +# RULE OVERRIDE CRUD SCHEMAS +# ============================================================================= + + +class RuleOverrideCreate(BaseModel): + """Request to create a rule override.""" + + scope: RuleOverrideScope + scope_id: UUID = Field( + description="ID of the story/scene/universe this override applies to" + ) + target: str = Field( + max_length=200, description="What rule is being overridden (e.g., 'flanking')" + ) + original: str = Field(max_length=500, description="Original rule text or behavior") + override: str = Field(max_length=500, description="New rule text or behavior") + reason: Optional[str] = Field( + None, max_length=500, description="Why this override was created" + ) + + +class RuleOverrideUpdate(BaseModel): + """Request to update a rule override.""" + + active: Optional[bool] = None + times_used: Optional[int] = Field(None, ge=0) + reason: Optional[str] = Field(None, max_length=500) + + +class RuleOverrideResponse(BaseModel): + """Response with rule override data.""" + + id: UUID + scope: RuleOverrideScope + scope_id: UUID + target: str + original: str + override: str + reason: Optional[str] + times_used: int + active: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class RuleOverrideListResponse(BaseModel): + """Response for list operations.""" + + overrides: List[RuleOverrideResponse] + total: 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 73dec07b..96906ddb 100644 --- a/packages/data-layer/src/monitor_data/tools/mongodb_tools.py +++ b/packages/data-layer/src/monitor_data/tools/mongodb_tools.py @@ -81,6 +81,16 @@ MemoryResponse, MemoryListResponse, ) +from monitor_data.schemas.game_systems import ( + GameSystemCreate, + GameSystemUpdate, + GameSystemResponse, + GameSystemListResponse, + RuleOverrideCreate, + RuleOverrideUpdate, + RuleOverrideResponse, + RuleOverrideListResponse, +) # ============================================================================= @@ -2251,3 +2261,507 @@ def mongodb_delete_memory(memory_id: UUID) -> bool: result = memories_collection.delete_one({"memory_id": str(memory_id)}) return result.deleted_count > 0 + + +# ============================================================================= +# GAME SYSTEMS & RULES (DL-20) +# ============================================================================= + + +def _load_builtin_game_systems(): + """Load built-in game systems from seed data.""" + import json + import os + + seed_file = os.path.join( + os.path.dirname(__file__), "..", "data", "builtin_systems.json" + ) + with open(seed_file, "r") as f: + return json.load(f) + + +def _ensure_builtin_systems_seeded(): + """Ensure built-in game systems are seeded in the database.""" + mongodb = get_mongodb_client() + systems_collection = mongodb.get_collection("game_systems") + + # Check if any builtin systems exist + existing_count = systems_collection.count_documents({"is_builtin": True}) + if existing_count > 0: + return # Already seeded + + # Load and insert builtin systems + builtin_systems = _load_builtin_game_systems() + now = datetime.now(timezone.utc) + + for system_data in builtin_systems: + system_id = uuid4() + doc = { + "system_id": str(system_id), + **system_data, + "created_at": now, + "updated_at": None, + } + systems_collection.insert_one(doc) + + +def mongodb_create_game_system(params: GameSystemCreate) -> GameSystemResponse: + """ + Create a new game system. + + Args: + params: Game system creation parameters + + Returns: + GameSystemResponse with created system data + + Raises: + ValueError: If is_builtin is True (only allowed for seed data) + """ + if params.is_builtin: + raise ValueError("Cannot manually create builtin systems") + + mongodb = get_mongodb_client() + systems_collection = mongodb.get_collection("game_systems") + + now = datetime.now(timezone.utc) + system_id = uuid4() + + system_doc = { + "system_id": str(system_id), + "name": params.name, + "description": params.description, + "version": params.version, + "core_mechanic": params.core_mechanic.model_dump(mode="json"), + "attributes": [attr.model_dump(mode="json") for attr in params.attributes], + "skills": [skill.model_dump(mode="json") for skill in params.skills], + "resources": [res.model_dump(mode="json") for res in params.resources], + "custom_dice": params.custom_dice, + "is_builtin": False, + "created_at": now, + "updated_at": None, + } + + systems_collection.insert_one(system_doc) + + return GameSystemResponse( + id=system_id, + name=params.name, + description=params.description, + version=params.version, + core_mechanic=params.core_mechanic, + attributes=params.attributes, + skills=params.skills, + resources=params.resources, + custom_dice=params.custom_dice, + is_builtin=False, + created_at=now, + updated_at=None, + ) + + +def mongodb_get_game_system(system_id: UUID) -> Optional[GameSystemResponse]: + """ + Get a game system by ID. + + Args: + system_id: Game system UUID + + Returns: + GameSystemResponse or None if not found + """ + # Ensure builtin systems are seeded + _ensure_builtin_systems_seeded() + + mongodb = get_mongodb_client() + systems_collection = mongodb.get_collection("game_systems") + + system_doc = systems_collection.find_one({"system_id": str(system_id)}) + if not system_doc: + return None + + from monitor_data.schemas.game_systems import ( + CoreMechanic, + AttributeDefinition, + SkillDefinition, + ResourceDefinition, + ) + + return GameSystemResponse( + id=UUID(system_doc["system_id"]), + name=system_doc["name"], + description=system_doc["description"], + version=system_doc.get("version"), + core_mechanic=CoreMechanic(**system_doc["core_mechanic"]), + attributes=[AttributeDefinition(**attr) for attr in system_doc["attributes"]], + skills=[SkillDefinition(**skill) for skill in system_doc["skills"]], + resources=[ResourceDefinition(**res) for res in system_doc["resources"]], + custom_dice=system_doc.get("custom_dice", {}), + is_builtin=system_doc["is_builtin"], + created_at=system_doc["created_at"], + updated_at=system_doc.get("updated_at"), + ) + + +def mongodb_list_game_systems( + include_builtin: bool = True, limit: int = 50, offset: int = 0 +) -> GameSystemListResponse: + """ + List game systems with optional filtering. + + Args: + include_builtin: Whether to include built-in systems + limit: Maximum number of systems to return + offset: Number of systems to skip + + Returns: + GameSystemListResponse with matching systems + """ + # Ensure builtin systems are seeded + _ensure_builtin_systems_seeded() + + mongodb = get_mongodb_client() + systems_collection = mongodb.get_collection("game_systems") + + # Build query + query = {} + if not include_builtin: + query["is_builtin"] = False + + # Get total count + total = systems_collection.count_documents(query) + + # Get paginated results + systems_docs = ( + systems_collection.find(query) + .sort("name", 1) + .skip(offset) + .limit(limit) # Alphabetical + ) + + from monitor_data.schemas.game_systems import ( + CoreMechanic, + AttributeDefinition, + SkillDefinition, + ResourceDefinition, + ) + + systems = [] + for doc in systems_docs: + systems.append( + GameSystemResponse( + id=UUID(doc["system_id"]), + name=doc["name"], + description=doc["description"], + version=doc.get("version"), + core_mechanic=CoreMechanic(**doc["core_mechanic"]), + attributes=[AttributeDefinition(**attr) for attr in doc["attributes"]], + skills=[SkillDefinition(**skill) for skill in doc["skills"]], + resources=[ResourceDefinition(**res) for res in doc["resources"]], + custom_dice=doc.get("custom_dice", {}), + is_builtin=doc["is_builtin"], + created_at=doc["created_at"], + updated_at=doc.get("updated_at"), + ) + ) + + return GameSystemListResponse( + systems=systems, + total=total, + limit=limit, + offset=offset, + ) + + +def mongodb_update_game_system( + system_id: UUID, params: GameSystemUpdate +) -> GameSystemResponse: + """ + Update a game system. + + Args: + system_id: Game system UUID + params: Update parameters + + Returns: + GameSystemResponse with updated system data + + Raises: + ValueError: If system not found or is a builtin system + """ + mongodb = get_mongodb_client() + systems_collection = mongodb.get_collection("game_systems") + + # Check if system exists + system_doc = systems_collection.find_one({"system_id": str(system_id)}) + if not system_doc: + raise ValueError(f"Game system {system_id} not found") + + # Prevent modification of builtin systems + if system_doc["is_builtin"]: + raise ValueError("Cannot modify builtin game systems") + + # Build update document + update_doc: Dict[str, Any] = {} + if params.name is not None: + update_doc["name"] = params.name + if params.description is not None: + update_doc["description"] = params.description + if params.version is not None: + update_doc["version"] = params.version + if params.core_mechanic is not None: + update_doc["core_mechanic"] = params.core_mechanic.model_dump(mode="json") + if params.attributes is not None: + update_doc["attributes"] = [ + attr.model_dump(mode="json") for attr in params.attributes + ] + if params.skills is not None: + update_doc["skills"] = [ + skill.model_dump(mode="json") for skill in params.skills + ] + if params.resources is not None: + update_doc["resources"] = [ + res.model_dump(mode="json") for res in params.resources + ] + if params.custom_dice is not None: + update_doc["custom_dice"] = params.custom_dice + + if update_doc: + update_doc["updated_at"] = datetime.now(timezone.utc) + systems_collection.update_one( + {"system_id": str(system_id)}, {"$set": update_doc} + ) + + # Return updated system + updated_system = mongodb_get_game_system(system_id) + if not updated_system: + raise ValueError(f"Failed to retrieve updated system {system_id}") + return updated_system + + +def mongodb_delete_game_system(system_id: UUID) -> None: + """ + Delete a game system. + + Args: + system_id: Game system UUID + + Raises: + ValueError: If system not found or is a builtin system + """ + mongodb = get_mongodb_client() + systems_collection = mongodb.get_collection("game_systems") + + # Check if system exists + system_doc = systems_collection.find_one({"system_id": str(system_id)}) + if not system_doc: + raise ValueError(f"Game system {system_id} not found") + + # Prevent deletion of builtin systems + if system_doc["is_builtin"]: + raise ValueError("Cannot delete builtin game systems") + + systems_collection.delete_one({"system_id": str(system_id)}) + + +def mongodb_create_rule_override(params: RuleOverrideCreate) -> RuleOverrideResponse: + """ + Create a new rule override. + + Args: + params: Rule override creation parameters + + Returns: + RuleOverrideResponse with created override data + """ + mongodb = get_mongodb_client() + overrides_collection = mongodb.get_collection("rule_overrides") + + now = datetime.now(timezone.utc) + override_id = uuid4() + + override_doc = { + "override_id": str(override_id), + "scope": params.scope.value, + "scope_id": str(params.scope_id), + "target": params.target, + "original": params.original, + "override": params.override, + "reason": params.reason, + "times_used": 0, + "active": True, + "created_at": now, + } + + overrides_collection.insert_one(override_doc) + + return RuleOverrideResponse( + id=override_id, + scope=params.scope, + scope_id=params.scope_id, + target=params.target, + original=params.original, + override=params.override, + reason=params.reason, + times_used=0, + active=True, + created_at=now, + ) + + +def mongodb_get_rule_override(override_id: UUID) -> Optional[RuleOverrideResponse]: + """ + Get a rule override by ID. + + Args: + override_id: Rule override UUID + + Returns: + RuleOverrideResponse or None if not found + """ + mongodb = get_mongodb_client() + overrides_collection = mongodb.get_collection("rule_overrides") + + override_doc = overrides_collection.find_one({"override_id": str(override_id)}) + if not override_doc: + return None + + from monitor_data.schemas.game_systems import RuleOverrideScope + + return RuleOverrideResponse( + id=UUID(override_doc["override_id"]), + scope=RuleOverrideScope(override_doc["scope"]), + scope_id=UUID(override_doc["scope_id"]), + target=override_doc["target"], + original=override_doc["original"], + override=override_doc["override"], + reason=override_doc.get("reason"), + times_used=override_doc["times_used"], + active=override_doc["active"], + created_at=override_doc["created_at"], + ) + + +def mongodb_list_rule_overrides( + scope: Optional[str] = None, + scope_id: Optional[UUID] = None, + active_only: bool = True, +) -> RuleOverrideListResponse: + """ + List rule overrides with filtering. + + Args: + scope: Filter by scope type + scope_id: Filter by scope ID + active_only: Only return active overrides + + Returns: + RuleOverrideListResponse with matching overrides + """ + mongodb = get_mongodb_client() + overrides_collection = mongodb.get_collection("rule_overrides") + + # Build query + query: Dict[str, Any] = {} + if scope is not None: + query["scope"] = scope + if scope_id is not None: + query["scope_id"] = str(scope_id) + if active_only: + query["active"] = True + + # Get total count + total = overrides_collection.count_documents(query) + + # Get all matching overrides (no pagination for now) + overrides_docs = overrides_collection.find(query).sort("created_at", -1) + + from monitor_data.schemas.game_systems import RuleOverrideScope + + overrides = [] + for doc in overrides_docs: + overrides.append( + RuleOverrideResponse( + id=UUID(doc["override_id"]), + scope=RuleOverrideScope(doc["scope"]), + scope_id=UUID(doc["scope_id"]), + target=doc["target"], + original=doc["original"], + override=doc["override"], + reason=doc.get("reason"), + times_used=doc["times_used"], + active=doc["active"], + created_at=doc["created_at"], + ) + ) + + return RuleOverrideListResponse( + overrides=overrides, + total=total, + ) + + +def mongodb_update_rule_override( + override_id: UUID, params: RuleOverrideUpdate +) -> RuleOverrideResponse: + """ + Update a rule override. + + Args: + override_id: Rule override UUID + params: Update parameters + + Returns: + RuleOverrideResponse with updated override data + + Raises: + ValueError: If override not found + """ + mongodb = get_mongodb_client() + overrides_collection = mongodb.get_collection("rule_overrides") + + # Check if override exists + override_doc = overrides_collection.find_one({"override_id": str(override_id)}) + if not override_doc: + raise ValueError(f"Rule override {override_id} not found") + + # Build update document + update_doc: Dict[str, Any] = {} + if params.active is not None: + update_doc["active"] = params.active + if params.times_used is not None: + update_doc["times_used"] = params.times_used + if params.reason is not None: + update_doc["reason"] = params.reason + + if update_doc: + overrides_collection.update_one( + {"override_id": str(override_id)}, {"$set": update_doc} + ) + + # Return updated override + updated_override = mongodb_get_rule_override(override_id) + if not updated_override: + raise ValueError(f"Failed to retrieve updated override {override_id}") + return updated_override + + +def mongodb_delete_rule_override(override_id: UUID) -> None: + """ + Delete a rule override. + + Args: + override_id: Rule override UUID + + Raises: + ValueError: If override not found + """ + mongodb = get_mongodb_client() + overrides_collection = mongodb.get_collection("rule_overrides") + + # Check if override exists + override_doc = overrides_collection.find_one({"override_id": str(override_id)}) + if not override_doc: + raise ValueError(f"Rule override {override_id} not found") + + overrides_collection.delete_one({"override_id": str(override_id)}) diff --git a/packages/data-layer/tests/test_tools/test_game_system_tools.py b/packages/data-layer/tests/test_tools/test_game_system_tools.py new file mode 100644 index 00000000..60068870 --- /dev/null +++ b/packages/data-layer/tests/test_tools/test_game_system_tools.py @@ -0,0 +1,906 @@ +""" +Tests for Game Systems MongoDB tools (DL-20). + +Tests all game system and rule override CRUD operations including +built-in system seeding, custom system management, and rule overrides. +""" + +from datetime import datetime, timezone +from unittest.mock import Mock, MagicMock, patch, mock_open +from uuid import uuid4 + +import pytest + +from monitor_data.schemas.game_systems import ( + GameSystemCreate, + GameSystemUpdate, + RuleOverrideCreate, + RuleOverrideUpdate, + CoreMechanicType, + SuccessType, + RuleOverrideScope, + CoreMechanic, + AttributeDefinition, + SkillDefinition, + ResourceDefinition, +) +from monitor_data.tools.mongodb_tools import ( + mongodb_create_game_system, + mongodb_get_game_system, + mongodb_list_game_systems, + mongodb_update_game_system, + mongodb_delete_game_system, + mongodb_create_rule_override, + mongodb_get_rule_override, + mongodb_list_rule_overrides, + mongodb_update_rule_override, + mongodb_delete_rule_override, +) + + +# ============================================================================= +# FIXTURES +# ============================================================================= + + +@pytest.fixture +def sample_core_mechanic(): + """Sample core mechanic for testing.""" + return CoreMechanic( + type=CoreMechanicType.D20, + formula="1d20 + modifier vs DC", + success_type=SuccessType.MEET_OR_BEAT, + success_threshold="Meet or beat DC", + critical_success="Natural 20", + critical_failure="Natural 1", + ) + + +@pytest.fixture +def sample_attributes(): + """Sample attributes for testing.""" + return [ + AttributeDefinition( + name="Strength", + abbreviation="STR", + min_value=1, + max_value=20, + default_value=10, + modifier_formula="(VALUE - 10) / 2", + ), + AttributeDefinition( + name="Dexterity", + abbreviation="DEX", + min_value=1, + max_value=20, + default_value=10, + modifier_formula="(VALUE - 10) / 2", + ), + ] + + +@pytest.fixture +def sample_skills(): + """Sample skills for testing.""" + return [ + SkillDefinition( + name="Athletics", + abbreviation="Ath", + linked_attribute="Strength", + description="Physical prowess", + ), + SkillDefinition( + name="Stealth", + abbreviation="Ste", + linked_attribute="Dexterity", + description="Moving silently", + ), + ] + + +@pytest.fixture +def sample_resources(): + """Sample resources for testing.""" + return [ + ResourceDefinition( + name="Hit Points", + abbreviation="HP", + calculation="class_hit_die + CON", + min_value=0, + recovers_on="long rest", + ), + ] + + +# ============================================================================= +# TEST: mongodb_create_game_system +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_game_system_success( + mock_get_mongodb: Mock, + sample_core_mechanic, + sample_attributes, + sample_skills, + sample_resources, +): + """Test creating a custom game system.""" + # Mock MongoDB + mock_mongodb = MagicMock() + mock_systems = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_systems + + # Create system + create_params = GameSystemCreate( + name="My Custom System", + description="A homebrew RPG system", + version="1.0", + core_mechanic=sample_core_mechanic, + attributes=sample_attributes, + skills=sample_skills, + resources=sample_resources, + custom_dice={}, + is_builtin=False, + ) + + result = mongodb_create_game_system(create_params) + + # Verify insert was called + assert mock_systems.insert_one.called + inserted_doc = mock_systems.insert_one.call_args[0][0] + + # Verify document structure + assert inserted_doc["name"] == "My Custom System" + assert inserted_doc["description"] == "A homebrew RPG system" + assert inserted_doc["version"] == "1.0" + assert inserted_doc["is_builtin"] is False + assert "system_id" in inserted_doc + assert "created_at" in inserted_doc + + # Verify response + assert result.name == "My Custom System" + assert result.is_builtin is False + assert len(result.attributes) == 2 + assert len(result.skills) == 2 + assert len(result.resources) == 1 + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_game_system_builtin_rejected( + mock_get_mongodb: Mock, + sample_core_mechanic, +): + """Test that manually creating builtin systems is rejected.""" + create_params = GameSystemCreate( + name="Fake Builtin", + description="Should not work", + core_mechanic=sample_core_mechanic, + attributes=[], + is_builtin=True, # This should cause rejection + ) + + with pytest.raises(ValueError, match="Cannot manually create builtin systems"): + mongodb_create_game_system(create_params) + + +# ============================================================================= +# TEST: mongodb_get_game_system +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools._ensure_builtin_systems_seeded") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_game_system_found( + mock_get_mongodb: Mock, + mock_ensure_seeded: Mock, +): + """Test retrieving a game system.""" + system_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_systems = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_systems + + # Mock system document + mock_systems.find_one.return_value = { + "system_id": str(system_id), + "name": "Test System", + "description": "A test system", + "version": "1.0", + "core_mechanic": { + "type": "d20", + "formula": "1d20", + "success_type": "meet_or_beat", + }, + "attributes": [], + "skills": [], + "resources": [], + "custom_dice": {}, + "is_builtin": False, + "created_at": datetime.now(timezone.utc), + "updated_at": None, + } + + result = mongodb_get_game_system(system_id) + + # Verify seeding was checked + assert mock_ensure_seeded.called + + # Verify result + assert result is not None + assert result.name == "Test System" + assert result.id == system_id + + +@patch("monitor_data.tools.mongodb_tools._ensure_builtin_systems_seeded") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_game_system_not_found( + mock_get_mongodb: Mock, + mock_ensure_seeded: Mock, +): + """Test retrieving a non-existent game system.""" + system_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_systems = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_systems + mock_systems.find_one.return_value = None + + result = mongodb_get_game_system(system_id) + + assert result is None + + +# ============================================================================= +# TEST: mongodb_list_game_systems +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools._ensure_builtin_systems_seeded") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_game_systems_includes_builtin( + mock_get_mongodb: Mock, + mock_ensure_seeded: Mock, +): + """Test listing game systems includes builtin systems.""" + # Mock MongoDB + mock_mongodb = MagicMock() + mock_systems = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_systems + + # Mock cursor with 2 systems + mock_cursor = MagicMock() + mock_cursor.__iter__.return_value = iter( + [ + { + "system_id": str(uuid4()), + "name": "D&D 5e", + "description": "Fifth Edition", + "version": "5e", + "core_mechanic": { + "type": "d20", + "formula": "1d20", + "success_type": "meet_or_beat", + }, + "attributes": [], + "skills": [], + "resources": [], + "custom_dice": {}, + "is_builtin": True, + "created_at": datetime.now(timezone.utc), + "updated_at": None, + }, + { + "system_id": str(uuid4()), + "name": "Custom System", + "description": "Homebrew", + "version": "1.0", + "core_mechanic": { + "type": "dice_pool", + "formula": "3d6", + "success_type": "count_successes", + }, + "attributes": [], + "skills": [], + "resources": [], + "custom_dice": {}, + "is_builtin": False, + "created_at": datetime.now(timezone.utc), + "updated_at": None, + }, + ] + ) + + mock_systems.find.return_value.sort.return_value.skip.return_value.limit.return_value = ( + mock_cursor + ) + mock_systems.count_documents.return_value = 2 + + result = mongodb_list_game_systems(include_builtin=True, limit=50, offset=0) + + # Verify seeding was checked + assert mock_ensure_seeded.called + + # Verify results + assert len(result.systems) == 2 + assert result.total == 2 + assert result.systems[0].name == "D&D 5e" + assert result.systems[0].is_builtin is True + assert result.systems[1].name == "Custom System" + assert result.systems[1].is_builtin is False + + +@patch("monitor_data.tools.mongodb_tools._ensure_builtin_systems_seeded") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_game_systems_exclude_builtin( + mock_get_mongodb: Mock, + mock_ensure_seeded: Mock, +): + """Test listing game systems excluding builtin.""" + # Mock MongoDB + mock_mongodb = MagicMock() + mock_systems = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_systems + + # Mock cursor with only custom system + mock_cursor = MagicMock() + mock_cursor.__iter__.return_value = iter( + [ + { + "system_id": str(uuid4()), + "name": "Custom System", + "description": "Homebrew", + "version": "1.0", + "core_mechanic": { + "type": "dice_pool", + "formula": "3d6", + "success_type": "count_successes", + }, + "attributes": [], + "skills": [], + "resources": [], + "custom_dice": {}, + "is_builtin": False, + "created_at": datetime.now(timezone.utc), + "updated_at": None, + }, + ] + ) + + mock_systems.find.return_value.sort.return_value.skip.return_value.limit.return_value = ( + mock_cursor + ) + mock_systems.count_documents.return_value = 1 + + result = mongodb_list_game_systems(include_builtin=False, limit=50, offset=0) + + # Verify query excluded builtin + called_query = mock_systems.find.call_args[0][0] + assert "is_builtin" in called_query + assert called_query["is_builtin"] is False + + # Verify results + assert len(result.systems) == 1 + assert result.systems[0].is_builtin is False + + +# ============================================================================= +# TEST: mongodb_update_game_system +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_game_system") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_game_system_success( + mock_get_mongodb: Mock, + mock_get_system: Mock, + sample_core_mechanic, +): + """Test updating a custom game system.""" + system_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_systems = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_systems + + # Mock existing system (not builtin) + mock_systems.find_one.return_value = { + "system_id": str(system_id), + "is_builtin": False, + } + + # Mock the get after update + mock_result = MagicMock() + mock_result.id = system_id + mock_result.name = "Updated System" + mock_result.is_builtin = False + mock_get_system.return_value = mock_result + + # Update + update_params = GameSystemUpdate( + name="Updated System", + description="Updated description", + ) + + result = mongodb_update_game_system(system_id, update_params) + + # Verify update was called + assert mock_systems.update_one.called + update_call = mock_systems.update_one.call_args[0] + assert update_call[0] == {"system_id": str(system_id)} + assert "name" in update_call[1]["$set"] + assert update_call[1]["$set"]["name"] == "Updated System" + + # Verify result + assert result.name == "Updated System" + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_builtin_system_rejected(mock_get_mongodb: Mock): + """Test that updating builtin systems is rejected.""" + system_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_systems = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_systems + + # Mock builtin system + mock_systems.find_one.return_value = { + "system_id": str(system_id), + "is_builtin": True, + } + + update_params = GameSystemUpdate(name="Hacked D&D") + + with pytest.raises(ValueError, match="Cannot modify builtin game systems"): + mongodb_update_game_system(system_id, update_params) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_nonexistent_system(mock_get_mongodb: Mock): + """Test updating a non-existent system.""" + system_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_systems = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_systems + mock_systems.find_one.return_value = None + + update_params = GameSystemUpdate(name="Doesn't Matter") + + with pytest.raises(ValueError, match="not found"): + mongodb_update_game_system(system_id, update_params) + + +# ============================================================================= +# TEST: mongodb_delete_game_system +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_delete_custom_system_success(mock_get_mongodb: Mock): + """Test deleting a custom game system.""" + system_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_systems = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_systems + + # Mock custom system + mock_systems.find_one.return_value = { + "system_id": str(system_id), + "is_builtin": False, + } + + mongodb_delete_game_system(system_id) + + # Verify delete was called + assert mock_systems.delete_one.called + delete_call = mock_systems.delete_one.call_args[0][0] + assert delete_call == {"system_id": str(system_id)} + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_delete_builtin_system_rejected(mock_get_mongodb: Mock): + """Test that deleting builtin systems is rejected.""" + system_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_systems = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_systems + + # Mock builtin system + mock_systems.find_one.return_value = { + "system_id": str(system_id), + "is_builtin": True, + } + + with pytest.raises(ValueError, match="Cannot delete builtin game systems"): + mongodb_delete_game_system(system_id) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_delete_nonexistent_system(mock_get_mongodb: Mock): + """Test deleting a non-existent system.""" + system_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_systems = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_systems + mock_systems.find_one.return_value = None + + with pytest.raises(ValueError, match="not found"): + mongodb_delete_game_system(system_id) + + +# ============================================================================= +# TEST: mongodb_create_rule_override +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_rule_override_success(mock_get_mongodb: Mock): + """Test creating a rule override.""" + scope_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_overrides = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_overrides + + # Create override + create_params = RuleOverrideCreate( + scope=RuleOverrideScope.STORY, + scope_id=scope_id, + target="flanking", + original="Flanking grants advantage", + override="Flanking grants +2 bonus instead of advantage", + reason="Table preference for numerical bonuses", + ) + + result = mongodb_create_rule_override(create_params) + + # Verify insert was called + assert mock_overrides.insert_one.called + inserted_doc = mock_overrides.insert_one.call_args[0][0] + + # Verify document structure + assert inserted_doc["scope"] == "story" + assert inserted_doc["scope_id"] == str(scope_id) + assert inserted_doc["target"] == "flanking" + assert inserted_doc["active"] is True + assert inserted_doc["times_used"] == 0 + + # Verify response + assert result.scope == RuleOverrideScope.STORY + assert result.target == "flanking" + assert result.active is True + + +# ============================================================================= +# TEST: mongodb_get_rule_override +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_rule_override_found(mock_get_mongodb: Mock): + """Test retrieving a rule override.""" + override_id = uuid4() + scope_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_overrides = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_overrides + + # Mock override document + mock_overrides.find_one.return_value = { + "override_id": str(override_id), + "scope": "scene", + "scope_id": str(scope_id), + "target": "critical_hits", + "original": "Crit on 20", + "override": "Crit on 19-20", + "reason": "Champion fighter", + "times_used": 3, + "active": True, + "created_at": datetime.now(timezone.utc), + } + + result = mongodb_get_rule_override(override_id) + + # Verify result + assert result is not None + assert result.id == override_id + assert result.scope == RuleOverrideScope.SCENE + assert result.times_used == 3 + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_rule_override_not_found(mock_get_mongodb: Mock): + """Test retrieving a non-existent rule override.""" + override_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_overrides = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_overrides + mock_overrides.find_one.return_value = None + + result = mongodb_get_rule_override(override_id) + + assert result is None + + +# ============================================================================= +# TEST: mongodb_list_rule_overrides +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_rule_overrides_by_scope(mock_get_mongodb: Mock): + """Test listing rule overrides filtered by scope.""" + story_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_overrides = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_overrides + + # Mock cursor with 2 overrides + mock_cursor = MagicMock() + mock_cursor.__iter__.return_value = iter( + [ + { + "override_id": str(uuid4()), + "scope": "story", + "scope_id": str(story_id), + "target": "flanking", + "original": "Advantage", + "override": "+2", + "reason": "Preference", + "times_used": 0, + "active": True, + "created_at": datetime.now(timezone.utc), + }, + { + "override_id": str(uuid4()), + "scope": "story", + "scope_id": str(story_id), + "target": "crits", + "original": "Double dice", + "override": "Max + roll", + "reason": "Feels better", + "times_used": 2, + "active": True, + "created_at": datetime.now(timezone.utc), + }, + ] + ) + + mock_overrides.find.return_value.sort.return_value = mock_cursor + mock_overrides.count_documents.return_value = 2 + + result = mongodb_list_rule_overrides( + scope="story", scope_id=story_id, active_only=True + ) + + # Verify query + called_query = mock_overrides.find.call_args[0][0] + assert called_query["scope"] == "story" + assert called_query["scope_id"] == str(story_id) + assert called_query["active"] is True + + # Verify results + assert len(result.overrides) == 2 + assert result.total == 2 + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_rule_overrides_active_only(mock_get_mongodb: Mock): + """Test listing only active rule overrides.""" + # Mock MongoDB + mock_mongodb = MagicMock() + mock_overrides = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_overrides + + mock_cursor = MagicMock() + mock_cursor.__iter__.return_value = iter([]) + mock_overrides.find.return_value.sort.return_value = mock_cursor + mock_overrides.count_documents.return_value = 0 + + mongodb_list_rule_overrides(active_only=True) + + # Verify query included active filter + called_query = mock_overrides.find.call_args[0][0] + assert called_query["active"] is True + + +# ============================================================================= +# TEST: mongodb_update_rule_override +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_rule_override") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_rule_override_success( + mock_get_mongodb: Mock, + mock_get_override: Mock, +): + """Test updating a rule override.""" + override_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_overrides = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_overrides + + # Mock existing override + mock_overrides.find_one.return_value = { + "override_id": str(override_id), + } + + # Mock the get after update + mock_result = MagicMock() + mock_result.id = override_id + mock_result.times_used = 5 + mock_result.active = True + mock_get_override.return_value = mock_result + + # Update + update_params = RuleOverrideUpdate( + times_used=5, + active=True, + ) + + result = mongodb_update_rule_override(override_id, update_params) + + # Verify update was called + assert mock_overrides.update_one.called + update_call = mock_overrides.update_one.call_args[0] + assert update_call[0] == {"override_id": str(override_id)} + assert "times_used" in update_call[1]["$set"] + + # Verify result + assert result.times_used == 5 + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_nonexistent_override(mock_get_mongodb: Mock): + """Test updating a non-existent override.""" + override_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_overrides = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_overrides + mock_overrides.find_one.return_value = None + + update_params = RuleOverrideUpdate(active=False) + + with pytest.raises(ValueError, match="not found"): + mongodb_update_rule_override(override_id, update_params) + + +# ============================================================================= +# TEST: mongodb_delete_rule_override +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_delete_rule_override_success(mock_get_mongodb: Mock): + """Test deleting a rule override.""" + override_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_overrides = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_overrides + + # Mock existing override + mock_overrides.find_one.return_value = { + "override_id": str(override_id), + } + + mongodb_delete_rule_override(override_id) + + # Verify delete was called + assert mock_overrides.delete_one.called + delete_call = mock_overrides.delete_one.call_args[0][0] + assert delete_call == {"override_id": str(override_id)} + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_delete_nonexistent_override(mock_get_mongodb: Mock): + """Test deleting a non-existent override.""" + override_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_overrides = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_overrides + mock_overrides.find_one.return_value = None + + with pytest.raises(ValueError, match="not found"): + mongodb_delete_rule_override(override_id) + + +# ============================================================================= +# TEST: Built-in Systems Seeding +# ============================================================================= + + +@patch( + "builtins.open", + new_callable=mock_open, + read_data='[{"name": "Test System", "is_builtin": true}]', +) +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_builtin_systems_seeded(mock_get_mongodb: Mock, mock_file: Mock): + """Test that built-in systems are seeded on first access.""" + # Mock MongoDB + mock_mongodb = MagicMock() + mock_systems = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_systems + + # First call: no builtin systems exist + mock_systems.count_documents.return_value = 0 + + # Import and call the seeding function directly + from monitor_data.tools.mongodb_tools import _ensure_builtin_systems_seeded + + _ensure_builtin_systems_seeded() + + # Verify seed file was loaded and systems inserted + assert mock_systems.insert_one.called + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_builtin_systems_not_reseeded(mock_get_mongodb: Mock): + """Test that built-in systems are not re-seeded if they exist.""" + # Mock MongoDB + mock_mongodb = MagicMock() + mock_systems = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_systems + + # Builtin systems already exist + mock_systems.count_documents.return_value = 3 + + # Import and call the seeding function + from monitor_data.tools.mongodb_tools import _ensure_builtin_systems_seeded + + _ensure_builtin_systems_seeded() + + # Verify no insert was called + assert not mock_systems.insert_one.called From 8471b937146520215162553bda36ec829de730cb Mon Sep 17 00:00:00 2001 From: spuentesp Date: Wed, 7 Jan 2026 01:05:13 -0300 Subject: [PATCH 2/3] feat(data-layer): Add Narrative Tags built-in system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 4th built-in game system demonstrating fiction-first play: ## Narrative Tags System Key features: - Zero numeric attributes (attributes: []) - 10 descriptive tags as 'skills': Brave, Cunning, Strong-willed, Charming, Observant, Athletic, Scholarly, Connected, Stealthy, Empathetic - Pure narrative resolution via tag relevance + fiction quality - Resources: Resolve (5) and Momentum (3) for narrative economy Custom mechanics: - Oracle outcomes (yes_and, yes, yes_but, no_but, no, no_and) - Tag invocation (spend Resolve for advantage) - Momentum system (gain from risks, spend for narrative control) Demonstrates system flexibility: - Supports zero-stat systems - Skills work as tags without linked attributes - Narrative core mechanic type - custom_dice holds oracle tables and economy rules All 23 tests passing ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../monitor_data/data/builtin_systems.json | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/packages/data-layer/src/monitor_data/data/builtin_systems.json b/packages/data-layer/src/monitor_data/data/builtin_systems.json index 13e4990e..02757d10 100644 --- a/packages/data-layer/src/monitor_data/data/builtin_systems.json +++ b/packages/data-layer/src/monitor_data/data/builtin_systems.json @@ -261,5 +261,121 @@ ], "custom_dice": {}, "is_builtin": true + }, + { + "name": "Narrative Tags", + "description": "Fiction-first system using character tags and narrative positioning. Zero stats, pure story-driven resolution.", + "version": "1.0", + "core_mechanic": { + "type": "narrative", + "formula": "GM adjudication based on tags + fiction", + "success_type": "highest_wins", + "success_threshold": "Player with most relevant tags + best fiction wins", + "critical_success": "Perfect narrative fit with bonus consequence", + "critical_failure": null + }, + "attributes": [], + "skills": [ + { + "name": "Brave", + "abbreviation": null, + "linked_attribute": null, + "description": "Courage in the face of danger, standing firm when others flee" + }, + { + "name": "Cunning", + "abbreviation": null, + "linked_attribute": null, + "description": "Quick-thinking and clever, finding creative solutions" + }, + { + "name": "Strong-willed", + "abbreviation": null, + "linked_attribute": null, + "description": "Mental fortitude, resisting influence and temptation" + }, + { + "name": "Charming", + "abbreviation": null, + "linked_attribute": null, + "description": "Social grace and charisma, winning people over" + }, + { + "name": "Observant", + "abbreviation": null, + "linked_attribute": null, + "description": "Notices details others miss, reads situations well" + }, + { + "name": "Athletic", + "abbreviation": null, + "linked_attribute": null, + "description": "Physical prowess, coordination, and endurance" + }, + { + "name": "Scholarly", + "abbreviation": null, + "linked_attribute": null, + "description": "Well-read and knowledgeable across many subjects" + }, + { + "name": "Connected", + "abbreviation": null, + "linked_attribute": null, + "description": "Has friends and contacts in useful places" + }, + { + "name": "Stealthy", + "abbreviation": null, + "linked_attribute": null, + "description": "Moves unseen and unheard, blends into shadows" + }, + { + "name": "Empathetic", + "abbreviation": null, + "linked_attribute": null, + "description": "Understands and shares the feelings of others" + } + ], + "resources": [ + { + "name": "Resolve", + "abbreviation": "RES", + "calculation": "5", + "min_value": 0, + "recovers_on": "scene end or short rest" + }, + { + "name": "Momentum", + "abbreviation": "MOM", + "calculation": "3", + "min_value": 0, + "recovers_on": "session start" + } + ], + "custom_dice": { + "oracle": { + "description": "Narrative oracle for yes/no questions", + "outcomes": { + "yes_and": "Overwhelming success, additional benefit", + "yes": "Success as intended", + "yes_but": "Success with complication", + "no_but": "Failure with silver lining", + "no": "Failure as feared", + "no_and": "Failure with additional consequence" + } + }, + "tag_invocation": { + "description": "Spend Resolve to invoke a tag for advantage", + "cost_resolve": 1, + "benefit": "Advantage in resolution when tag applies to fiction" + }, + "momentum_rules": { + "description": "Momentum represents narrative flow", + "gain": "Earn when embodying character concept or taking risks", + "spend": "Spend to declare minor narrative facts or push for success" + } + }, + "is_builtin": true } ] From fd6a494968878bf6c94e0d19c4d3affb4056f000 Mon Sep 17 00:00:00 2001 From: spuentesp Date: Wed, 7 Jan 2026 01:10:41 -0300 Subject: [PATCH 3/3] fix(data-layer): Address 6 Copilot PR review comments on DL-20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all review feedback from PR #108: 1. **Fix race condition in seeding** - Changed from count_documents check + insert_one to atomic upsert with $setOnInsert. Multiple processes can now safely seed simultaneously without creating duplicates. 2-5. **Remove redundant imports** - Removed 4 local imports of CoreMechanic, AttributeDefinition, SkillDefinition, ResourceDefinition, and RuleOverrideScope from within functions. Added them to module-level imports at line 85-89 instead. 6. **Add error handling for file I/O** - Added try-except blocks in _load_builtin_game_systems with specific error messages for FileNotFoundError, JSONDecodeError, and OSError. Test updates: - Updated test_builtin_systems_seeded to check update_one instead of insert_one - Renamed test_builtin_systems_not_reseeded to test_builtin_systems_use_atomic_upsert - New test verifies upsert=True and $setOnInsert usage All 23 tests passing ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 0: - return # Already seeded - - # Load and insert builtin systems + # Load and upsert builtin systems atomically to avoid race conditions builtin_systems = _load_builtin_game_systems() now = datetime.now(timezone.utc) for system_data in builtin_systems: - system_id = uuid4() + # Use name (and is_builtin) to uniquely identify each builtin system. + # The upsert ensures only one document is inserted per system, even if + # multiple processes run this seeding code concurrently. + filter_doc = { + "is_builtin": True, + "name": system_data.get("name"), + } doc = { - "system_id": str(system_id), + "system_id": str(uuid4()), + "is_builtin": True, **system_data, "created_at": now, "updated_at": None, } - systems_collection.insert_one(doc) + systems_collection.update_one( + filter_doc, + {"$setOnInsert": doc}, + upsert=True, + ) def mongodb_create_game_system(params: GameSystemCreate) -> GameSystemResponse: @@ -2380,13 +2408,6 @@ def mongodb_get_game_system(system_id: UUID) -> Optional[GameSystemResponse]: if not system_doc: return None - from monitor_data.schemas.game_systems import ( - CoreMechanic, - AttributeDefinition, - SkillDefinition, - ResourceDefinition, - ) - return GameSystemResponse( id=UUID(system_doc["system_id"]), name=system_doc["name"], @@ -2439,13 +2460,6 @@ def mongodb_list_game_systems( .limit(limit) # Alphabetical ) - from monitor_data.schemas.game_systems import ( - CoreMechanic, - AttributeDefinition, - SkillDefinition, - ResourceDefinition, - ) - systems = [] for doc in systems_docs: systems.append( @@ -2626,8 +2640,6 @@ def mongodb_get_rule_override(override_id: UUID) -> Optional[RuleOverrideRespons if not override_doc: return None - from monitor_data.schemas.game_systems import RuleOverrideScope - return RuleOverrideResponse( id=UUID(override_doc["override_id"]), scope=RuleOverrideScope(override_doc["scope"]), @@ -2676,8 +2688,6 @@ def mongodb_list_rule_overrides( # Get all matching overrides (no pagination for now) overrides_docs = overrides_collection.find(query).sort("created_at", -1) - from monitor_data.schemas.game_systems import RuleOverrideScope - overrides = [] for doc in overrides_docs: overrides.append( diff --git a/packages/data-layer/tests/test_tools/test_game_system_tools.py b/packages/data-layer/tests/test_tools/test_game_system_tools.py index 60068870..4f0df747 100644 --- a/packages/data-layer/tests/test_tools/test_game_system_tools.py +++ b/packages/data-layer/tests/test_tools/test_game_system_tools.py @@ -881,26 +881,33 @@ def test_builtin_systems_seeded(mock_get_mongodb: Mock, mock_file: Mock): _ensure_builtin_systems_seeded() - # Verify seed file was loaded and systems inserted - assert mock_systems.insert_one.called + # Verify seed file was loaded and systems upserted (to avoid race conditions) + assert mock_systems.update_one.called +@patch( + "builtins.open", + new_callable=mock_open, + read_data='[{"name": "Test System", "is_builtin": true}]', +) @patch("monitor_data.tools.mongodb_tools.get_mongodb_client") -def test_builtin_systems_not_reseeded(mock_get_mongodb: Mock): - """Test that built-in systems are not re-seeded if they exist.""" +def test_builtin_systems_use_atomic_upsert(mock_get_mongodb: Mock, mock_file: Mock): + """Test that built-in systems use atomic upsert to prevent duplicates.""" # Mock MongoDB mock_mongodb = MagicMock() mock_systems = MagicMock() mock_get_mongodb.return_value = mock_mongodb mock_mongodb.get_collection.return_value = mock_systems - # Builtin systems already exist - mock_systems.count_documents.return_value = 3 - # Import and call the seeding function from monitor_data.tools.mongodb_tools import _ensure_builtin_systems_seeded _ensure_builtin_systems_seeded() - # Verify no insert was called - assert not mock_systems.insert_one.called + # Verify upsert was called with correct parameters + assert mock_systems.update_one.called + call_args = mock_systems.update_one.call_args + # Check that upsert=True was passed + assert call_args[1]["upsert"] is True + # Check that $setOnInsert was used (prevents overwriting existing data) + assert "$setOnInsert" in call_args[0][1]