From 733b28dcea0ca9fc585b855e8db036c8adfea282 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 6 Nov 2026 20:08:10 -0300 Subject: [PATCH 1/3] fix(data): RS-1 RS-2 stop building Death in Space into the platform A named game comes from its ingested book, not from code. Removes the Death in Space builtin entry, the two hand-written spec modules and their package re-export, and its name-based tags. The seeder only inserts, so an existing database keeps the record it already has. The typed-sheet tools only validated games with a hand-written class, which meant only Death in Space. They now validate against each system's spec, so every registered system works, with the legacy class registry as fallback. d20 systems stored as meet_or_beat now map to roll-over, and one whose formula rolls under a target maps to roll-under. D&D 5e, Mistlands Core and Narrative Weighted were all falling through to custom. Tests that used Death in Space to exercise the generic factory and registry now use an invented system, so their checks are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../monitor_data/data/builtin_systems.json | 659 ------------------ .../schemas/rpg_ontology/__init__.py | 18 +- .../schemas/rpg_ontology/converters.py | 21 +- .../schemas/rpg_ontology/specs/__init__.py | 2 +- .../schemas/rpg_ontology/systems/__init__.py | 1 - .../rpg_ontology/systems/death_in_space.py | 537 -------------- .../schemas/rpg_ontology/topology.py | 6 +- .../src/monitor_data/tools/rpg_tools.py | 39 +- .../test_tools/_lantern_drift_spec.py} | 227 ++---- .../tests/test_tools/test_rpg_meta.py | 164 ++--- .../tests/test_tools/test_rpg_ontology.py | 607 +++------------- .../test_rpg_ontology_converters.py | 66 +- .../tests/test_tools/test_rpg_tools.py | 120 ++-- tests/e2e/test_02_system_registry_full.py | 26 +- 14 files changed, 378 insertions(+), 2115 deletions(-) delete mode 100644 packages/data-layer/src/monitor_data/schemas/rpg_ontology/systems/__init__.py delete mode 100644 packages/data-layer/src/monitor_data/schemas/rpg_ontology/systems/death_in_space.py rename packages/data-layer/{src/monitor_data/schemas/rpg_ontology/specs/death_in_space.py => tests/test_tools/_lantern_drift_spec.py} (72%) 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 771eb1e9..14289405 100644 --- a/packages/data-layer/src/monitor_data/data/builtin_systems.json +++ b/packages/data-layer/src/monitor_data/data/builtin_systems.json @@ -1528,665 +1528,6 @@ } ] }, - { - "name": "Death in Space", - "description": "Sci-fi RPG about desperate freelancers in the cold void of space. TTRPG system featuring d20 roll-under, Stress & Debt mechanics, ship-to-ship combat, and mortal danger around every airlock.", - "version": "Core Rules", - "edition": "1st", - "core_mechanic": { - "success_type": "meet_or_beat", - "formula": "1d20", - "type": "d20" - }, - "attributes": [ - { - "name": "Body", - "abbreviation": "BDY", - "description": "Physical strength, endurance, and resilience.", - "min_value": 3, - "max_value": 18, - "default_value": 10 - }, - { - "name": "Dexterity", - "abbreviation": "DEX", - "description": "Agility, reflexes, and precision.", - "min_value": 3, - "max_value": 18, - "default_value": 10 - }, - { - "name": "Insight", - "abbreviation": "INS", - "description": "Perception, reasoning, and technical know-how.", - "min_value": 3, - "max_value": 18, - "default_value": 10 - }, - { - "name": "Bravery", - "abbreviation": "BRV", - "description": "Willpower, courage, and staying calm under fire.", - "min_value": 3, - "max_value": 18, - "default_value": 10 - }, - { - "name": "Charisma", - "abbreviation": "CHA", - "description": "Social influence, leadership, and deception.", - "min_value": 3, - "max_value": 18, - "default_value": 10 - } - ], - "skills": [ - { - "name": "Athletics", - "attribute": "BDY", - "description": "Running, climbing, swimming, jumping in zero-G." - }, - { - "name": "Melee", - "attribute": "BDY", - "description": "Fighting with blades, pipes, or fists." - }, - { - "name": "Ranged", - "attribute": "DEX", - "description": "Shooting firearms and energy weapons." - }, - { - "name": "Stealth", - "attribute": "DEX", - "description": "Moving silently, hiding, pickpocketing." - }, - { - "name": "Engineering", - "attribute": "INS", - "description": "Fixing, building, and sabotaging technology." - }, - { - "name": "Medicine", - "attribute": "INS", - "description": "First aid, surgery, and treating conditions." - }, - { - "name": "Navigation", - "attribute": "INS", - "description": "Plotting courses, reading star charts, piloting." - }, - { - "name": "Perception", - "attribute": "INS", - "description": "Noticing dangers, spotting lies, scanning." - }, - { - "name": "Courage", - "attribute": "BRV", - "description": "Resisting fear, maintaining composure under fire." - }, - { - "name": "Leadership", - "attribute": "CHA", - "description": "Commanding crew, inspiring allies, negotiation." - }, - { - "name": "Deception", - "attribute": "CHA", - "description": "Lying, bluffing, disguises." - }, - { - "name": "Streetwise", - "attribute": "CHA", - "description": "Black markets, criminal contacts, underworld knowledge." - } - ], - "resources": [ - { - "name": "Stress", - "abbreviation": "STR", - "calculation": "3", - "min_value": 0, - "recovers_on": "breather" - }, - { - "name": "Momentum", - "abbreviation": "MOM", - "calculation": "1", - "min_value": 0, - "recovers_on": "session start" - }, - { - "name": "Oxygen", - "abbreviation": "O2", - "calculation": "varies by situation", - "min_value": 0, - "recovers_on": "return to ship or station" - } - ], - "tracks": [ - { - "name": "HP", - "track_type": "resource", - "min_value": 0, - "max_formula": "BDY", - "default_value": 0, - "threshold_effects": [ - { - "value": 1, - "direction": "at_or_below", - "effect": "Disadvantage on physical actions until healed." - }, - { - "value": 0, - "direction": "at_or_below", - "effect": "You are incapacitated and will die in 3 rounds without stabilization." - } - ] - }, - { - "name": "Stress", - "track_type": "stress", - "min_value": 0, - "max_value": 5, - "default_value": 0, - "threshold_effects": [ - { - "value": 2, - "direction": "at_or_below", - "effect": "You are on edge. -1 to all checks." - }, - { - "value": 4, - "direction": "at_or_below", - "effect": "You are barely holding it together. -2 to all checks." - }, - { - "value": 5, - "direction": "at_or_below", - "effect": "You must flee, freeze, or act irrationally." - } - ] - }, - { - "name": "Debt", - "track_type": "degradation", - "min_value": 0, - "max_value": 10, - "default_value": 0, - "threshold_effects": [ - { - "value": 3, - "direction": "at_or_below", - "effect": "Creditors start sending collectors." - }, - { - "value": 6, - "direction": "at_or_below", - "effect": "Your assets are frozen. No legitimate work available." - }, - { - "value": 9, - "direction": "at_or_below", - "effect": "Bounty hunters are actively pursuing you." - } - ] - }, - { - "name": "Ship Hull", - "track_type": "resource", - "min_value": 0, - "max_value": 10, - "default_value": 10, - "threshold_effects": [ - { - "value": 3, - "direction": "at_or_below", - "effect": "Systems failing. Life support compromised." - }, - { - "value": 5, - "direction": "at_or_below", - "effect": "Hull breaches. Some systems offline." - } - ] - } - ], - "damage_model": { - "damage_types": [ - { - "name": "Physical", - "healing_rate": "slow", - "healing_requires": "medicine or rest", - "resisted_by": "armor", - "lethality": "normal", - "bypasses": [] - }, - { - "name": "Stress", - "healing_rate": "moderate", - "healing_requires": "breather", - "resisted_by": "Bravery", - "lethality": "non-lethal", - "bypasses": [] - }, - { - "name": "Environmental", - "healing_rate": "immediate on removal", - "healing_requires": "remove source", - "resisted_by": "BDY save", - "lethality": "normal", - "bypasses": [ - "armor" - ] - } - ], - "damage_track": "HP", - "incapacitated_at": "0", - "death_condition": "3 rounds without stabilization" - }, - "conditions": [ - { - "name": "Wounded", - "trigger": "HP drops to 1 or below", - "mechanical_effects": [ - "Disadvantage on physical actions until healed." - ], - "ends_when": "HP restored above 1", - "stackable": false - }, - { - "name": "Dying", - "trigger": "HP drops to 0", - "mechanical_effects": [ - "Incapacitated.", - "Die in 3 rounds without stabilization." - ], - "ends_when": "stabilized by another character", - "stackable": false - }, - { - "name": "Stressed", - "trigger": "Stress reaches 2+", - "mechanical_effects": [ - "Cumulative -1 to all checks per threshold reached." - ], - "ends_when": "Stress reduced below threshold", - "stackable": false - }, - { - "name": "Breaking Down", - "trigger": "Stress reaches max", - "mechanical_effects": [ - "Must flee, freeze, or act irrationally for the scene." - ], - "ends_when": "scene ends or Stress reduced", - "stackable": false - }, - { - "name": "Low Oxygen", - "trigger": "Oxygen runs out or environment is vacuum", - "mechanical_effects": [ - "Take 1 damage per round.", - "Can't hold breath more than BDY rounds." - ], - "ends_when": "oxygen restored", - "stackable": false - }, - { - "name": "In Debt", - "trigger": "Debt reaches 3+", - "mechanical_effects": [ - "Creditors send collectors.", - "Limited legitimate work." - ], - "ends_when": "Debt reduced below 3", - "stackable": false - } - ], - "action_economy": { - "action_types": [ - { - "name": "Action", - "count_per_turn": 1, - "can_be_used_for": [ - "attack", - "skill check", - "use item", - "interact" - ], - "triggers_on": "your turn" - }, - { - "name": "Move", - "count_per_turn": 1, - "can_be_used_for": [ - "move", - "take cover", - "enter/exit vehicle" - ], - "triggers_on": "your turn" - }, - { - "name": "Free Action", - "count_per_turn": 99, - "can_be_used_for": [ - "talk", - "drop item", - "look around" - ], - "triggers_on": "anytime" - } - ], - "turn_structure": "GM calls for actions when the fiction demands it. No strict initiative order \u2014 GM decides who acts based on narrative positioning.", - "initiative_model": "GM discretion based on fiction and positioning" - }, - "resolution_mechanics": [ - { - "dice_formula": "1d20 \u2264 attribute_score", - "mechanic_type": "d20", - "difficulty_model": "attribute score is the target", - "success_degrees": [ - { - "threshold": "0", - "label": "failure", - "effect": "Action fails, consequences occur" - }, - { - "threshold": "1", - "label": "success", - "effect": "Action succeeds" - } - ], - "success_type": "meet_or_beat", - "critical_success": "Natural 1: exceptional success with bonus effect.", - "critical_failure": "Natural 20: fumble with extra consequence." - }, - { - "dice_formula": "1d20 \u2264 attribute + modifiers", - "mechanic_type": "d20", - "difficulty_model": "target attribute or armor", - "success_degrees": [ - { - "threshold": "0", - "label": "miss", - "effect": "miss" - }, - { - "threshold": "1", - "label": "hit", - "effect": "hit" - } - ], - "success_type": "meet_or_beat", - "critical_success": "Natural 1: critical hit \u2014 double damage or called shot.", - "critical_failure": "Natural 20: weapon jams, friendly fire, or worse." - } - ], - "tiered_ability_systems": [ - { - "name": "Crew Roles", - "description": "Specialized training for shipboard operations.", - "parent_category": "profession", - "acquisition_rule": "Choose one primary role at creation.", - "max_tier": 3, - "access_restriction": "", - "tiers": [ - { - "tier": 1, - "name": "Greenhorn", - "description": "Basic training in the role.", - "effect": "+1 to role-related checks", - "cost": "0", - "prerequisites": [] - }, - { - "tier": 2, - "name": "Veteran", - "description": "Seasoned experience.", - "effect": "+2 to role-related checks, unlock special maneuver", - "cost": "2", - "prerequisites": [ - "Greenhorn" - ] - }, - { - "tier": 3, - "name": "Ace", - "description": "Legendary skill.", - "effect": "+3 to role-related checks, signature move", - "cost": "4", - "prerequisites": [ - "Veteran" - ] - } - ] - } - ], - "recovery_model": { - "events": [ - { - "name": "Breather", - "duration": "a few minutes", - "restores": [ - "HP: 1", - "Stress: 2" - ], - "requires": "safe moment in fiction", - "available_when": "when GM allows" - }, - { - "name": "Full Rest", - "duration": "overnight", - "restores": [ - "HP: full", - "Stress: 0" - ], - "requires": "safe shelter", - "available_when": "between jobs" - } - ] - }, - "advancement_model": { - "currencies": [ - { - "name": "XP", - "earn_conditions": [ - "completing jobs", - "surviving missions", - "creative problem solving" - ] - } - ], - "targets": [ - { - "target_type": "skill", - "target_name": "Skill Rank", - "cost_formula": "current_rank + 1", - "prerequisites": [], - "max_purchases": 3 - } - ], - "uses_levels": false, - "max_level": null - }, - "rules": [ - { - "name": "Roll Under Attributes", - "rule_type": "mechanic", - "description": "To attempt something risky, roll 1d20 and try to get at or below the relevant attribute score. Lower is better.", - "formula": "1d20 \u2264 attribute", - "applies_to_subject": "character", - "tags": [ - "core", - "roll-under", - "d20" - ] - }, - { - "name": "Stress & Consequences", - "rule_type": "mechanic", - "description": "When you fail a check or face horrors, gain 1 Stress. At certain thresholds, penalties apply. At max Stress, you break down.", - "formula": null, - "applies_to_subject": "character", - "tags": [ - "stress", - "mental", - "consequence" - ] - }, - { - "name": "Debt Mechanic", - "rule_type": "mechanic", - "description": "Taking a loan or owing a favor increases Debt. Higher Debt means more dangerous creditors. Complete jobs to pay it off.", - "formula": null, - "applies_to_subject": "character", - "tags": [ - "debt", - "economy", - "fiction" - ] - }, - { - "name": "Ship Combat", - "rule_type": "mechanic", - "description": "Ship combat uses the same roll-under system but against the ship's stats. Hull damage reduces Ship Hull track. At 0, the ship is destroyed.", - "formula": "1d20 \u2264 crew_skill vs opposing ship", - "applies_to_subject": "ship", - "tags": [ - "vehicle", - "combat", - "space" - ] - } - ], - "character_creation": { - "steps": [ - { - "step_number": 1, - "step_type": "generate_attributes", - "title": "Roll Attributes", - "instructions": "Roll 3d6 in order for BDY, DEX, INS, BRV, CHA. These are your attribute scores AND your roll-under targets.", - "is_optional": false, - "options": [], - "derived_stats": [], - "formula_hints": { - "roll": "3d6", - "count": "5" - } - }, - { - "step_number": 2, - "step_type": "choose_class", - "title": "Choose Background", - "instructions": "Pick a background (Military, Corporate, Scavenger, Medical, Criminal, Academic) for skill bonuses.", - "is_optional": false, - "options": [ - "Military", - "Corporate", - "Scavenger", - "Medical", - "Criminal", - "Academic" - ], - "derived_stats": [ - "skill_bonus" - ], - "formula_hints": {} - }, - { - "step_number": 3, - "step_type": "choose_skills", - "title": "Choose Skills", - "instructions": "Pick 3 skills from your background list and 2 from any list. Each trained skill adds +2 to the attribute for rolls.", - "is_optional": false, - "options": [], - "derived_stats": [], - "formula_hints": {} - }, - { - "step_number": 4, - "step_type": "calculate_derived", - "title": "Calculate Derived Stats", - "instructions": "HP = BDY score. Stress starts at 0 (max 5). Debt starts at 0 (or choose to start in debt for bonus skills).", - "is_optional": false, - "options": [], - "derived_stats": [ - "HP", - "Stress", - "Debt" - ], - "formula_hints": { - "HP": "BDY" - } - }, - { - "step_number": 5, - "step_type": "write_backstory", - "title": "Define Why You're Here", - "instructions": "Write a sentence about why you're in deep space and what you owe or need.", - "is_optional": true, - "options": [], - "derived_stats": [], - "formula_hints": {} - } - ], - "ability_score_generation": { - "method": "random_roll", - "roll_formula": "3d6", - "roll_count": 5, - "reroll_ones": false, - "min_score": 3, - "max_score": 18 - }, - "starting_packages": [ - { - "class_or_archetype": "Military", - "items": [ - "Sidearm", - "Flak Vest", - "Comms Unit", - "Rations" - ], - "gold": 50, - "resources": { - "HP": 0 - }, - "name": "Military Starting Package" - }, - { - "class_or_archetype": "Scavenger", - "items": [ - "Multi-tool", - "Vacuum Suit", - "Cutting Torch", - "Rations" - ], - "gold": 20, - "resources": { - "HP": 0 - }, - "name": "Scavenger Starting Package" - }, - { - "class_or_archetype": "Medical", - "items": [ - "Med Kit", - "Diagnostic Scanner", - "Sidearm", - "Rations" - ], - "gold": 30, - "resources": { - "HP": 0 - }, - "name": "Medical Starting Package" - } - ] - }, - "is_builtin": true - }, { "name": "Mistlands Core", "description": "Modular condition-weighted RPG system for the Mistlands setting.", diff --git a/packages/data-layer/src/monitor_data/schemas/rpg_ontology/__init__.py b/packages/data-layer/src/monitor_data/schemas/rpg_ontology/__init__.py index 0f3e9d18..f141b1de 100644 --- a/packages/data-layer/src/monitor_data/schemas/rpg_ontology/__init__.py +++ b/packages/data-layer/src/monitor_data/schemas/rpg_ontology/__init__.py @@ -12,11 +12,13 @@ 1. **Meta / factory** (new, general): - ``meta.py`` — pure-data spec types (GameSystemSpec, FieldSpec, …) - ``factory.py`` — runtime Pydantic model generation via create_model() - - ``specs/`` — built-in system specs (death_in_space, …) + - ``specs/`` — loads every system in builtin_systems.json into the registry 2. **Base / static** (legacy, kept for backward compatibility): - ``base.py`` — abstract base classes (BaseCharacterSheet, …) - - ``systems/`` — per-system Python subclasses (being phased out) + +No game is written into the platform as code. A system comes from builtin_systems.json +or from an ingested book. Usage (new approach — preferred) --------------------------------- @@ -25,17 +27,11 @@ from monitor_data.schemas.rpg_ontology import ( GameSystemSpec, SystemRegistry, make_entity_model, validate_entity_data, - DEATH_IN_SPACE, ) registry = SystemRegistry() - spec = registry.get("death_in_space") + spec = registry.get("dnd_5e") CharacterModel = make_entity_model(spec, "character") - - sheet = CharacterModel( - character_name="Zyx", body=1, dex=2, savvy=-1, tech=0, - hp=7, cosmic_rot=0, credits=10, debt=0, - ) """ # ── New general meta-schema exports ────────────────────────────────────────── @@ -84,9 +80,6 @@ ValidatorType, ) -# Convenience re-export for Death in Space -from monitor_data.schemas.rpg_ontology.specs.death_in_space import DEATH_IN_SPACE - # Data topology — all 6 backends, cross-store edges, gap report from monitor_data.schemas.rpg_ontology.topology import ( MONITOR_TOPOLOGY, @@ -119,7 +112,6 @@ "make_all_models", "validate_entity_data", "clear_model_cache", - "DEATH_IN_SPACE", # Topology "DataTopology", "StorageNode", diff --git a/packages/data-layer/src/monitor_data/schemas/rpg_ontology/converters.py b/packages/data-layer/src/monitor_data/schemas/rpg_ontology/converters.py index e601ab52..f42361b3 100644 --- a/packages/data-layer/src/monitor_data/schemas/rpg_ontology/converters.py +++ b/packages/data-layer/src/monitor_data/schemas/rpg_ontology/converters.py @@ -19,7 +19,7 @@ ---------------- - **One converter function, all systems** — no per-system Python subclasses. The JSON schema is uniform enough that a universal mapping function handles - all 7 systems correctly. + every system correctly. - **Lossless where possible** — every field in the JSON maps to something in the spec. Unknown fields are stored in ``GameSystemSpec.description`` or discarded with a logged warning. @@ -45,9 +45,6 @@ # Now factory works for all systems CharacterModel = make_entity_model(registry.get("dnd_5e"), "character") - - # Death in Space also works - DISModel = make_entity_model(registry.get("death_in_space"), "character") """ from __future__ import annotations @@ -265,14 +262,18 @@ def _detect_core_mechanic_type( """Detect OntologyMechanicType from a core_mechanic dict.""" if not isinstance(core_mechanic, dict): core_mechanic = core_mechanic.model_dump() if hasattr(core_mechanic, "model_dump") else dict(core_mechanic) - formula = core_mechanic.get("formula", "") + formula = str(core_mechanic.get("formula") or "").lower() mechanic_type = core_mechanic.get("type", "").lower() success_type = core_mechanic.get("success_type", "").lower() if "d20" in formula or mechanic_type == "d20": - if success_type in ("under", "roll_under"): + # The schema has no roll-under success type, so a roll-under system says so in + # its formula or threshold text, and "meet_or_beat" alone means rolling high. + threshold = str(core_mechanic.get("success_threshold") or "").lower() + says_under = "<=" in formula or any(t in f"{formula} {threshold}" for t in ("roll under", "under or equal")) + if success_type in ("under", "roll_under") or says_under: return OntologyMechanicType.D20_UNDER - if success_type in ("over", "roll_over", "dc"): + if success_type in ("over", "roll_over", "dc", "meet_or_beat"): return OntologyMechanicType.D20_OVER if "2d6" in formula or mechanic_type == "2d6": return OntologyMechanicType.DICE_POOL @@ -674,8 +675,6 @@ def builtin_to_game_system_spec( tags = ["narrative", "pbta", "2d6"] elif "vampire" in name_lower or "masquerade" in name_lower: tags = ["horror", "personal-horror", "vampire", "world-of-darkness"] - elif "death in space" in name_lower: - tags = ["sci-fi", "space", "mork-borg"] elif "narrative" in name_lower: tags = ["narrative", "fiction-first"] else: @@ -708,9 +707,7 @@ def load_all_builtin_specs( """ Load all entries from builtin_systems.json and convert to GameSystemSpec list. - Returns a list of all 7 specs (including Death in Space, even though it - already has a hand-written spec — the JSON entry is used as the canonical - source so both representations are equivalent). + Returns one spec per entry in the file. Raises ------ diff --git a/packages/data-layer/src/monitor_data/schemas/rpg_ontology/specs/__init__.py b/packages/data-layer/src/monitor_data/schemas/rpg_ontology/specs/__init__.py index ca18f452..97b5bbf3 100644 --- a/packages/data-layer/src/monitor_data/schemas/rpg_ontology/specs/__init__.py +++ b/packages/data-layer/src/monitor_data/schemas/rpg_ontology/specs/__init__.py @@ -5,7 +5,7 @@ No longer imports .py files directly — instead calls the JSON seed loader which produces GameSystemSpec objects for ALL systems in builtin_systems.json. -Death in Space and all other systems are now loaded from: +All systems are loaded from: packages/data-layer/src/monitor_data/data/builtin_systems.json Each spec in that file is converted to a GameSystemSpec at startup via diff --git a/packages/data-layer/src/monitor_data/schemas/rpg_ontology/systems/__init__.py b/packages/data-layer/src/monitor_data/schemas/rpg_ontology/systems/__init__.py deleted file mode 100644 index 321529d1..00000000 --- a/packages/data-layer/src/monitor_data/schemas/rpg_ontology/systems/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Systems subpackage — concrete game system implementations.""" diff --git a/packages/data-layer/src/monitor_data/schemas/rpg_ontology/systems/death_in_space.py b/packages/data-layer/src/monitor_data/schemas/rpg_ontology/systems/death_in_space.py deleted file mode 100644 index 216d8063..00000000 --- a/packages/data-layer/src/monitor_data/schemas/rpg_ontology/systems/death_in_space.py +++ /dev/null @@ -1,537 +0,0 @@ -""" -Death in Space RPG — typed ontology implementation. - -LAYER: 1 (data-layer) -IMPORTS FROM: External libraries only + monitor_data.schemas.rpg_ontology.base - -Source ------- -*Death in Space Core Rules* (2022), MORK BORG Publishing. -ISBN: 978-91-988176-0-1 - -System Summary --------------- -Death in Space is a gritty sci-fi RPG set in a decaying space station called -the Ypsilon 14 Ring. Characters are desperate drifters fighting for survival -in a dying universe. - -Core Mechanics: - - Roll d20 under attribute (lower is riskier) - - Attributes: BODY, DEX, SAVVY, TECH (-3 to +3) - - HP = max(1, BODY + 6) - - Cosmic Rot: 0-10 scale — cosmic mutations, powers, and corruption - -This module registers **DEATH_IN_SPACE** with the ``GameSystemRegistry`` -at import time. - -Usage ------ -:: - - from monitor_data.schemas.rpg_ontology.systems.death_in_space import ( - DISCharacterSheet, - DISWeapon, - DISArmor, - DISOrigin, - DEATH_IN_SPACE, - ) - - sheet = DISCharacterSheet( - entity_id=uuid4(), - character_name="Zyx the Wanderer", - body=1, dex=2, savvy=-1, tech=0, - hp=7, max_hp=7, - origin=DISOrigin.CONTRACTOR, - drive="Find my lost crew", - ) -""" - -from __future__ import annotations - -from enum import Enum, StrEnum -from typing import ClassVar - -from pydantic import Field, model_validator - -from monitor_data.schemas.rpg_ontology.base import ( - AttributeSpec, - BaseArmor, - BaseCharacterSheet, - BaseEquipmentItem, - BaseGameSystem, - BaseWeapon, - GameSystemRegistry, - ItemType, - ResourceSpec, -) - -# --------------------------------------------------------------------------- -# System-level enums -# --------------------------------------------------------------------------- - -SYSTEM_ID = "death_in_space" - - -class DISDamageDie(StrEnum): - """Legal damage dice in Death in Space.""" - - D2 = "d2" # improvised / unarmed - D4 = "d4" - D6 = "d6" - D8 = "d8" - D10 = "d10" - D12 = "d12" - - -class DISArmorProtection(int, Enum): - """Protection values for Death in Space armor.""" - - NONE = 0 # unarmored (built-in baseline, not worn armor) - LIGHT = 1 # light suit, padded jacket - MEDIUM = 2 # security armor, combat suit - HEAVY = 3 # environmental suit, military plate - - -class DISOrigin(StrEnum): - """ - Character origins (backgrounds) from the Death in Space core rules. - - Origins grant starting skills, define the character's past, and sometimes - provide a unique ability or starting item. - """ - - CONTRACTOR = "contractor" # Corporate enforcer / debt collector - CULTIST = "cultist" # Void-worshipping true believer - DRIFTER = "drifter" # Homeless wanderer of the ring - ENGINEER = "engineer" # Station maintenance and repair - GLADIATOR = "gladiator" # Pit fighter from the Arena districts - GEARHEAD = "gearhead" # Ship mechanic / technician - MINER = "miner" # Ore extraction and hard labor - MEDIC = "medic" # Battlefield field surgeon - PIRATE = "pirate" # Void raider and opportunist - RESEARCHER = "researcher" # Scientist stranded in the ring - SOLDIER = "soldier" # Former military (any faction) - TRADER = "trader" # Merchant, fence, and scrounger - - -class DISSkill(StrEnum): - """ - Skills from Death in Space (core rules). - - Skills give +2 to relevant attribute rolls. - """ - - SHARP = "Sharp" # Perception and awareness - SNEAKY = "Sneaky" # Stealth and deception - TOUGH = "Tough" # Endurance and pain tolerance - TINKER = "Tinker" # Repair and jury-rig technology - COMMAND = "Command" # Leadership and persuasion - MEDIC_SKILL = "Medic" # First aid and surgery - PILOT = "Pilot" # Spacecraft and vehicle handling - TRADE = "Trade" # Commerce, haggling, and contacts - MUSCLE = "Muscle" # Brute strength applications - SCAVENGE = "Scavenge" # Finding useful junk in wreckage - - -class DISWeaponProperty(StrEnum): - """Special weapon properties.""" - - ENERGY = "energy" # Bypasses some armor ratings - EXPLOSIVE = "explosive" # AOE damage - HEAVY = "heavy" # Requires BODY +1 or higher to use - SILENT = "silent" # No noise when fired / used - SLOW = "slow" # Requires action to reload per shot - THROWN = "thrown" # Can be used melee or thrown - TWO_HANDED = "two_handed" # Requires both hands - - -# --------------------------------------------------------------------------- -# Typed item schemas -# --------------------------------------------------------------------------- - - -class DISWeapon(BaseWeapon): - """ - Death in Space weapon — typed damage die + energy/physical distinction. - - Examples from core rules (p.38-39): - Plasma Cutter d6 energy short range - Scrapper's Pistol d6 ranged 30m - Combat Knife d4 melee - Assault Rifle d10 ranged 100m slow - """ - - damage_formula: DISDamageDie = Field(description="Damage die (d2, d4, d6, d8, d10, d12)") - properties: list[DISWeaponProperty] = Field(default_factory=list) - range_m: int | None = Field(None, ge=0) - is_ranged: bool = Field(default=False) - - @model_validator(mode="after") - def ranged_needs_range(self) -> DISWeapon: - if self.is_ranged and self.range_m is None: - raise ValueError("Ranged weapons must specify range_m") - return self - - -class DISArmor(BaseArmor): - """ - Death in Space armor — protection 0-3 scale. - - Examples from core rules (p.40): - Padded Jacket protection=1 (Light) - Security Armor protection=2 (Medium) - Full Environ. protection=3 (Heavy) - """ - - protection: DISArmorProtection = Field( - description="Damage reduction value (0=none, 1=light, 2=medium, 3=heavy)", - ) - blocks_cosmic_rot: bool = Field( - default=False, - description="Some environmental suits also block Cosmic Rot exposure", - ) - - -class DISEquipmentItem(BaseEquipmentItem): - """Generic Death in Space equipment (not weapon or armor).""" - - uses: int | None = Field( - None, - ge=1, - description="Number of uses before depleted (None = unlimited)", - ) - requires_power: bool = Field( - default=False, - description="Requires a power cell or reactor to function", - ) - - -# --------------------------------------------------------------------------- -# Ship schema (space vessel — unique to Death in Space) -# --------------------------------------------------------------------------- - - -class DISShipClass(StrEnum): - """Ship class tiers.""" - - SHUTTLE = "shuttle" # 2 crew max - FREIGHTER = "freighter" # 4-6 crew - WARSHIP = "warship" # 8-20 crew - STATION = "station" # Fixed — Ypsilon 14-style mega structures - - -class DISDriveType(StrEnum): - """Omega Drive variants.""" - - STANDARD = "standard" # Basic FTL drive - MILITARY = "military" # Faster, more power draw - EXPERIMENTAL = "experimental" # Partially functional void drive - BROKEN = "broken" # Drifting on thrusters only - - -class DISShipModule(StrEnum): - """Installable ship modules.""" - - MEDICAL_BAY = "medical_bay" - CARGO_BAY = "cargo_bay" - WEAPONS_TURRET = "weapons_turret" - SENSOR_ARRAY = "sensor_array" - BOARDING_CLAMP = "boarding_clamp" - SMUGGLING_HOLD = "smuggling_hold" - ENGINEERING_LAB = "engineering_lab" - PASSENGER_QUARTERS = "passenger_quarters" - - -class DISShipSheet(BaseEquipmentItem): - """ - Death in Space ship sheet. - - Ships are not characters, but they are owned by parties and have their own - stats. They are stored in the equipment catalog under item_type=VEHICLE. - """ - - item_type: ItemType = Field(default=ItemType.VEHICLE) - ship_class: DISShipClass - hull_points: int = Field(ge=1, description="Current HP of the ship") - max_hull_points: int = Field(ge=1) - crew_capacity: int = Field(ge=1) - drive_type: DISDriveType = Field(default=DISDriveType.STANDARD) - drive_condition: int = Field( - ge=0, - le=6, - default=6, - description="Drive integrity 0-6 (6=fully functional, 0=destroyed)", - ) - modules: list[DISShipModule] = Field(default_factory=list) - cargo_units: int = Field(ge=0, default=0) - credits_debt: int = Field( - ge=0, - default=0, - description="Loan/debt owed on this ship", - ) - - @model_validator(mode="after") - def hp_lte_max(self) -> DISShipSheet: - if self.hull_points > self.max_hull_points: - raise ValueError(f"hull_points ({self.hull_points}) cannot exceed max_hull_points ({self.max_hull_points})") - return self - - -# --------------------------------------------------------------------------- -# Cosmic Powers (gained via Cosmic Rot) -# --------------------------------------------------------------------------- - - -class DISCosmicPower(StrEnum): - """ - Cosmic powers unlocked by Cosmic Rot exposure (core rules p.22-23). - - Powers are gained at certain rot thresholds and become permanent mutations. - They confer both an ability AND a physical mark of corruption. - """ - - VOID_SENSE = "Void Sense" - DRIFT_STEP = "Drift Step" # Teleport short distances - RADIATION_SKIN = "Radiation Skin" # Immune to some radiation - PSYCHIC_SCREAM = "Psychic Scream" - MASS_BENDING = "Mass Bending" # Reduce own mass briefly - ZERO_G_MASTER = "Zero-G Master" - HIVE_MIND = "Hive Mind" # Brief psychic link - VOID_STEP = "Void Step" # Pass through thin barriers - ENTROPY_BURST = "Entropy Burst" # Accelerate decay in objects - FINAL_VOID = "Final Void" # Rot=10: apocalyptic power - - -# --------------------------------------------------------------------------- -# Character sheet — the core typed schema -# --------------------------------------------------------------------------- - - -class DISCharacterSheet(BaseCharacterSheet): - """ - Death in Space character sheet — fully typed and rule-validated. - - Replaces the old ``properties: Dict[str, Any]`` with proper fields. - Every field has its legal range enforced by Pydantic validators. - - Attribute rules (core rules p.10-11): - - All four attributes start at 0 and are modified during character creation - - Valid range: -3 to +3 - - BODY affects HP (HP = max(1, BODY + 6)) - - DEX affects initiative and ranged attacks - - SAVVY affects social skills and perception - - TECH affects hacking, repair, and technical actions - - Character creation order: - 1. Choose origin → grants skill bonuses - 2. Write your drive - 3. Generate attributes (roll 2d6 on a -3 to +3 table, or point-buy) - 4. Calculate HP = max(1, BODY + 6) - 5. Choose starting gear (from origin pack) - 6. Name your character - """ - - system_id: ClassVar[str] = SYSTEM_ID - - # Core attributes (-3 to +3) - body: int = Field(ge=-3, le=3, description="Physical power and durability") - dex: int = Field(ge=-3, le=3, description="Agility and reflexes") - savvy: int = Field(ge=-3, le=3, description="Intuition, wits, and persuasion") - tech: int = Field(ge=-3, le=3, description="Technical skill and hacking") - - # Hit Points - hp: int = Field(ge=0, description="Current Hit Points (0 = incapacitated)") - max_hp: int = Field(ge=1, description="Maximum HP = max(1, BODY + 6)") - - # Cosmic Rot — 0-10 scale - cosmic_rot: int = Field( - ge=0, - le=10, - default=0, - description=("Cosmic Rot accumulation (0=clean, 10=fully corrupted). Powers unlocked at 3, 6, and 9."), - ) - cosmic_powers: list[DISCosmicPower] = Field( - default_factory=list, - description="Powers gained from Cosmic Rot exposure", - ) - cosmic_marks: list[str] = Field( - default_factory=list, - description="Physical corruption marks (free text, e.g. 'glowing eyes')", - ) - - # Background and motivation - origin: DISOrigin | None = Field( - None, - description="Character background/career before the campaign", - ) - drive: str | None = Field( - None, - max_length=200, - description="Personal motivation — why your character pushes forward", - ) - - # Skills - skills: list[DISSkill] = Field( - default_factory=list, - description="Known skills; each grants +2 to relevant attribute rolls", - ) - - # Gear summary (names only — detailed items in EquipmentCatalog) - gear_names: list[str] = Field( - default_factory=list, - description="Names of carried gear items (links to equipment_catalog)", - ) - - # Economy - credits: int = Field(ge=0, default=0, description="Cash credits carried") - debt: int = Field(ge=0, default=0, description="Total debt owed") - - # Conditions - conditions: list[str] = Field( - default_factory=list, - description="Active conditions, e.g. ['Bleeding', 'Zero-G disoriented']", - ) - - # --------------------------------------------------------------------------- - # Validators - # --------------------------------------------------------------------------- - - @model_validator(mode="after") - def validate_max_hp(self) -> DISCharacterSheet: - """HP = max(1, BODY + 6) — enforced at creation, not on updates.""" - expected = max(1, self.body + 6) - if self.max_hp != expected: - raise ValueError( - f"max_hp must be {expected} (= max(1, BODY + 6) where BODY={self.body}), got {self.max_hp}" - ) - return self - - @model_validator(mode="after") - def validate_hp_lte_max(self) -> DISCharacterSheet: - if self.hp > self.max_hp: - raise ValueError(f"hp ({self.hp}) cannot exceed max_hp ({self.max_hp})") - return self - - @model_validator(mode="after") - def validate_cosmic_powers_gated_by_rot(self) -> DISCharacterSheet: - """ - Powers are unlocked at rot thresholds: - rot 3+ → may have 1 power - rot 6+ → may have 2 powers - rot 9+ → may have 3 powers - """ - max_powers = self.cosmic_rot // 3 - if len(self.cosmic_powers) > max_powers: - raise ValueError( - f"cosmic_rot={self.cosmic_rot} allows at most {max_powers} power(s), but got {len(self.cosmic_powers)}" - ) - return self - - @model_validator(mode="after") - def validate_no_duplicate_skills(self) -> DISCharacterSheet: - if len(self.skills) != len(set(self.skills)): - raise ValueError("skills list contains duplicates") - return self - - # --------------------------------------------------------------------------- - # Computed property helpers - # --------------------------------------------------------------------------- - - @property - def stat_total(self) -> int: - """Sum of all four attributes (useful for balance checking).""" - return self.body + self.dex + self.savvy + self.tech - - @property - def is_incapacitated(self) -> bool: - return self.hp <= 0 - - @property - def is_fully_corrupted(self) -> bool: - return self.cosmic_rot >= 10 - - def power_slots_available(self) -> int: - """Number of additional power slots unlocked by current cosmic_rot.""" - return (self.cosmic_rot // 3) - len(self.cosmic_powers) - - -# --------------------------------------------------------------------------- -# System definition — registered with GameSystemRegistry at import -# --------------------------------------------------------------------------- - -DEATH_IN_SPACE = BaseGameSystem( - system_id=SYSTEM_ID, - system_name="Death in Space", - version="1.0", - description=( - "Gritty sci-fi RPG set in the Ypsilon 14 Ring — a decaying space station " - "in a dying universe. Characters are desperate drifters fighting for " - "survival against corporate enforcers, void cults, and each other." - ), - publisher="MORK BORG Publishing", - core_mechanic_formula="d20 + attribute ≥ 12 (or versus opposing roll)", - attributes=[ - AttributeSpec( - name="Body", - abbreviation="BODY", - min_value=-3, - max_value=3, - default_value=0, - description="Physical power, endurance, and raw toughness", - ), - AttributeSpec( - name="Dexterity", - abbreviation="DEX", - min_value=-3, - max_value=3, - default_value=0, - description="Agility, reflexes, and hand-eye coordination", - ), - AttributeSpec( - name="Savvy", - abbreviation="SAVVY", - min_value=-3, - max_value=3, - default_value=0, - description="Street wisdom, intuition, and persuasive ability", - ), - AttributeSpec( - name="Tech", - abbreviation="TECH", - min_value=-3, - max_value=3, - default_value=0, - description="Technical expertise, hacking, and device operation", - ), - ], - resources=[ - ResourceSpec( - name="Hit Points", - abbreviation="HP", - min_value=0, - max_formula="max(1, BODY + 6)", - depleted_effect="Incapacitated — make a BODY roll or die", - recovers_on="Medical bay treatment or rest with medic", - ), - ResourceSpec( - name="Cosmic Rot", - abbreviation="ROT", - min_value=0, - max_formula="10", # hard cap - depleted_effect="At 10: full corruption — character becomes an NPC void creature", - recovers_on="Extremely rare — void purification ritual", - ), - ResourceSpec( - name="Credits", - abbreviation="CR", - min_value=0, - max_formula=None, # unlimited - depleted_effect="Broke — NPC faction debt pressure", - ), - ], - schema_class=("monitor_data.schemas.rpg_ontology.systems.death_in_space.DISCharacterSheet"), -) - -# Auto-register at import time -_registry = GameSystemRegistry() -_registry.register(DEATH_IN_SPACE, DISCharacterSheet) diff --git a/packages/data-layer/src/monitor_data/schemas/rpg_ontology/topology.py b/packages/data-layer/src/monitor_data/schemas/rpg_ontology/topology.py index e3306b19..4f0bb79d 100644 --- a/packages/data-layer/src/monitor_data/schemas/rpg_ontology/topology.py +++ b/packages/data-layer/src/monitor_data/schemas/rpg_ontology/topology.py @@ -485,11 +485,7 @@ def edges_to(self, backend: StorageBackend) -> list[DataEdgeSpec]: description=("Uploaded source documents. Path pattern: uploads/{doc_id}/{timestamp}_{filename}"), indexed_by=IndexedBy.NO_INDEX, has_mcp_tools=True, - notes=( - "Death in Space PDF confirmed present at " - "monitor/uploads//. " - "Metadata lives in MongoDB 'documents' collection." - ), + notes=("Metadata lives in MongoDB 'documents' collection."), ), ], notes=( diff --git a/packages/data-layer/src/monitor_data/tools/rpg_tools.py b/packages/data-layer/src/monitor_data/tools/rpg_tools.py index bba1a0f1..99d37cff 100644 --- a/packages/data-layer/src/monitor_data/tools/rpg_tools.py +++ b/packages/data-layer/src/monitor_data/tools/rpg_tools.py @@ -58,6 +58,7 @@ TypedCharacterSheetCreate, TypedCharacterSheetResponse, TypedCharacterSheetUpdate, + validate_entity_data, ) # --------------------------------------------------------------------------- @@ -75,6 +76,18 @@ def _spec_registry() -> SystemRegistry: return SystemRegistry() +def _validated_sheet(system_id: str, data: dict[str, Any]) -> dict[str, Any]: + """Validate a character sheet against its system's spec, from data rather than code. + + Raises ValueError if the system is unknown and pydantic.ValidationError if the + sheet doesn't match. + """ + spec = _spec_registry().get(system_id) + if spec is not None and "character" in spec.entity_types: + return validate_entity_data(spec, "character", data).model_dump(mode="json") + return _registry().parse_sheet(system_id, data).model_dump(mode="json") + + # --------------------------------------------------------------------------- # Game system registration # --------------------------------------------------------------------------- @@ -171,9 +184,7 @@ async def rpg_character_create( - ``entity_id`` is an existing Neo4j EntityInstance UUID """ # Validate against the registered Pydantic schema - registry = _registry() - parsed_sheet = registry.parse_sheet(request.system_id, request.sheet_data) - validated_data = parsed_sheet.model_dump(mode="json") + validated_data = _validated_sheet(request.system_id, request.sheet_data) pool = await client._get_pool() async with pool.acquire() as conn: @@ -276,9 +287,7 @@ async def rpg_character_update( # Merge and revalidate merged = {**current.sheet_data, **update.sheet_data} - registry = _registry() - parsed = registry.parse_sheet(current.system_id, merged) - validated = parsed.model_dump(mode="json") + validated = _validated_sheet(current.system_id, merged) pool = await client._get_pool() async with pool.acquire() as conn: @@ -575,21 +584,13 @@ def rpg_validate_sheet( Returns ``{"valid": False, "errors": [...]}`` on validation failure. Never raises — safe to call from untrusted input paths. """ - registry = _registry() - sheet_class = registry.get_sheet_class(system_id) - if sheet_class is None: - return { - "valid": False, - "errors": [f"Unknown game system: '{system_id}'"], - } try: - sheet_class.model_validate(sheet_data) + _validated_sheet(system_id, sheet_data) return {"valid": True, "errors": []} - except ValidationError as exc: - return { - "valid": False, - "errors": [str(e) for e in exc.errors()], - } + except ValidationError as exc: # A ValueError too, so it's caught first. + return {"valid": False, "errors": [str(e) for e in exc.errors()]} + except ValueError as exc: # Unknown system. + return {"valid": False, "errors": [str(exc)]} # --------------------------------------------------------------------------- diff --git a/packages/data-layer/src/monitor_data/schemas/rpg_ontology/specs/death_in_space.py b/packages/data-layer/tests/test_tools/_lantern_drift_spec.py similarity index 72% rename from packages/data-layer/src/monitor_data/schemas/rpg_ontology/specs/death_in_space.py rename to packages/data-layer/tests/test_tools/_lantern_drift_spec.py index 75c841b5..37ed7b26 100644 --- a/packages/data-layer/src/monitor_data/schemas/rpg_ontology/specs/death_in_space.py +++ b/packages/data-layer/tests/test_tools/_lantern_drift_spec.py @@ -1,49 +1,7 @@ -""" -Death in Space — pure data ``GameSystemSpec``. - -LAYER: 1 (data-layer) -IMPORTS FROM: External libraries only + monitor_data.schemas.rpg_ontology.meta - -Source ------- -*Death in Space Core Rules* (2022), MORK BORG Publishing. -ISBN: 978-91-988176-0-1 - -This module replaces the old Python-subclass approach (DISCharacterSheet, -DISWeapon, etc.) with a single ``GameSystemSpec`` object that is entirely -JSON-serialisable and requires **no Python code to add a new game system**. - -Usage ------ -:: - - from monitor_data.schemas.rpg_ontology.specs.death_in_space import DEATH_IN_SPACE - - # Access via the registry - from monitor_data.schemas.rpg_ontology.meta import SystemRegistry - registry = SystemRegistry() - spec = registry.get("death_in_space") - - # Generate a validated Pydantic class at runtime - from monitor_data.schemas.rpg_ontology.factory import make_entity_model - CharacterModel = make_entity_model(spec, "character") - - sheet = CharacterModel( - character_name="Zyx", - body=1, dex=2, savvy=-1, tech=0, - hp=7, - cosmic_rot=0, - credits=10, debt=0, - ) +"""An invented game system for tests: no real game is written into the platform. -Validation rules encoded as data ---------------------------------- -- BODY, DEX, SAVVY, TECH: range -3 to +3 -- max_hp = max(1, body + 6) (derived formula) -- hp: 0 ≤ hp ≤ max_hp (range + validator) -- cosmic_rot: 0 ≤ cosmic_rot ≤ 10 (range) -- cosmic_powers: len ≤ floor(cosmic_rot / 3) (count_gate) -- Ship: hull_points ≤ max_hull_points (range via validator) +It has the shape of a rules-light d20 roll-under game, so the spec factory's ranges, +derived formulas, gated lists, item subtypes and data edges all get exercised. """ from monitor_data.schemas.rpg_ontology.meta import ( @@ -58,7 +16,6 @@ RelationCardinality, RelationSpec, StorageBackend, - SystemRegistry, ValidatorSpec, ValidatorType, ) @@ -69,69 +26,17 @@ _DAMAGE_DICE = ["d2", "d4", "d6", "d8", "d10", "d12"] -_ORIGINS = [ - "contractor", - "cultist", - "drifter", - "engineer", - "gladiator", - "gearhead", - "miner", - "medic", - "pirate", - "researcher", - "soldier", - "trader", -] +_ORIGINS = ["lamplighter", "ferryman", "archivist", "smuggler", "tinker", "pilgrim"] -_SKILLS = [ - "Sharp", - "Sneaky", - "Tough", - "Tinker", - "Command", - "Medic", - "Pilot", - "Trade", - "Muscle", - "Scavenge", -] +_SKILLS = ["Climb", "Haggle", "Mend", "Steer", "Hide", "Bargain", "Read", "Brawl"] -_COSMIC_POWERS = [ - "Void Sense", - "Drift Step", - "Radiation Skin", - "Psychic Scream", - "Mass Bending", - "Zero-G Master", - "Hive Mind", - "Void Step", - "Entropy Burst", - "Final Void", -] +_GIFTS = ["Glow Sight", "Quiet Step", "Warm Hands", "Far Call", "Still Mind"] -_WEAPON_PROPERTIES = [ - "energy", - "explosive", - "heavy", - "silent", - "slow", - "thrown", - "two_handed", -] +_WEAPON_PROPERTIES = ["loud", "hooked", "two_handed", "thrown", "fragile"] -_SHIP_CLASSES = ["shuttle", "freighter", "warship", "station"] -_DRIVE_TYPES = ["standard", "military", "experimental", "broken"] -_SHIP_MODULES = [ - "medical_bay", - "cargo_bay", - "weapons_turret", - "sensor_array", - "boarding_clamp", - "smuggling_hold", - "engineering_lab", - "passenger_quarters", -] +_SHIP_CLASSES = ["skiff", "barge", "lantern_boat", "houseboat"] +_DRIVE_TYPES = ["standard", "oared", "steam", "broken"] +_SHIP_MODULES = ["hold", "galley", "lamp_room", "bunks", "winch"] # --------------------------------------------------------------------------- # Character entity type @@ -154,8 +59,8 @@ description="True for PCs, False for NPCs", ), FieldSpec( - name="body", - display_name="BODY", + name="grit", + display_name="GRIT", field_type=FieldType.INT, min_value=-3, max_value=3, @@ -163,8 +68,8 @@ description="Physical power and durability", ), FieldSpec( - name="dex", - display_name="DEX", + name="nerve", + display_name="NERVE", field_type=FieldType.INT, min_value=-3, max_value=3, @@ -172,8 +77,8 @@ description="Agility and reflexes", ), FieldSpec( - name="savvy", - display_name="SAVVY", + name="wits", + display_name="WITS", field_type=FieldType.INT, min_value=-3, max_value=3, @@ -181,23 +86,23 @@ description="Intuition, wits, and persuasion", ), FieldSpec( - name="tech", - display_name="TECH", + name="craft", + display_name="CRAFT", field_type=FieldType.INT, min_value=-3, max_value=3, default_value=0, description="Technical skill and repair", ), - # Derived: max_hp = max(1, body + 6) + # Derived: max_hp = max(1, grit + 6) FieldSpec( name="max_hp", display_name="Max HP", field_type=FieldType.INT, min_value=1, - derived_formula="max(1, body + 6)", + derived_formula="max(1, grit + 6)", user_settable=False, - description="Maximum HP — derived from BODY: max(1, BODY + 6)", + description="Maximum HP — derived from GRIT: max(1, GRIT + 6)", ), FieldSpec( name="hp", @@ -208,8 +113,8 @@ description="Current HP (0 = incapacitated)", ), FieldSpec( - name="cosmic_rot", - display_name="Cosmic Rot", + name="strain", + display_name="Strain", field_type=FieldType.INT, min_value=0, max_value=10, @@ -217,17 +122,17 @@ description="Corruption level 0-10. Powers unlock at 3, 6, 9.", ), FieldSpec( - name="cosmic_powers", - display_name="Cosmic Powers", + name="gifts", + display_name="Gifts", field_type=FieldType.LIST_ENUM, - enum_values=_COSMIC_POWERS, + enum_values=_GIFTS, default_value=[], required=False, - description="Powers gained from Cosmic Rot", + description="Powers gained from Strain", ), FieldSpec( - name="cosmic_marks", - display_name="Cosmic Marks", + name="marks", + display_name="Marks", field_type=FieldType.LIST_STR, default_value=[], required=False, @@ -300,20 +205,20 @@ _CHARACTER_VALIDATORS: list[ValidatorSpec] = [ ValidatorSpec( - rule_id="cosmic_powers_gated_by_rot", + rule_id="gifts_gated_by_strain", validator_type=ValidatorType.COUNT_GATE, - field="cosmic_powers", - other_field="cosmic_rot", + field="gifts", + other_field="strain", divisor=3, - description="# of cosmic powers ≤ floor(cosmic_rot / 3)", - error_message="Too many cosmic powers for current rot level", + description="# of gifts ≤ floor(strain / 3)", + error_message="Too many gifts for current strain", ), ] CHARACTER_TYPE = EntityTypeSpec( type_key="character", display_name="Character", - description="A player character or NPC in Death in Space", + description="A player character or NPC in Lantern Drift", fields=_CHARACTER_FIELDS, validators=_CHARACTER_VALIDATORS, storage_backends=[StorageBackend.POSTGRES, StorageBackend.MONGODB], @@ -334,7 +239,7 @@ name="item_type", display_name="Item Type", field_type=FieldType.ENUM, - enum_values=["weapon", "armor", "tool", "consumable", "vehicle", "misc", "ship"], + enum_values=["weapon", "armor", "tool", "consumable", "vehicle", "misc", "boat"], description="Ontological classification", ), FieldSpec( @@ -433,34 +338,34 @@ description="Damage reduction (0=none, 1=light, 2=medium, 3=heavy)", ), FieldSpec( - name="blocks_cosmic_rot", - display_name="Blocks Cosmic Rot", + name="blocks_strain", + display_name="Blocks Strain", field_type=FieldType.BOOL, default_value=False, - description="Some env suits also block Cosmic Rot exposure", + description="Some env suits also block Strain exposure", ), ], ) _SHIP_ITEM_TYPE = ItemTypeSpec( - type_key="ship", - display_name="Ship", + type_key="boat", + display_name="Boat", extra_fields=[ FieldSpec( - name="ship_class", - display_name="Ship Class", + name="boat_class", + display_name="Boat Class", field_type=FieldType.ENUM, enum_values=_SHIP_CLASSES, ), FieldSpec( - name="hull_points", - display_name="Hull Points", + name="plank_points", + display_name="Plank Points", field_type=FieldType.INT, min_value=0, ), FieldSpec( - name="max_hull_points", - display_name="Max Hull Points", + name="max_plank_points", + display_name="Max Plank Points", field_type=FieldType.INT, min_value=1, ), @@ -514,13 +419,13 @@ ITEM_TYPE = EntityTypeSpec( type_key="item", display_name="Item", - description="Equipment, weapons, armor, ships, and other items", + description="Equipment, weapons, armor, boats, and other items", fields=_ITEM_BASE_FIELDS, validators=[], item_types={ "weapon": _WEAPON_ITEM_TYPE, "armor": _ARMOR_ITEM_TYPE, - "ship": _SHIP_ITEM_TYPE, + "boat": _SHIP_ITEM_TYPE, }, storage_backends=[StorageBackend.POSTGRES], ) @@ -531,12 +436,12 @@ _RELATIONS: list[RelationSpec] = [ RelationSpec( - relation_id="character_crews_ship", - display_name="Crews Ship", + relation_id="character_crews_boat", + display_name="Crews Boat", from_type="character", to_type="item", cardinality=RelationCardinality.MANY_TO_MANY, - description="Characters can crew ships (via party_inventories join)", + description="Characters can crew boats (via party_inventories join)", ), ] @@ -546,7 +451,7 @@ _DATA_EDGES: list[DataEdgeSpec] = [ DataEdgeSpec( - edge_id="dis_char_sheet_to_neo4j_entity", + edge_id="ld_char_sheet_to_neo4j_entity", description="Character sheet (PostgreSQL) links to canonical entity in Neo4j", from_backend=StorageBackend.POSTGRES, from_collection="character_sheet_index", @@ -558,7 +463,7 @@ notes="Soft reference — no FK across backends", ), DataEdgeSpec( - edge_id="dis_char_working_state_to_sheet", + edge_id="ld_char_working_state_to_sheet", description="In-scene working state (MongoDB) references the character entity", from_backend=StorageBackend.MONGODB, from_collection="character_working_state", @@ -570,7 +475,7 @@ notes="Ephemeral in-scene state; not canonical", ), DataEdgeSpec( - edge_id="dis_memory_to_entity", + edge_id="ld_memory_to_entity", description="Character memory (MongoDB) references entity in Neo4j", from_backend=StorageBackend.MONGODB, from_collection="memories", @@ -581,7 +486,7 @@ is_enforced=False, ), DataEdgeSpec( - edge_id="dis_game_system_to_source_doc", + edge_id="ld_game_system_to_source_doc", description="Game system record in MongoDB points to the source PDF in MinIO", from_backend=StorageBackend.MONGODB, from_collection="game_systems", @@ -590,7 +495,6 @@ to_collection="monitor/uploads", to_field="doc_id", is_enforced=False, - notes="Death in Space PDF stored at monitor/uploads//", ), ] @@ -615,17 +519,13 @@ # The spec # --------------------------------------------------------------------------- -DEATH_IN_SPACE: GameSystemSpec = GameSystemSpec( - system_id="death_in_space", - system_name="Death in Space", +LANTERN_DRIFT: GameSystemSpec = GameSystemSpec( + system_id="lantern_drift", + system_name="Lantern Drift", version="1.0", - description=( - "Gritty sci-fi RPG set in a decaying space station (Ypsilon 14 Ring). " - "Characters are desperate drifters fighting for survival in a dying universe. " - "Core Rules, MORK BORG Publishing, 2022." - ), - publisher="MORK BORG Publishing", - tags=["sci-fi", "horror", "space", "OSR", "rules-light"], + description="An invented game for tests: lamp-boat crews on a flooded city's canals.", + publisher=None, + tags=["invented", "test-fixture"], core_mechanic=_CORE_MECHANIC, entity_types={ "character": CHARACTER_TYPE, @@ -634,6 +534,3 @@ relations=_RELATIONS, data_edges=_DATA_EDGES, ) - -# Auto-register at import time -SystemRegistry().register(DEATH_IN_SPACE) diff --git a/packages/data-layer/tests/test_tools/test_rpg_meta.py b/packages/data-layer/tests/test_tools/test_rpg_meta.py index bba5f7e5..770d2f53 100644 --- a/packages/data-layer/tests/test_tools/test_rpg_meta.py +++ b/packages/data-layer/tests/test_tools/test_rpg_meta.py @@ -4,7 +4,7 @@ Tests cover: - meta.py — FieldSpec, ValidatorSpec, EntityTypeSpec, GameSystemSpec, SystemRegistry - factory.py — make_entity_model, validate_entity_data, clear_model_cache - - specs/death_in_space.py — DEATH_IN_SPACE spec integrity and DIS game rules + - an invented system (Lantern Drift) exercising the factory end to end """ import pytest @@ -30,7 +30,8 @@ ValidatorSpec, ValidatorType, ) -from monitor_data.schemas.rpg_ontology.specs.death_in_space import DEATH_IN_SPACE + +from _lantern_drift_spec import LANTERN_DRIFT # --------------------------------------------------------------------------- # Helpers @@ -332,70 +333,75 @@ def test_validate_entity_data(self): # =========================================================================== -# Death in Space spec +# Invented system spec (Lantern Drift) # =========================================================================== -class TestDeathInSpaceSpec: - def test_spec_is_registered(self): +class TestInventedSystemSpec: + def test_spec_registers_and_unregisters(self): registry = SystemRegistry() - spec = registry.get("death_in_space") - assert spec is not None - assert spec.system_name == "Death in Space" + registry.register(LANTERN_DRIFT) + try: + spec = registry.get("lantern_drift") + assert spec is not None + assert spec.system_name == "Lantern Drift" + finally: + registry.unregister("lantern_drift") + assert registry.get("lantern_drift") is None def test_entity_types(self): - assert "character" in DEATH_IN_SPACE.entity_types - assert "item" in DEATH_IN_SPACE.entity_types + assert "character" in LANTERN_DRIFT.entity_types + assert "item" in LANTERN_DRIFT.entity_types def test_core_mechanic_is_d20_under(self): - assert DEATH_IN_SPACE.core_mechanic.mechanic_type == OntologyMechanicType.D20_UNDER + assert LANTERN_DRIFT.core_mechanic.mechanic_type == OntologyMechanicType.D20_UNDER def test_character_fields_include_four_attributes(self): - char = DEATH_IN_SPACE.entity_types["character"] + char = LANTERN_DRIFT.entity_types["character"] field_names = [f.name for f in char.fields] - for attr in ["body", "dex", "savvy", "tech"]: + for attr in ["grit", "nerve", "wits", "craft"]: assert attr in field_names, f"Missing attribute: {attr}" def test_max_hp_is_derived(self): - char = DEATH_IN_SPACE.entity_types["character"] + char = LANTERN_DRIFT.entity_types["character"] max_hp_field = next(f for f in char.fields if f.name == "max_hp") - assert max_hp_field.derived_formula == "max(1, body + 6)" + assert max_hp_field.derived_formula == "max(1, grit + 6)" assert max_hp_field.user_settable is False def test_data_edges_reference_existing_backends(self): - for edge in DEATH_IN_SPACE.data_edges: + for edge in LANTERN_DRIFT.data_edges: assert isinstance(edge.from_backend, StorageBackend) assert isinstance(edge.to_backend, StorageBackend) def test_spec_round_trips_json(self): - data = DEATH_IN_SPACE.model_dump() + data = LANTERN_DRIFT.model_dump() spec2 = GameSystemSpec.model_validate(data) - assert spec2.system_id == "death_in_space" - assert len(spec2.entity_types) == len(DEATH_IN_SPACE.entity_types) + assert spec2.system_id == "lantern_drift" + assert len(spec2.entity_types) == len(LANTERN_DRIFT.entity_types) # =========================================================================== -# Death in Space — character model (factory-generated) +# Invented system — character model (factory-generated) # =========================================================================== -class TestDISCharacterModel: +class TestInventedCharacterModel: @pytest.fixture(autouse=True) def model(self): - clear_model_cache("death_in_space") - self.CharacterModel = make_entity_model(DEATH_IN_SPACE, "character") + clear_model_cache("lantern_drift") + self.CharacterModel = make_entity_model(LANTERN_DRIFT, "character") yield - clear_model_cache("death_in_space") + clear_model_cache("lantern_drift") def _make(self, **kwargs): defaults = { - "character_name": "Zyx", - "body": 0, - "dex": 0, - "savvy": 0, - "tech": 0, + "character_name": "Wren", + "grit": 0, + "nerve": 0, + "wits": 0, + "craft": 0, "hp": 6, - "cosmic_rot": 0, + "strain": 0, "credits": 0, "debt": 0, } @@ -403,84 +409,84 @@ def _make(self, **kwargs): return self.CharacterModel(**defaults) def test_create_basic(self): - sheet = self._make(character_name="Zyx") - assert sheet.character_name == "Zyx" + sheet = self._make(character_name="Wren") + assert sheet.character_name == "Wren" - def test_max_hp_derived_body_zero(self): - sheet = self._make(body=0, hp=6) + def test_max_hp_derived_grit_zero(self): + sheet = self._make(grit=0, hp=6) assert sheet.max_hp == 6 # max(1, 0+6) - def test_max_hp_derived_body_plus_three(self): - sheet = self._make(body=3, hp=9) + def test_max_hp_derived_grit_plus_three(self): + sheet = self._make(grit=3, hp=9) assert sheet.max_hp == 9 # max(1, 3+6) - def test_max_hp_derived_body_minus_three(self): - sheet = self._make(body=-3, hp=1) + def test_max_hp_derived_grit_minus_three(self): + sheet = self._make(grit=-3, hp=1) assert sheet.max_hp == 3 # max(1, -3+6) still = 3 def test_max_hp_floor_is_one_regardless(self): - # Minimum possible: BODY=-3 → max(1, -3+6)=3, not <1 - sheet = self._make(body=-3, hp=1) + # Minimum possible: GRIT=-3 → max(1, -3+6)=3, not <1 + sheet = self._make(grit=-3, hp=1) assert sheet.max_hp >= 1 - def test_body_range_enforced(self): + def test_grit_range_enforced(self): with pytest.raises(ValidationError): - self._make(body=4) + self._make(grit=4) with pytest.raises(ValidationError): - self._make(body=-4) + self._make(grit=-4) - def test_dex_range_enforced(self): + def test_nerve_range_enforced(self): with pytest.raises(ValidationError): - self._make(dex=5) + self._make(nerve=5) - def test_cosmic_rot_range_enforced(self): + def test_strain_range_enforced(self): with pytest.raises(ValidationError): - self._make(cosmic_rot=11) + self._make(strain=11) with pytest.raises(ValidationError): - self._make(cosmic_rot=-1) + self._make(strain=-1) - def test_cosmic_rot_zero_no_powers(self): - sheet = self._make(cosmic_rot=0, cosmic_powers=[]) - assert sheet.cosmic_rot == 0 + def test_strain_zero_no_powers(self): + sheet = self._make(strain=0, gifts=[]) + assert sheet.strain == 0 - def test_cosmic_powers_gated_rot_three(self): - """floor(3/3)=1 power allowed at rot=3.""" - sheet = self._make(cosmic_rot=3, cosmic_powers=["Void Sense"]) - assert len(sheet.cosmic_powers) == 1 + def test_gifts_gated_strain_three(self): + """floor(3/3)=1 gift allowed at strain=3.""" + sheet = self._make(strain=3, gifts=["Glow Sight"]) + assert len(sheet.gifts) == 1 - def test_cosmic_powers_too_many_for_rot(self): - """2 powers requires rot ≥ 6.""" + def test_gifts_too_many_for_strain(self): + """2 gifts require strain ≥ 6.""" with pytest.raises((ValidationError, ValueError)): - self._make(cosmic_rot=3, cosmic_powers=["Void Sense", "Drift Step"]) + self._make(strain=3, gifts=["Glow Sight", "Quiet Step"]) - def test_cosmic_powers_six_rot_two_allowed(self): - """floor(6/3)=2 powers allowed at rot=6.""" - sheet = self._make(cosmic_rot=6, cosmic_powers=["Void Sense", "Drift Step"]) - assert len(sheet.cosmic_powers) == 2 + def test_gifts_six_strain_two_allowed(self): + """floor(6/3)=2 gifts allowed at strain=6.""" + sheet = self._make(strain=6, gifts=["Glow Sight", "Quiet Step"]) + assert len(sheet.gifts) == 2 - def test_cosmic_powers_nine_rot_three_allowed(self): - """floor(9/3)=3 powers allowed at rot=9.""" + def test_gifts_nine_strain_three_allowed(self): + """floor(9/3)=3 gifts allowed at strain=9.""" sheet = self._make( - cosmic_rot=9, - cosmic_powers=["Void Sense", "Drift Step", "Radiation Skin"], + strain=9, + gifts=["Glow Sight", "Quiet Step", "Warm Hands"], ) - assert len(sheet.cosmic_powers) == 3 + assert len(sheet.gifts) == 3 def test_invalid_origin_rejected(self): with pytest.raises(ValidationError): self._make(origin="astronaut") def test_valid_origin_accepted(self): - sheet = self._make(origin="soldier") - assert sheet.origin == "soldier" + sheet = self._make(origin="ferryman") + assert sheet.origin == "ferryman" def test_invalid_skill_rejected(self): with pytest.raises(ValidationError): self._make(skills=["Hacking"]) def test_valid_skill_accepted(self): - sheet = self._make(skills=["Pilot", "Tinker"]) - assert "Pilot" in sheet.skills + sheet = self._make(skills=["Steer", "Mend"]) + assert "Steer" in sheet.skills def test_credits_non_negative(self): with pytest.raises(ValidationError): @@ -496,21 +502,21 @@ def test_default_is_player_character(self): # =========================================================================== -# Death in Space — item model (factory-generated) +# Invented system — item model (factory-generated) # =========================================================================== -class TestDISItemModel: +class TestInventedItemModel: @pytest.fixture(autouse=True) def model(self): - clear_model_cache("death_in_space") - self.ItemModel = make_entity_model(DEATH_IN_SPACE, "item") + clear_model_cache("lantern_drift") + self.ItemModel = make_entity_model(LANTERN_DRIFT, "item") yield - clear_model_cache("death_in_space") + clear_model_cache("lantern_drift") def test_create_weapon(self): - item = self.ItemModel(item_name="Combat Knife", item_type="weapon") - assert item.item_name == "Combat Knife" + item = self.ItemModel(item_name="Boat Hook", item_type="weapon") + assert item.item_name == "Boat Hook" def test_invalid_item_type_rejected(self): with pytest.raises(ValidationError): @@ -542,5 +548,5 @@ def test_exec_in_formula_rejected(self): def test_basic_arithmetic(self): from monitor_data.schemas.rpg_ontology.factory import _eval_formula - assert _eval_formula("max(1, body + 6)", {"body": -3}) == 3 + assert _eval_formula("max(1, grit + 6)", {"grit": -3}) == 3 assert _eval_formula("(strength - 10) // 2", {"strength": 16}) == 3 diff --git a/packages/data-layer/tests/test_tools/test_rpg_ontology.py b/packages/data-layer/tests/test_tools/test_rpg_ontology.py index 939a2139..55924be6 100644 --- a/packages/data-layer/tests/test_tools/test_rpg_ontology.py +++ b/packages/data-layer/tests/test_tools/test_rpg_ontology.py @@ -1,574 +1,135 @@ """ -Tests for the RPG Ontology system — covering schema validation, Death in Space -typed character sheets, equipment, and the tool layer. - -Tests are pure unit tests (no DB required): they verify the Pydantic models, -validator rules, and the GameSystemRegistry logic. - -Death in Space rule tests verify: - - Attribute range enforcement (-3 to +3) - - HP = max(1, BODY + 6) invariant - - Cosmic Rot power gating (1 power per 3 rot) - - Weapon damage die typing - - Armor protection range (0-3) - - Ship HP ≤ max_hp - - Discriminated registry lookup +Tests for the RPG ontology's legacy class registry and sheet validation. + +No game is written into the platform as code, so these use invented systems: +a hand-written legacy sheet class here, and the Lantern Drift spec for the +spec-driven path that real systems use. """ +from typing import ClassVar from uuid import uuid4 import pytest -from pydantic import ValidationError +from _lantern_drift_spec import LANTERN_DRIFT +from pydantic import Field, ValidationError -# Import the schema under test (auto-registers at import time) +from monitor_data.schemas.rpg_ontology import SystemRegistry from monitor_data.schemas.rpg_ontology.base import ( + AttributeSpec, + BaseCharacterSheet, + BaseGameSystem, GameSystemRegistry, ) -from monitor_data.schemas.rpg_ontology.systems.death_in_space import ( - DEATH_IN_SPACE, - SYSTEM_ID, - DISArmor, - DISArmorProtection, - DISCharacterSheet, - DISCosmicPower, - DISDamageDie, - DISOrigin, - DISShipClass, - DISShipSheet, - DISSkill, - DISWeapon, - DISWeaponProperty, -) +from monitor_data.schemas.rpg_ontology.factory import clear_model_cache from monitor_data.tools.rpg_tools import rpg_validate_sheet # ============================================================================= -# Helper factory -# ============================================================================= - - -def valid_dis_sheet(**overrides) -> dict: - """Return a valid Death in Space character sheet dict.""" - defaults = { - "entity_id": str(uuid4()), - "character_name": "Zyx the Wanderer", - "body": 1, - "dex": 2, - "savvy": -1, - "tech": 0, - "hp": 7, # max(1, 1 + 6) - "max_hp": 7, - "cosmic_rot": 0, - } - defaults.update(overrides) - return defaults - - -# ============================================================================= -# 1. GameSystemRegistry -# ============================================================================= - - -class TestGameSystemRegistry: - def test_death_in_space_is_auto_registered(self): - reg = GameSystemRegistry() - assert SYSTEM_ID in reg.list_systems() - - def test_get_definition_returns_correct_system(self): - reg = GameSystemRegistry() - defn = reg.get_definition(SYSTEM_ID) - assert defn is not None - assert defn.system_name == "Death in Space" - assert defn.publisher == "MORK BORG Publishing" - - def test_get_sheet_class_returns_dis_class(self): - reg = GameSystemRegistry() - cls = reg.get_sheet_class(SYSTEM_ID) - assert cls is DISCharacterSheet - - def test_parse_sheet_valid(self): - reg = GameSystemRegistry() - sheet = reg.parse_sheet(SYSTEM_ID, valid_dis_sheet()) - assert isinstance(sheet, DISCharacterSheet) - - def test_parse_sheet_unknown_system_raises(self): - reg = GameSystemRegistry() - with pytest.raises(ValueError, match="Unknown game system: 'nonexistent'"): - reg.parse_sheet("nonexistent", {}) - - def test_attribute_specs_have_correct_range(self): - defn = GameSystemRegistry().get_definition(SYSTEM_ID) - for attr in defn.attributes: - assert attr.min_value == -3 - assert attr.max_value == 3 - - def test_validate_attribute_value(self): - defn = GameSystemRegistry().get_definition(SYSTEM_ID) - assert defn.validate_attribute_value("BODY", 3) is True - assert defn.validate_attribute_value("BODY", -3) is True - assert defn.validate_attribute_value("BODY", 4) is False - assert defn.validate_attribute_value("BODY", -4) is False - - def test_attribute_lookup_case_insensitive(self): - defn = GameSystemRegistry().get_definition(SYSTEM_ID) - assert defn.attribute_by_abbr("body") is not None - assert defn.attribute_by_abbr("BODY") is not None - assert defn.attribute_by_abbr("Body") is not None - - -# ============================================================================= -# 2. DISCharacterSheet — attribute validation -# ============================================================================= - - -class TestDISCharacterSheetAttributes: - def test_valid_character_creates_successfully(self): - data = valid_dis_sheet() - sheet = DISCharacterSheet.model_validate(data) - assert sheet.character_name == "Zyx the Wanderer" - assert sheet.body == 1 - - def test_body_max_boundary_accepted(self): - data = valid_dis_sheet(body=3, max_hp=9, hp=9) - sheet = DISCharacterSheet.model_validate(data) - assert sheet.body == 3 - - def test_body_min_boundary_accepted(self): - data = valid_dis_sheet(body=-3, max_hp=3, hp=3) - sheet = DISCharacterSheet.model_validate(data) - assert sheet.body == -3 - - def test_body_above_max_rejected(self): - with pytest.raises(ValidationError, match="less than or equal to 3"): - DISCharacterSheet.model_validate(valid_dis_sheet(body=4, max_hp=10, hp=10)) - - def test_body_below_min_rejected(self): - with pytest.raises(ValidationError, match="greater than or equal to -3"): - DISCharacterSheet.model_validate(valid_dis_sheet(body=-4, max_hp=2, hp=2)) - - def test_all_attributes_in_range(self): - data = valid_dis_sheet(body=-3, dex=3, savvy=-2, tech=1, max_hp=3, hp=3) - sheet = DISCharacterSheet.model_validate(data) - assert sheet.stat_total == -3 + 3 + (-2) + 1 # = -1 - - def test_stat_total_computed_correctly(self): - data = valid_dis_sheet(body=1, dex=2, savvy=-1, tech=0, max_hp=7, hp=7) - sheet = DISCharacterSheet.model_validate(data) - assert sheet.stat_total == 2 # 1+2+(-1)+0 - - -# ============================================================================= -# 3. HP invariant (HP = max(1, BODY + 6)) +# An invented legacy system: one hand-written sheet class # ============================================================================= - -class TestDISHPInvariant: - @pytest.mark.parametrize( - "body,expected_max_hp", - [ - (3, 9), # 3+6 - (1, 7), # 1+6 - (0, 6), # 0+6 - (-1, 5), # -1+6 - (-3, 3), # -3+6 = 3 (above min) - ], - ) - def test_valid_max_hp_accepted(self, body, expected_max_hp): - data = valid_dis_sheet(body=body, max_hp=expected_max_hp, hp=1) - sheet = DISCharacterSheet.model_validate(data) - assert sheet.max_hp == expected_max_hp - - def test_incorrect_max_hp_rejected(self): - # body=1 → expected max_hp=7, but we pass 8 - with pytest.raises(ValidationError, match="max_hp must be 7"): - DISCharacterSheet.model_validate(valid_dis_sheet(body=1, max_hp=8, hp=7)) - - def test_hp_exceeds_max_hp_rejected(self): - with pytest.raises(ValidationError, match="cannot exceed max_hp"): - DISCharacterSheet.model_validate(valid_dis_sheet(body=1, max_hp=7, hp=8)) - - def test_hp_at_zero_is_incapacitated(self): - data = valid_dis_sheet(hp=0) - sheet = DISCharacterSheet.model_validate(data) - assert sheet.is_incapacitated is True - - def test_hp_above_zero_is_not_incapacitated(self): - data = valid_dis_sheet(hp=3) - sheet = DISCharacterSheet.model_validate(data) - assert sheet.is_incapacitated is False - - def test_minimum_max_hp_is_one_when_body_is_minus_five_equivalent(self): - """This cannot happen in DIS (body≥-3 means max_hp≥3), but test the formula.""" - # body=-3 → max(1, -3+6) = max(1,3) = 3 - data = valid_dis_sheet(body=-3, max_hp=3, hp=1) - sheet = DISCharacterSheet.model_validate(data) - assert sheet.max_hp == 3 +_LEGACY_ID = "pebble_and_thread" -# ============================================================================= -# 4. Cosmic Rot power gating -# ============================================================================= +class _PebbleSheet(BaseCharacterSheet): + system_id: ClassVar[str] = _LEGACY_ID + knots: int = Field(ge=0, le=4) -class TestDISCosmicRot: - def test_no_powers_with_zero_rot(self): - data = valid_dis_sheet(cosmic_rot=0, cosmic_powers=[]) - sheet = DISCharacterSheet.model_validate(data) - assert sheet.cosmic_powers == [] - - def test_one_power_allowed_at_rot_3(self): - data = valid_dis_sheet( - cosmic_rot=3, - cosmic_powers=[DISCosmicPower.VOID_SENSE.value], - ) - sheet = DISCharacterSheet.model_validate(data) - assert len(sheet.cosmic_powers) == 1 - - def test_two_powers_allowed_at_rot_6(self): - data = valid_dis_sheet( - cosmic_rot=6, - cosmic_powers=[DISCosmicPower.VOID_SENSE.value, DISCosmicPower.DRIFT_STEP.value], - ) - sheet = DISCharacterSheet.model_validate(data) - assert len(sheet.cosmic_powers) == 2 - - def test_three_powers_allowed_at_rot_9(self): - data = valid_dis_sheet( - cosmic_rot=9, - cosmic_powers=[ - DISCosmicPower.VOID_SENSE.value, - DISCosmicPower.DRIFT_STEP.value, - DISCosmicPower.PSYCHIC_SCREAM.value, - ], - ) - sheet = DISCharacterSheet.model_validate(data) - assert len(sheet.cosmic_powers) == 3 - - def test_too_many_powers_for_rot_level_rejected(self): - # rot=3 allows only 1 power - with pytest.raises(ValidationError, match="allows at most 1 power"): - DISCharacterSheet.model_validate( - valid_dis_sheet( - cosmic_rot=3, - cosmic_powers=[ - DISCosmicPower.VOID_SENSE.value, - DISCosmicPower.DRIFT_STEP.value, - ], - ) - ) - - def test_power_slots_available_computed(self): - data = valid_dis_sheet( - cosmic_rot=6, - cosmic_powers=[DISCosmicPower.VOID_SENSE.value], - ) - sheet = DISCharacterSheet.model_validate(data) - assert sheet.power_slots_available() == 1 # 6//3=2 slots, 1 used - - def test_cosmic_rot_max_is_ten(self): - with pytest.raises(ValidationError, match="less than or equal to 10"): - DISCharacterSheet.model_validate(valid_dis_sheet(cosmic_rot=11)) - - def test_fully_corrupted_flag(self): - data = valid_dis_sheet( - cosmic_rot=10, - cosmic_powers=[ - DISCosmicPower.VOID_SENSE.value, - DISCosmicPower.DRIFT_STEP.value, - DISCosmicPower.PSYCHIC_SCREAM.value, - ], - ) - sheet = DISCharacterSheet.model_validate(data) - assert sheet.is_fully_corrupted is True +_PEBBLE = BaseGameSystem( + system_id=_LEGACY_ID, + system_name="Pebble and Thread", + publisher=None, + core_mechanic_formula="2d6 + knots vs 8", + attributes=[AttributeSpec(name="Knots", abbreviation="KNOT", min_value=0, max_value=4, default_value=1)], + resources=[], + schema_class="tests._PebbleSheet", +) -# ============================================================================= -# 5. Skills -# ============================================================================= +@pytest.fixture +def legacy(): + reg = GameSystemRegistry() + reg.register(_PEBBLE, _PebbleSheet) + yield reg + reg._systems.pop(_LEGACY_ID, None) -class TestDISSkills: - def test_valid_skills_accepted(self): - data = valid_dis_sheet(skills=[DISSkill.SHARP.value, DISSkill.SNEAKY.value]) - sheet = DISCharacterSheet.model_validate(data) - assert len(sheet.skills) == 2 +@pytest.fixture +def invented(): + reg = SystemRegistry() + reg.register(LANTERN_DRIFT) + yield LANTERN_DRIFT.system_id + reg.unregister(LANTERN_DRIFT.system_id) + clear_model_cache(LANTERN_DRIFT.system_id) - def test_duplicate_skills_rejected(self): - with pytest.raises(ValidationError, match="duplicates"): - DISCharacterSheet.model_validate(valid_dis_sheet(skills=[DISSkill.SHARP.value, DISSkill.SHARP.value])) - def test_origin_accepted(self): - data = valid_dis_sheet(origin=DISOrigin.CONTRACTOR.value) - sheet = DISCharacterSheet.model_validate(data) - assert sheet.origin == DISOrigin.CONTRACTOR +def _pebble(**overrides) -> dict: + return {"entity_id": str(uuid4()), "character_name": "Moss", "knots": 2, **overrides} # ============================================================================= -# 6. Weapons +# Legacy GameSystemRegistry # ============================================================================= -class TestDISWeapon: - def test_melee_weapon_no_range_required(self): - weapon = DISWeapon( - name="Combat Knife", - damage_formula=DISDamageDie.D4, - is_ranged=False, - ) - assert weapon.damage_formula == DISDamageDie.D4 - assert weapon.range_m is None - - def test_ranged_weapon_requires_range(self): - with pytest.raises(ValidationError, match="Ranged weapons must specify range_m"): - DISWeapon( - name="Scrapper's Pistol", - damage_formula=DISDamageDie.D6, - is_ranged=True, - # missing range_m - ) - - def test_valid_ranged_weapon(self): - weapon = DISWeapon( - name="Scrapper's Pistol", - damage_formula=DISDamageDie.D6, - is_ranged=True, - range_m=30, - ) - assert weapon.range_m == 30 - - def test_assault_rifle_is_d10(self): - weapon = DISWeapon( - name="Assault Rifle", - damage_formula=DISDamageDie.D10, - is_ranged=True, - range_m=100, - properties=[DISWeaponProperty.SLOW], - ) - assert weapon.damage_formula == DISDamageDie.D10 - - def test_energy_weapon(self): - weapon = DISWeapon( - name="Plasma Cutter", - damage_formula=DISDamageDie.D6, - is_ranged=True, - range_m=15, - properties=[DISWeaponProperty.ENERGY], - ) - assert DISWeaponProperty.ENERGY in weapon.properties +class TestGameSystemRegistry: + def test_no_game_is_registered_by_the_platform(self): + assert "death_in_space" not in GameSystemRegistry().list_systems() + def test_registered_system_is_listed_and_found(self, legacy): + assert _LEGACY_ID in legacy.list_systems() + assert legacy.get_definition(_LEGACY_ID).system_name == "Pebble and Thread" + assert legacy.get_sheet_class(_LEGACY_ID) is _PebbleSheet -# ============================================================================= -# 7. Armor -# ============================================================================= + def test_parse_sheet_valid(self, legacy): + assert isinstance(legacy.parse_sheet(_LEGACY_ID, _pebble()), _PebbleSheet) + def test_parse_sheet_out_of_range_raises(self, legacy): + with pytest.raises(ValidationError): + legacy.parse_sheet(_LEGACY_ID, _pebble(knots=9)) -class TestDISArmor: - def test_light_armor(self): - armor = DISArmor( - name="Padded Jacket", - protection=DISArmorProtection.LIGHT, - ) - assert armor.protection == DISArmorProtection.LIGHT - assert armor.protection == 1 + def test_parse_sheet_unknown_system_raises(self): + with pytest.raises(ValueError, match="Unknown game system: 'nonexistent'"): + GameSystemRegistry().parse_sheet("nonexistent", {}) - def test_heavy_armor(self): - armor = DISArmor( - name="Full Environmental Suit", - protection=DISArmorProtection.HEAVY, - blocks_cosmic_rot=True, - ) - assert armor.protection == 3 - assert armor.blocks_cosmic_rot is True + def test_validate_attribute_value(self, legacy): + defn = legacy.get_definition(_LEGACY_ID) + assert defn.validate_attribute_value("KNOT", 4) is True + assert defn.validate_attribute_value("KNOT", 0) is True + assert defn.validate_attribute_value("KNOT", 5) is False + assert defn.validate_attribute_value("KNOT", -1) is False - def test_protection_enum_values(self): - assert DISArmorProtection.NONE == 0 - assert DISArmorProtection.LIGHT == 1 - assert DISArmorProtection.MEDIUM == 2 - assert DISArmorProtection.HEAVY == 3 + def test_attribute_lookup_case_insensitive(self, legacy): + defn = legacy.get_definition(_LEGACY_ID) + assert defn.attribute_by_abbr("knot") is not None + assert defn.attribute_by_abbr("KNOT") is not None + assert defn.attribute_by_abbr("Knot") is not None # ============================================================================= -# 8. Ships +# rpg_validate_sheet: spec-driven systems first, then legacy classes # ============================================================================= -class TestDISShipSheet: - def test_valid_ship(self): - ship = DISShipSheet( - name="The Leaking Coffin", - ship_class=DISShipClass.FREIGHTER, - hull_points=8, - max_hull_points=12, - crew_capacity=4, - drive_condition=6, - ) - assert ship.hull_points == 8 - assert ship.max_hull_points == 12 - - def test_hull_points_cannot_exceed_max(self): - with pytest.raises(ValidationError, match="cannot exceed max_hull_points"): - DISShipSheet( - name="Ghost Ship", - ship_class=DISShipClass.SHUTTLE, - hull_points=10, - max_hull_points=6, - crew_capacity=2, - ) - - def test_drive_condition_range(self): - with pytest.raises(ValidationError, match="less than or equal to 6"): - DISShipSheet( - name="Fast Ship", - ship_class=DISShipClass.WARSHIP, - hull_points=20, - max_hull_points=20, - crew_capacity=12, - drive_condition=7, # out of range 0-6 - ) - +class TestRPGValidateSheet: + def test_spec_system_valid_sheet(self, invented): + result = rpg_validate_sheet(invented, {"character_name": "Wren", "grit": 1, "hp": 7}) + assert result == {"valid": True, "errors": []} -# ============================================================================= -# 9. rpg_validate_sheet tool -# ============================================================================= + def test_spec_system_out_of_range_rejected(self, invented): + result = rpg_validate_sheet(invented, {"character_name": "Wren", "grit": 9}) + assert result["valid"] is False and result["errors"] + def test_every_builtin_validates_a_sheet_from_data(self): + # Before, only a game with a hand-written class could be validated at all. + assert rpg_validate_sheet("fate_core", {"character_name": "Ash"})["valid"] is True -class TestRPGValidateSheet: - def test_valid_sheet_returns_success(self): - result = rpg_validate_sheet(SYSTEM_ID, valid_dis_sheet()) - assert result["valid"] is True - assert result["errors"] == [] - - def test_invalid_sheet_returns_errors(self): - bad_data = valid_dis_sheet(body=99, max_hp=105, hp=105) - result = rpg_validate_sheet(SYSTEM_ID, bad_data) - assert result["valid"] is False - assert len(result["errors"]) > 0 + def test_legacy_class_still_validates(self, legacy): + assert rpg_validate_sheet(_LEGACY_ID, _pebble())["valid"] is True + bad = rpg_validate_sheet(_LEGACY_ID, _pebble(knots=9)) + assert bad["valid"] is False and any("knots" in e for e in bad["errors"]) def test_unknown_system_returns_error(self): result = rpg_validate_sheet("nonexistent_system", {}) assert result["valid"] is False assert "Unknown game system" in result["errors"][0] - - def test_wrong_max_hp_returns_error(self): - bad_data = valid_dis_sheet(body=0, max_hp=99, hp=6) - result = rpg_validate_sheet(SYSTEM_ID, bad_data) - assert result["valid"] is False - assert any("max_hp" in e for e in result["errors"]) - - -# ============================================================================= -# 10. DEATH_IN_SPACE system definition integrity -# ============================================================================= - - -class TestDEATHINSPACEDefinition: - def test_system_id_is_snake_case(self): - import re - - assert re.match(r"^[a-z][a-z0-9_]*$", DEATH_IN_SPACE.system_id) - - def test_has_exactly_four_attributes(self): - assert len(DEATH_IN_SPACE.attributes) == 4 - - def test_attribute_abbreviations_are_uppercase(self): - abbrs = [a.abbreviation for a in DEATH_IN_SPACE.attributes] - assert abbrs == ["BODY", "DEX", "SAVVY", "TECH"] - - def test_has_hp_resource(self): - res_names = [r.abbreviation for r in DEATH_IN_SPACE.resources] - assert "HP" in res_names - - def test_has_cosmic_rot_resource(self): - res_names = [r.abbreviation for r in DEATH_IN_SPACE.resources] - assert "ROT" in res_names - - def test_schema_class_path_is_importable(self): - """The schema_class string must resolve to DISCharacterSheet.""" - module_path, class_name = DEATH_IN_SPACE.schema_class.rsplit(".", 1) - import importlib - - module = importlib.import_module(module_path) - cls = getattr(module, class_name) - assert cls is DISCharacterSheet - - def test_hp_max_formula_in_resource(self): - hp_res = next(r for r in DEATH_IN_SPACE.resources if r.abbreviation == "HP") - assert "BODY" in hp_res.max_formula - assert "6" in hp_res.max_formula - - -# ============================================================================= -# 11. Integration-style — full character creation flow -# ============================================================================= - - -class TestDISFullCharacterCreation: - """End-to-end character creation mirroring the Death in Space rulebook.""" - - def test_create_zyx_the_wanderer(self): - """Contractor origin, survivalist character.""" - sheet = DISCharacterSheet.model_validate( - { - "entity_id": str(uuid4()), - "character_name": "Zyx the Wanderer", - "body": 1, - "dex": 2, - "savvy": -1, - "tech": 0, - "hp": 7, - "max_hp": 7, # max(1, 1+6) - "cosmic_rot": 0, - "origin": DISOrigin.CONTRACTOR.value, - "drive": "Find my lost crew", - "skills": [DISSkill.TOUGH.value, DISSkill.MUSCLE.value], - "credits": 50, - } - ) - assert sheet.character_name == "Zyx the Wanderer" - assert sheet.stat_total == 2 - assert not sheet.is_incapacitated - - def test_create_void_cultist_with_cosmic_powers(self): - """High rot character — unlocked 2 cosmic powers.""" - sheet = DISCharacterSheet.model_validate( - { - "entity_id": str(uuid4()), - "character_name": "Brother Null", - "body": -2, - "dex": 0, - "savvy": 3, - "tech": -1, - "hp": 1, # max(1, -2+6)=4 → current hp=1 - "max_hp": 4, - "cosmic_rot": 7, - "cosmic_powers": [ - DISCosmicPower.VOID_SENSE.value, - DISCosmicPower.DRIFT_STEP.value, - ], - "cosmic_marks": ["Empty eye sockets replaced with void crystals"], - "origin": DISOrigin.CULTIST.value, - "drive": "Bring the Final Void to all", - "skills": [DISSkill.SNEAKY.value], - "credits": 0, - } - ) - assert sheet.cosmic_rot == 7 - assert len(sheet.cosmic_powers) == 2 - assert sheet.power_slots_available() == 0 # 7//3=2, 2 used - - def test_minimum_viable_character(self): - """Bare-minimum valid character (all zeros).""" - sheet = DISCharacterSheet.model_validate( - { - "entity_id": str(uuid4()), - "character_name": "Unknown Drifter", - "body": 0, - "dex": 0, - "savvy": 0, - "tech": 0, - "hp": 6, - "max_hp": 6, # max(1, 0+6) - "cosmic_rot": 0, - } - ) - assert sheet.max_hp == 6 - assert sheet.stat_total == 0 diff --git a/packages/data-layer/tests/test_tools/test_rpg_ontology_converters.py b/packages/data-layer/tests/test_tools/test_rpg_ontology_converters.py index 6cad0d3d..26966fc0 100644 --- a/packages/data-layer/tests/test_tools/test_rpg_ontology_converters.py +++ b/packages/data-layer/tests/test_tools/test_rpg_ontology_converters.py @@ -73,7 +73,6 @@ class TestBuiltinToGameSystemSpec: "Powered by the Apocalypse", "Narrative Pure", "Narrative Weighted", - "Death in Space", "Mistlands Core", ], ) @@ -156,10 +155,11 @@ def test_character_entity_has_name_field(self, all_specs): class TestSystemRegistryPopulation: - def test_all_eight_systems_registered(self, registry): - # 8 → 7 after removing the VtM 5e builtin (2026-07-21); will grow - # again when a real VtM source is ingested and registered. - assert len(registry.list_systems()) == 7 + def test_all_builtin_systems_registered(self, registry): + # 8 → 7 after removing the VtM 5e builtin (2026-07-21), and 7 → 6 after + # removing Death in Space (2026-11-06). A named game comes from its ingested + # book, not from the platform. + assert len(registry.list_systems()) == 6 def test_get_by_system_id(self, registry): """Each system is retrievable by its canonical system_id.""" @@ -167,34 +167,33 @@ def test_get_by_system_id(self, registry): spec = registry.get("dandd_5e") assert spec is not None assert spec.system_name == "D&D 5e" - # Death in Space - spec = registry.get("death_in_space") + spec = registry.get("fate_core") assert spec is not None - assert "Death in Space" in spec.system_name + assert spec.system_name == "Fate Core" + assert registry.get("death_in_space") is None - def test_registry_list_systems_returns_all_eight(self, registry): - """list_systems() returns the 7 currently registered system IDs.""" + def test_registry_list_systems_returns_every_builtin(self, registry): + """list_systems() returns the 6 currently registered system IDs.""" systems = registry.list_systems() - assert len(systems) == 7 + assert len(systems) == 6 expected = { "dandd_5e", "fate_core", "powered_by_the_apocalypse", "narrative_pure", "narrative_weighted", - "death_in_space", "mistlands_core", } assert set(systems) == expected def test_unregister_then_reregister(self, registry): """A system can be unregistered and re-registered without error.""" - spec = registry.get("death_in_space") + spec = registry.get("fate_core") assert spec is not None - registry.unregister("death_in_space") - assert registry.get("death_in_space") is None + registry.unregister("fate_core") + assert registry.get("fate_core") is None registry.register(spec) - assert registry.get("death_in_space") is not None + assert registry.get("fate_core") is not None def test_seed_is_idempotent(self, registry): """seed_system_registry() can be called multiple times without error.""" @@ -213,9 +212,9 @@ class TestFactoryModelGeneration: def test_make_entity_model_produces_model_per_system(self, registry): """make_entity_model generates a distinct model class for each system.""" dnd = make_entity_model(registry.get("dandd_5e"), "character") - dis = make_entity_model(registry.get("death_in_space"), "character") + fate = make_entity_model(registry.get("fate_core"), "character") - assert dnd is not dis + assert dnd is not fate def test_dnd5e_model_has_expected_fields(self, registry): """D&D 5e model exposes all expected character sheet fields.""" @@ -256,7 +255,6 @@ def test_field_count_reasonableness(self, registry): "powered_by_the_apocalypse", "narrative_pure", "narrative_weighted", - "death_in_space", "mistlands_core", ], ) @@ -282,7 +280,7 @@ def test_slugify(self): # claim about an existing system. assert _slugify("Vampire: The Masquerade 5th Edition") == "vampire_the_masquerade_5th_edition" assert _slugify("Powered by the Apocalypse") == "powered_by_the_apocalypse" - assert _slugify("Death in Space") == "death_in_space" + assert _slugify("Death in Space") == "death_in_space" # slugify only; not a builtin assert _slugify("Fate Core") == "fate_core" assert _slugify("7th Sea") == "sys_7th_sea" assert _slugify("A & B Game") == "a_and_b_game" @@ -293,3 +291,31 @@ def test_to_ascii(self): assert _to_ascii("Mörder") == "Morder" # umlaut stripped assert _to_ascii("Strength") == "Strength" assert _to_ascii("Nervous") == "Nervous" + + +# --------------------------------------------------------------------------- +# Core mechanic detection +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("system_name", "expected"), + [ + ("D&D 5e", "d20_over"), + ("Mistlands Core", "d20_over"), + # Stored as meet_or_beat, but its formula rolls under a target. + ("Narrative Weighted", "d20_under"), + ("Fate Core", "dice_pool"), + ("Powered by the Apocalypse", "dice_pool"), + ("Narrative Pure", "narrative"), + ], +) +def test_each_builtin_gets_its_mechanic_type(system_name, expected): + assert get_spec_for_system(system_name).core_mechanic.mechanic_type.value == expected + + +def test_a_d20_formula_that_rolls_under_is_detected_whatever_its_success_type(): + from monitor_data.schemas.rpg_ontology.converters import _detect_core_mechanic_type + + core = {"type": "d20", "formula": "1D20 Roll Under attribute", "success_type": "meet_or_beat"} + assert _detect_core_mechanic_type(core).value == "d20_under" diff --git a/packages/data-layer/tests/test_tools/test_rpg_tools.py b/packages/data-layer/tests/test_tools/test_rpg_tools.py index 3d401c4a..8bb9d826 100644 --- a/packages/data-layer/tests/test_tools/test_rpg_tools.py +++ b/packages/data-layer/tests/test_tools/test_rpg_tools.py @@ -12,6 +12,7 @@ from uuid import uuid4 import pytest +from _lantern_drift_spec import LANTERN_DRIFT from monitor_data.schemas.rpg_ontology import ( EquipmentItemCreate, @@ -19,7 +20,6 @@ TypedCharacterSheetCreate, TypedCharacterSheetUpdate, ) -from monitor_data.schemas.rpg_ontology.systems.death_in_space import SYSTEM_ID from monitor_data.tools.rpg_tools import ( rpg_character_create, rpg_character_delete, @@ -49,20 +49,33 @@ _ITEM_ID = uuid4() _NOW = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) -# A minimal valid DIS character sheet dict -_VALID_DIS = { - "entity_id": str(_ENTITY_ID), +SYSTEM_ID = LANTERN_DRIFT.system_id + +# A minimal valid character sheet for the invented test system +_VALID_SHEET = { "character_name": "Alpha Tester", - "body": 1, - "dex": 0, - "savvy": 0, - "tech": 0, + "grit": 1, + "nerve": 0, + "wits": 0, + "craft": 0, "hp": 7, - "max_hp": 7, - "cosmic_rot": 0, + "strain": 0, } +@pytest.fixture(autouse=True) +def _invented_system_registered(): + """Sheets validate against the system's spec, so the invented one must be registered.""" + from monitor_data.schemas.rpg_ontology import SystemRegistry + from monitor_data.schemas.rpg_ontology.factory import clear_model_cache + + registry = SystemRegistry() + registry.register(LANTERN_DRIFT) + yield + registry.unregister(SYSTEM_ID) + clear_model_cache(SYSTEM_ID) + + # --------------------------------------------------------------------------- # Helpers — build asyncpg-style mock row dicts # --------------------------------------------------------------------------- @@ -75,7 +88,7 @@ def _sheet_row(**overrides) -> dict: "entity_id": str(_ENTITY_ID), "system_id": SYSTEM_ID, "character_name": "Alpha Tester", - "sheet_data": _VALID_DIS, + "sheet_data": _VALID_SHEET, "created_at": _NOW, "updated_at": None, } @@ -91,7 +104,7 @@ def _item_row(**overrides) -> dict: "item_name": "Laser Pistol", "item_type": "weapon", "item_data": {"damage_formula": "d6"}, - "source_ref": "DIS Core p.45", + "source_ref": "Lantern Drift p.12", "created_at": _NOW, } row.update(overrides) @@ -125,14 +138,19 @@ def _make_client(fetchrow=None, fetch=None, execute=None) -> MagicMock: class TestRpgSystemRegister: @pytest.mark.asyncio async def test_returns_dict_of_row(self): - from monitor_data.schemas.rpg_ontology.systems.death_in_space import DEATH_IN_SPACE + from monitor_data.schemas.rpg_ontology.base import AttributeSpec, BaseGameSystem, ResourceSpec - expected = { - "system_id": DEATH_IN_SPACE.system_id, - "system_name": DEATH_IN_SPACE.system_name, - } + system = BaseGameSystem( + system_id=SYSTEM_ID, + system_name="Lantern Drift", + core_mechanic_formula="d20 under attribute", + attributes=[AttributeSpec(name="Grit", abbreviation="GRIT", min_value=-3, max_value=3, default_value=0)], + resources=[ResourceSpec(name="Hit Points", abbreviation="HP", max_formula="max(1, GRIT + 6)")], + schema_class="tests.LanternDriftSheet", + ) + expected = {"system_id": system.system_id, "system_name": system.system_name} client = _make_client(fetchrow=expected) - result = await rpg_system_register(client, DEATH_IN_SPACE) + result = await rpg_system_register(client, system) assert result == expected @@ -144,7 +162,7 @@ async def test_returns_dict_of_row(self): class TestRpgSystemGet: @pytest.mark.asyncio async def test_returns_dict_when_found(self): - row = {"system_id": SYSTEM_ID, "system_name": "Death in Space"} + row = {"system_id": SYSTEM_ID, "system_name": "Lantern Drift"} client = _make_client(fetchrow=row) result = await rpg_system_get(client, SYSTEM_ID) assert result == row @@ -165,7 +183,7 @@ class TestRpgSystemList: @pytest.mark.asyncio async def test_returns_list_of_dicts(self): rows = [ - {"system_id": "dis", "system_name": "Death in Space"}, + {"system_id": "lantern_drift", "system_name": "Lantern Drift"}, {"system_id": "dnd5e", "system_name": "D&D 5e"}, ] client = _make_client(fetch=rows) @@ -194,7 +212,7 @@ async def test_creates_and_returns_response(self): world_id=_WORLD_ID, entity_id=_ENTITY_ID, system_id=SYSTEM_ID, - sheet_data=_VALID_DIS, + sheet_data=_VALID_SHEET, ) result = await rpg_character_create(client, request) assert result.id == _SHEET_ID @@ -211,14 +229,13 @@ async def test_invalid_sheet_raises_validation_error(self): entity_id=_ENTITY_ID, system_id=SYSTEM_ID, sheet_data={ - "body": 99, + "grit": 99, "character_name": "X", - "dex": 0, - "savvy": 0, - "tech": 0, + "nerve": 0, + "wits": 0, + "craft": 0, "hp": 1, - "max_hp": 1, - "cosmic_rot": 0, + "strain": 0, }, ) with pytest.raises(ValidationError): @@ -275,7 +292,7 @@ async def test_returns_none_when_not_found(self): class TestRpgCharacterUpdate: @pytest.mark.asyncio async def test_updates_and_returns_response(self): - updated_row = _sheet_row(character_name="Alpha Tester", sheet_data={**_VALID_DIS, "hp": 5}) + updated_row = _sheet_row(character_name="Alpha Tester", sheet_data={**_VALID_SHEET, "hp": 5}) # First fetchrow = get current, second fetchrow = update result conn = AsyncMock() conn.fetchrow = AsyncMock(side_effect=[_sheet_row(), updated_row]) @@ -371,7 +388,7 @@ async def test_adds_and_returns_response(self): item_name="Laser Pistol", item_type=ItemType.WEAPON, item_data={"damage_formula": "d6"}, - source_ref="DIS Core p.45", + source_ref="Lantern Drift p.12", ) result = await rpg_equipment_add(client, request) assert result.id == _ITEM_ID @@ -470,13 +487,13 @@ async def test_unequip_returns_false_when_not_found(self): class TestRpgValidateSheet: - def test_valid_dis_sheet_returns_valid_true(self): - result = rpg_validate_sheet(SYSTEM_ID, _VALID_DIS) + def test_valid_sheet_returns_valid_true(self): + result = rpg_validate_sheet(SYSTEM_ID, _VALID_SHEET) assert result["valid"] is True assert result["errors"] == [] - def test_invalid_body_returns_errors(self): - bad = {**_VALID_DIS, "body": 99} + def test_invalid_attribute_returns_errors(self): + bad = {**_VALID_SHEET, "grit": 99} result = rpg_validate_sheet(SYSTEM_ID, bad) assert result["valid"] is False assert result["errors"] @@ -487,7 +504,8 @@ def test_unknown_system_returns_error(self): assert "Unknown game system" in result["errors"][0] def test_missing_required_field_returns_errors(self): - incomplete = {"character_name": "X"} + # Attributes have defaults in the spec; the name has none. + incomplete = {"grit": 0} result = rpg_validate_sheet(SYSTEM_ID, incomplete) assert result["valid"] is False @@ -500,24 +518,10 @@ def test_missing_required_field_returns_errors(self): class TestRpgSchemaInspect: """Tests for the schema introspection tool. - Because DEATH_IN_SPACE is auto-registered in SystemRegistry at import - time, most tests use the in-memory path (no real DB needed). + The invented system is registered for every test in this module, so most + tests use the in-memory path (no real DB needed). """ - @pytest.fixture(autouse=True) - def _ensure_python_spec(self): - """Ensure the Python DEATH_IN_SPACE spec is in the registry. - - Other test modules (e.g. test_rpg_ontology_converters) may replace - the registry contents with specs loaded from builtin_systems.json, - which use different attribute ranges. This fixture restores the - hand-written Python spec so assertions on mechanic_type / ranges - match expectations. - """ - from monitor_data.schemas.rpg_ontology import DEATH_IN_SPACE, SystemRegistry - - SystemRegistry().register(DEATH_IN_SPACE) - @pytest.mark.asyncio async def test_returns_dict_for_registered_system(self): client = _make_client() # DB never touched for in-memory hit @@ -575,13 +579,13 @@ async def test_field_summary_has_required_keys(self): assert key in field, f"Missing key '{key}' in field summary" @pytest.mark.asyncio - async def test_dis_body_field_has_correct_range(self): + async def test_attribute_field_has_correct_range(self): client = _make_client() result = await rpg_schema_inspect(client, SYSTEM_ID) fields = result["entity_types"]["character"]["fields"] - body = next(f for f in fields if f["name"] == "body") - assert body["min_value"] == -3 - assert body["max_value"] == 3 + grit = next(f for f in fields if f["name"] == "grit") + assert grit["min_value"] == -3 + assert grit["max_value"] == 3 @pytest.mark.asyncio async def test_max_hp_is_derived(self): @@ -615,16 +619,16 @@ async def test_returns_none_for_unknown_system_not_in_db(self): @pytest.mark.asyncio async def test_falls_back_to_postgres_when_not_in_registry(self): - from monitor_data.schemas.rpg_ontology import DEATH_IN_SPACE, SystemRegistry + from monitor_data.schemas.rpg_ontology import SystemRegistry from monitor_data.schemas.rpg_ontology.factory import clear_model_cache - # Temporarily unregister DIS, then restore after test + # Temporarily unregister the system, then restore after test reg = SystemRegistry() reg.unregister(SYSTEM_ID) clear_model_cache(SYSTEM_ID) try: # DB returns the full spec JSON - spec_dict = DEATH_IN_SPACE.model_dump(mode="json") + spec_dict = LANTERN_DRIFT.model_dump(mode="json") # Build a mock row with spec column holding the spec dict mock_spec_row = MagicMock() @@ -645,7 +649,7 @@ async def test_falls_back_to_postgres_when_not_in_registry(self): assert result["system_id"] == SYSTEM_ID finally: # Always restore the spec so other tests still work - reg.register(DEATH_IN_SPACE) + reg.register(LANTERN_DRIFT) @pytest.mark.asyncio async def test_tags_is_list(self): diff --git a/tests/e2e/test_02_system_registry_full.py b/tests/e2e/test_02_system_registry_full.py index 5f711b37..ba5f460e 100644 --- a/tests/e2e/test_02_system_registry_full.py +++ b/tests/e2e/test_02_system_registry_full.py @@ -45,7 +45,6 @@ def registry(): "powered_by_the_apocalypse", "narrative_pure", "narrative_weighted", - "death_in_space", "mistlands_core", ], ) @@ -159,28 +158,9 @@ def test_narrative_weighted_has_approach(self, registry): f"Narrative Weighted should have 4 approaches (force/finesse/insight/presence), got: {field_names}" ) - def test_death_in_space_derived_hp(self, registry): - """DIS character attributes are correctly configured. - - Note: The JSON-loaded spec has different ranges than the handwritten DIS spec. - This test validates the JSON spec behavior (min=3 for attributes). - The handwritten DISCharacterSheet has different derived fields like max_hp. - """ - from monitor_data.schemas.rpg_ontology import make_entity_model - - model_cls = make_entity_model(registry.get("death_in_space"), "character") - # JSON spec: attributes have min=3, max=18 (like D&D) - sheet = model_cls.model_validate( - { - "character_name": "Zyx", - "body": 3, - "dexterity": 3, - "insight": 3, - "bravery": 3, - "charisma": 3, - } - ) - assert sheet.body == 3, f"Expected body=3, got {sheet.body}" + def test_no_named_game_is_built_in(self, registry): + """A named game comes from its ingested book, not from the platform.""" + assert registry.get("death_in_space") is None # --------------------------------------------------------------------------- From e6fc274827b2ec6ab0141f06cc6e61acfef2a94a Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 6 Nov 2026 20:22:28 -0300 Subject: [PATCH 2/3] fix(loops): P-7 make backtrack undo what the turn wrote, not just the checkpoint Backtrack rewound the scene loop's checkpoint but left the turn's database writes in place: its turn entries came back on the next load, its memories stayed searchable, its working-state changes stuck, and its pending proposals could still be accepted into canon. A new data-layer tool removes what the scene wrote after the restore point's time: turns, resolutions, proposals, memories (and their vectors), foreshadowing plants, and payoffs, which are reopened. Working state is written in place, so it is put back from the restored checkpoint's copy; when there is none, that is reported rather than claimed. As P-7 says, a turn is undone only if it wasn't canonized: if any of its proposals was accepted, nothing changes and backtrack says why. Rejected ones never reached canon and are removed. The database is rewound before the checkpoint. A refusal changes nothing; a database failure midway may leave the turn partly undone, and backtrack says to run it again, which finishes the job. The CLI lists anything not undone. NPC profile updates the narrator makes during a turn are not undone yet. Co-Authored-By: Claude Opus 5.5 --- .../src/monitor_agents/loops/scene_loop.py | 56 +++++- .../tests/test_scene_state_carryover.py | 94 ++++++++- packages/cli/src/monitor_cli/commands/play.py | 2 + .../tools/mongodb_tools/scene_rewind.py | 162 +++++++++++++++ .../tests/test_tools/test_scene_rewind.py | 184 ++++++++++++++++++ 5 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 packages/data-layer/src/monitor_data/tools/mongodb_tools/scene_rewind.py create mode 100644 packages/data-layer/tests/test_tools/test_scene_rewind.py diff --git a/packages/agents/src/monitor_agents/loops/scene_loop.py b/packages/agents/src/monitor_agents/loops/scene_loop.py index 1dd2a9fc..6248ac8c 100644 --- a/packages/agents/src/monitor_agents/loops/scene_loop.py +++ b/packages/agents/src/monitor_agents/loops/scene_loop.py @@ -1840,6 +1840,13 @@ async def backtrack(self) -> dict[str, Any]: # Undoing Turn 1 restores the pre-turn-1 initial scene state. restored_values = {**dict(history[-1].values or {}), **base_defaults} + # The turn also wrote outside the checkpoint. Undo that first, and only + # rewind the checkpoint once it worked, so the two never disagree. + restore_point = prev_turn_state if prev_turn_state is not None else history[-1] + rewind = await _rewind_scene_writes(self.scene_id, restore_point.created_at, restored_values) + if "error" in rewind: + return rewind + restored_suggestions: list[ImageSuggestion] = [] for item in restored_values.get("image_suggestions") or []: if isinstance(item, ImageSuggestion): @@ -1853,8 +1860,55 @@ async def backtrack(self) -> dict[str, Any]: config, # type: ignore[arg-type] restored_values, ) + PersistenceService.clear_scene_runtime_cache(self.scene_id) + + return {**restored_values, "undone": rewind} + + +async def _rewind_scene_writes( + scene_id: UUID, restore_point: str | None, restored_values: dict[str, Any] +) -> dict[str, Any]: + """Remove what the scene wrote after the restore point; P-7. - return restored_values + Returns what was undone, or ``{"error": ...}``. A refusal changes nothing. A database + failure can leave the rewind part done, and running it again finishes it, since it + deletes by the same boundary. + """ + import anyio + from monitor_data.schemas.vectors import VectorDeleteRequest + from monitor_data.tools.mongodb_tools.scene_rewind import SceneRewindRefused, mongodb_rewind_scene + from monitor_data.tools.qdrant_tools import qdrant_delete + + if not restore_point: + return {"error": "Cannot backtrack: the checkpoint has no time, so its later writes can't be found."} + since = datetime.fromisoformat(restore_point) + earlier: dict[str, dict[str, Any]] = {} + snapshot = restored_values.get("working_state") + if isinstance(snapshot, dict) and snapshot.get("state_id"): + earlier[str(snapshot["state_id"])] = snapshot + try: + result = await anyio.to_thread.run_sync( + functools.partial(mongodb_rewind_scene, scene_id, since, working_states=earlier) + ) + except SceneRewindRefused as exc: + return {"error": str(exc)} + except Exception as exc: # noqa: BLE001 # reason: reported to the player; the checkpoint stays put and a retry finishes the job + logger.warning("backtrack: rewinding scene %s failed: %s", scene_id, exc) + return { + "error": ( + "The turn may be partly undone: the database stopped responding. " + "Run /backtrack again to finish; it picks up where this stopped." + ) + } + for memory_id in result.memory_ids_removed: + try: + await anyio.to_thread.run_sync( + qdrant_delete, VectorDeleteRequest(collection="memories", id=UUID(memory_id)) + ) + except Exception as exc: # noqa: BLE001 # reason: the memory is gone from MongoDB; a stale vector is reported, not fatal + logger.warning("backtrack: memory vector %s not deleted: %s", memory_id, exc) + result.not_undone.append(f"memory {memory_id}: its search vector is still stored") + return result.model_dump() _NON_COMBATANT_ENTITY_TYPES = frozenset( diff --git a/packages/agents/tests/test_scene_state_carryover.py b/packages/agents/tests/test_scene_state_carryover.py index 4aa8eef8..6fff6c1e 100644 --- a/packages/agents/tests/test_scene_state_carryover.py +++ b/packages/agents/tests/test_scene_state_carryover.py @@ -99,8 +99,30 @@ async def _fake_read(fn: Any, *_args: Any, **_kwargs: Any) -> Any: return [] +class _Rewinds: + """Stands in for the data-layer rewind, which has its own tests, and records each call.""" + + def __init__(self) -> None: + self.calls: list[tuple[Any, Any, dict[str, Any]]] = [] + self.refuse: str | None = None + self.memory_ids: list[str] = [] + + def __call__(self, scene_id: Any, since: Any, *, working_states: Any = None) -> Any: + from monitor_data.tools.mongodb_tools.scene_rewind import SceneRewindRefused, SceneRewindResult + + if self.refuse: + raise SceneRewindRefused(self.refuse) + self.calls.append((scene_id, since, working_states or {})) + return SceneRewindResult(memory_ids_removed=list(self.memory_ids)) + + @pytest.fixture -def world(): +def rewinds(): + return _Rewinds() + + +@pytest.fixture +def world(rewinds): saver = InMemorySaver() state = _World() @@ -117,7 +139,10 @@ def conn(_uri: str): patch.object(sl.PersistenceService, "persist_working_state", AsyncMock(return_value={})), patch("monitor_agents.foreshadowing.agent.ForeshadowingAgent.propose", AsyncMock(return_value={})), patch.object(sl, "seed_npc_profiles_for_accepted_proposals", AsyncMock(return_value=0)), + patch("monitor_data.tools.mongodb_tools.scene_rewind.mongodb_rewind_scene", rewinds), + patch("monitor_data.tools.qdrant_tools.qdrant_delete") as qdrant_delete, ): + state.qdrant_delete = qdrant_delete # type: ignore[attr-defined] yield state @@ -457,6 +482,73 @@ async def test_backtrack_reverts_full_turn_and_steps_back_repeatedly(world: _Wor assert "error" in await loop.backtrack() +async def _checkpoints(loop: sl.SceneLoop) -> list[Any]: + with sl.MongoDBSaver.from_conn_string("") as saver: + compiled = loop._graph.compile(checkpointer=saver) + config = {"configurable": {"thread_id": str(loop.scene_id)}} + return [snap async for snap in compiled.aget_state_history(config)] # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_backtrack_undoes_the_turns_writes_since_the_previous_turn(world: _World, rewinds: _Rewinds) -> None: + from datetime import datetime + + loop = _loop() + await loop.run("turn one") + turn_one_end = next(s for s in await _checkpoints(loop) if not s.next) + await loop.run("turn two") + rewinds.memory_ids = [str(uuid4())] + + restored = await loop.backtrack() + + ((scene_id, since, _),) = rewinds.calls + assert scene_id == loop.scene_id + assert since == datetime.fromisoformat(turn_one_end.created_at) + assert restored["turns_count"] == 1 and restored["undone"]["memory_ids_removed"] == rewinds.memory_ids + (request,) = [c.args[0] for c in world.qdrant_delete.call_args_list] # type: ignore[attr-defined] + assert request.collection == "memories" and str(request.id) == rewinds.memory_ids[0] + + +@pytest.mark.asyncio +async def test_a_refused_rewind_leaves_the_turn_in_place(world: _World, rewinds: _Rewinds) -> None: + loop = _loop() + await loop.run("turn one") + await loop.run("turn two") + rewinds.refuse = "1 change(s) from this turn were already accepted into canon" + + assert (await loop.backtrack()) == {"error": "1 change(s) from this turn were already accepted into canon"} + rewinds.refuse = None + # The checkpoint didn't move, so the next backtrack still undoes turn two. + assert (await loop.backtrack())["turns_count"] == 1 + + +@pytest.mark.asyncio +async def test_a_database_failure_says_to_run_backtrack_again(world: _World, rewinds: _Rewinds) -> None: + loop = _loop() + await loop.run("turn one") + await loop.run("turn two") + with patch("monitor_data.tools.mongodb_tools.scene_rewind.mongodb_rewind_scene", side_effect=OSError("down")): + failed = await loop.backtrack() + assert "partly undone" in failed["error"] and "/backtrack again" in failed["error"] + assert (await loop.backtrack())["turns_count"] == 1 + + +@pytest.mark.asyncio +async def test_backtrack_passes_the_earlier_working_state_to_restore(world: _World, rewinds: _Rewinds) -> None: + earlier = {"state_id": "ws-1", "current_stats": {"xp": 5}, "resources": {"HP": 9}} + sl.PersistenceService.persist_working_state.side_effect = [ # type: ignore[attr-defined] + {"working_state": earlier}, + {"working_state": {**earlier, "resources": {"HP": 3}}}, + ] + world.resolutions = [{"resolution_type": "dice"}, {"resolution_type": "dice"}] + loop = _loop() + await loop.run("turn one") + await loop.run("turn two") + await loop.backtrack() + ((_, _, states),) = rewinds.calls + assert states == {"ws-1": earlier} + + @pytest.mark.asyncio async def test_multi_turn_combat_carries_round_and_hp_and_persists_deltas(world: _World) -> None: from monitor_agents.loops import combat_loop as cl diff --git a/packages/cli/src/monitor_cli/commands/play.py b/packages/cli/src/monitor_cli/commands/play.py index 19fd9e49..495436c2 100644 --- a/packages/cli/src/monitor_cli/commands/play.py +++ b/packages/cli/src/monitor_cli/commands/play.py @@ -238,6 +238,8 @@ async def _run_play( else: turn_number = max(0, turn_number - 1) console.print("[green]State restored. You can re-enter your action.[/green]") + for item in (backtrack_result.get("undone") or {}).get("not_undone") or []: + console.print(f"[yellow]Not undone:[/yellow] {item}") continue if user_input.lower().startswith("/flashback "): diff --git a/packages/data-layer/src/monitor_data/tools/mongodb_tools/scene_rewind.py b/packages/data-layer/src/monitor_data/tools/mongodb_tools/scene_rewind.py new file mode 100644 index 00000000..8add2255 --- /dev/null +++ b/packages/data-layer/src/monitor_data/tools/mongodb_tools/scene_rewind.py @@ -0,0 +1,162 @@ +"""Undo what a scene wrote after a point in time, for backtracking a turn (P-7). + +Turns write outside the scene loop's checkpoints: the scene's turn entries, a resolution, +proposals, memories, foreshadowing, and the actor's working state. Rewinding the +checkpoint alone left all of that in place, so an undone turn came back on the next +load and its proposals could still be accepted into canon. + +Records a turn inserts are removed. The working state is written in place, so it is put +back from a copy the caller supplies. Nothing here touches Neo4j: a proposal that was +already accepted makes the whole rewind refuse, since only CanonKeeper may change canon. +Pending and rejected ones never reached canon, so they are removed. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any +from uuid import UUID + +import structlog +from pydantic import BaseModel, Field + +from monitor_data.db.mongodb import get_mongodb_client +from monitor_data.schemas.base import ProposalStatus + +log = structlog.get_logger() + + +class SceneRewindRefused(Exception): + """A record written since the rewind point can't be undone here.""" + + +class SceneRewindResult(BaseModel): + """What a rewind removed or restored, so the caller can report it truthfully.""" + + turns_removed: int = 0 + resolutions_removed: int = 0 + proposals_removed: int = 0 + memory_ids_removed: list[str] = Field(default_factory=list) + foreshadowing_removed: int = 0 + foreshadowing_reopened: int = 0 + modifications_removed: int = 0 + working_states_removed: int = 0 + working_states_restored: int = 0 + not_undone: list[str] = Field(default_factory=list, description="What couldn't be put back, in words") + + +def _aware(value: Any) -> datetime | None: + """Mongo returns naive UTC datetimes, and modifications hold ISO strings.""" + if isinstance(value, str): + try: + value = datetime.fromisoformat(value) + except ValueError: + return None + if not isinstance(value, datetime): + return None + return value if value.tzinfo else value.replace(tzinfo=UTC) + + +def mongodb_rewind_scene( + scene_id: UUID, + since: datetime, + *, + working_states: dict[str, dict[str, Any]] | None = None, +) -> SceneRewindResult: + """Remove what scene ``scene_id`` wrote after ``since``. + + ``working_states`` maps a working state's ``state_id`` to its earlier + ``current_stats`` and ``resources``. A working state changed after ``since`` + with no earlier copy is left alone and named in ``not_undone``. + + Authority: SceneLoop (backtrack) + Use Case: P-7 + + Raises: + SceneRewindRefused: If a proposal written since then was accepted into canon. + """ + since = _aware(since) or since + scene = str(scene_id) + db = get_mongodb_client() + after = {"scene_id": scene, "created_at": {"$gt": since}} + result = SceneRewindResult() + + proposals = db.get_collection("proposed_changes") + written = list(proposals.find(after, {"proposal_id": 1, "status": 1})) + accepted = [doc["proposal_id"] for doc in written if doc.get("status") == ProposalStatus.ACCEPTED.value] + if accepted: + raise SceneRewindRefused( + f"{len(accepted)} change(s) from this turn were already accepted into canon, so the turn can't be undone." + ) + + proposal_ids = [doc["proposal_id"] for doc in written] + if proposal_ids: + result.proposals_removed = proposals.delete_many({"proposal_id": {"$in": proposal_ids}}).deleted_count + + scenes = db.get_collection("scenes") + doc = scenes.find_one({"scene_id": scene}, {"turns": 1}) or {} + later = [turn for turn in doc.get("turns") or [] if (_aware(turn.get("timestamp")) or since) > since] + if later or proposal_ids: + scenes.update_one( + {"scene_id": scene}, + { + "$pull": { + "turns": {"turn_id": {"$in": [turn["turn_id"] for turn in later]}}, + "proposed_changes": {"$in": proposal_ids}, + }, + "$set": {"updated_at": datetime.now(UTC)}, + }, + ) + result.turns_removed = len(later) + + result.resolutions_removed = db.get_collection("resolutions").delete_many(after).deleted_count + + memories = db.get_collection("character_memories") + result.memory_ids_removed = [doc["memory_id"] for doc in memories.find(after, {"memory_id": 1})] + if result.memory_ids_removed: + memories.delete_many({"memory_id": {"$in": result.memory_ids_removed}}) + + foreshadowing = db.get_collection("scene_foreshadowing") + result.foreshadowing_removed = foreshadowing.delete_many(after).deleted_count + result.foreshadowing_reopened = foreshadowing.update_many( + {"scene_id": scene, "paid_at": {"$gt": since}}, + {"$set": {"status": "open", "paid_at": None, "paid_at_turn": None}}, + ).modified_count + + _rewind_working_states(db.get_collection("character_working_state"), scene, since, working_states or {}, result) + log.info("scene_rewound", scene_id=scene, since=since.isoformat(), **result.model_dump(exclude={"not_undone"})) + return result + + +def _rewind_working_states( + states: Any, + scene: str, + since: datetime, + earlier: dict[str, dict[str, Any]], + result: SceneRewindResult, +) -> None: + for doc in states.find({"scene_id": scene}): + state_id = doc["state_id"] + created = _aware(doc.get("created_at")) + if created is not None and created > since: + states.delete_one({"state_id": state_id}) + result.working_states_removed += 1 + continue + mods = doc.get("modifications") or [] + kept = [mod for mod in mods if (_aware(mod.get("timestamp")) or since) <= since] + updated = doc.get("updated_at") + # A rewind's own write isn't a change made by a turn. + changed = (_aware(updated) or since) > since and updated != doc.get("rewound_at") + if not changed and len(kept) == len(mods): + continue + now = datetime.now(UTC) + update: dict[str, Any] = {"modifications": kept, "updated_at": now, "rewound_at": now} + if state_id in earlier: + snapshot = earlier[state_id] + update["current_stats"] = snapshot.get("current_stats") or {} + update["resources"] = snapshot.get("resources") or {} + result.working_states_restored += 1 + elif changed: + result.not_undone.append(f"working state {state_id}: no earlier copy, so its stats were kept") + states.update_one({"state_id": state_id}, {"$set": update}) + result.modifications_removed += len(mods) - len(kept) diff --git a/packages/data-layer/tests/test_tools/test_scene_rewind.py b/packages/data-layer/tests/test_tools/test_scene_rewind.py new file mode 100644 index 00000000..37046d54 --- /dev/null +++ b/packages/data-layer/tests/test_tools/test_scene_rewind.py @@ -0,0 +1,184 @@ +"""Backtracking a turn removes what the scene wrote after the restore point (P-7).""" + +from __future__ import annotations + +import copy +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import Any +from unittest.mock import patch +from uuid import uuid4 + +import pytest + +from monitor_data.schemas.base import ProposalStatus +from monitor_data.tools.mongodb_tools import scene_rewind +from monitor_data.tools.mongodb_tools.scene_rewind import SceneRewindRefused, mongodb_rewind_scene + +T = datetime(2026, 11, 6, 12, 0, tzinfo=UTC) +BEFORE, AFTER = T - timedelta(minutes=5), T + timedelta(minutes=5) + + +def _matches(doc: dict[str, Any], query: dict[str, Any]) -> bool: + for key, want in query.items(): + have = doc.get(key) + if isinstance(want, dict): + if "$gt" in want and not (have is not None and scene_rewind._aware(have) > want["$gt"]): + return False + if "$in" in want and have not in want["$in"]: + return False + elif have != want: + return False + return True + + +class _Collection: + """Just the MongoDB operations the rewind uses, with Mongo's naive-UTC datetimes.""" + + def __init__(self) -> None: + self.docs: list[dict[str, Any]] = [] + + def insert(self, **doc: Any) -> None: + self.docs.append({k: v.replace(tzinfo=None) if isinstance(v, datetime) else v for k, v in doc.items()}) + + def find(self, query: dict[str, Any], _projection: Any = None) -> list[dict[str, Any]]: + return [copy.deepcopy(d) for d in self.docs if _matches(d, query)] + + def find_one(self, query: dict[str, Any], _projection: Any = None) -> dict[str, Any] | None: + return next(iter(self.find(query)), None) + + def delete_many(self, query: dict[str, Any]) -> Any: + keep = [d for d in self.docs if not _matches(d, query)] + removed, self.docs = len(self.docs) - len(keep), keep + return SimpleNamespace(deleted_count=removed) + + def delete_one(self, query: dict[str, Any]) -> Any: + return self.delete_many(query) + + def update_many(self, query: dict[str, Any], update: dict[str, Any]) -> Any: + hits = [d for d in self.docs if _matches(d, query)] + for doc in hits: + doc.update(update.get("$set", {})) + for field, cond in update.get("$pull", {}).items(): + if "$in" in cond: + doc[field] = [v for v in doc.get(field, []) if v not in cond["$in"]] + else: + doc[field] = [v for v in doc.get(field, []) if not _matches(v, cond)] + return SimpleNamespace(modified_count=len(hits)) + + def update_one(self, query: dict[str, Any], update: dict[str, Any]) -> Any: + return self.update_many(query, update) + + +@pytest.fixture +def db(): + collections: dict[str, _Collection] = {} + client = SimpleNamespace(get_collection=lambda name: collections.setdefault(name, _Collection())) + with patch.object(scene_rewind, "get_mongodb_client", return_value=client): + yield client.get_collection + + +@pytest.fixture +def scene(db): + sid = uuid4() + s = str(sid) + db("scenes").insert( + scene_id=s, + turns=[ + {"turn_id": "t1", "timestamp": BEFORE.replace(tzinfo=None)}, + {"turn_id": "t2", "timestamp": AFTER.replace(tzinfo=None)}, + ], + proposed_changes=["p1", "p2"], + ) + for name, when, key in (("before", BEFORE, "1"), ("after", AFTER, "2")): + db("proposed_changes").insert( + proposal_id=f"p{key}", scene_id=s, status=ProposalStatus.PENDING.value, created_at=when + ) + db("resolutions").insert(resolution_id=f"r{key}", scene_id=s, created_at=when) + db("character_memories").insert(memory_id=f"m{key}", scene_id=s, created_at=when) + db("scene_foreshadowing").insert(foreshadowing_id=f"f{key}", scene_id=s, created_at=when, status="open") + db("scene_foreshadowing").insert( + foreshadowing_id="paid", scene_id=s, created_at=BEFORE, status="paid", paid_at=AFTER, paid_at_turn=2 + ) + db("character_working_state").insert( + state_id="ws1", + scene_id=s, + created_at=BEFORE, + updated_at=AFTER, + current_stats={"xp": 30}, + resources={"HP": 2}, + modifications=[ + {"stat_or_resource": "HP", "change": -1, "timestamp": BEFORE.isoformat()}, + {"stat_or_resource": "HP", "change": -4, "timestamp": AFTER.isoformat()}, + ], + ) + # Another scene's writes are never touched. + db("resolutions").insert(resolution_id="other", scene_id=str(uuid4()), created_at=AFTER) + return sid + + +def _ids(db, name: str, key: str) -> list[str]: + return [d[key] for d in db(name).docs] + + +def test_everything_written_after_the_point_is_removed(db, scene): + earlier = {"ws1": {"current_stats": {"xp": 10}, "resources": {"HP": 6}}} + result = mongodb_rewind_scene(scene, T, working_states=earlier) + + (doc,) = db("scenes").docs + assert [t["turn_id"] for t in doc["turns"]] == ["t1"] and doc["proposed_changes"] == ["p1"] + assert _ids(db, "proposed_changes", "proposal_id") == ["p1"] + assert _ids(db, "resolutions", "resolution_id") == ["r1", "other"] + assert _ids(db, "character_memories", "memory_id") == ["m1"] + assert result.memory_ids_removed == ["m2"] + assert _ids(db, "scene_foreshadowing", "foreshadowing_id") == ["f1", "paid"] + paid = db("scene_foreshadowing").docs[1] + assert paid["status"] == "open" and paid["paid_at"] is None and paid["paid_at_turn"] is None + (state,) = db("character_working_state").docs + assert state["resources"] == {"HP": 6} and state["current_stats"] == {"xp": 10} + assert [m["change"] for m in state["modifications"]] == [-1] + assert result.not_undone == [] + assert (result.turns_removed, result.proposals_removed, result.resolutions_removed) == (1, 1, 1) + + +def test_an_accepted_proposal_refuses_and_changes_nothing(db, scene): + db("proposed_changes").docs[1]["status"] = ProposalStatus.ACCEPTED.value + before = {name: copy.deepcopy(c.docs) for name, c in [(n, db(n)) for n in ("scenes", "resolutions")]} + with pytest.raises(SceneRewindRefused, match="already accepted"): + mongodb_rewind_scene(scene, T) + assert db("scenes").docs == before["scenes"] and db("resolutions").docs == before["resolutions"] + assert len(db("proposed_changes").docs) == 2 + + +def test_a_rejected_proposal_never_reached_canon_so_it_is_removed(db, scene): + db("proposed_changes").docs[1]["status"] = ProposalStatus.REJECTED.value + assert mongodb_rewind_scene(scene, T).proposals_removed == 1 + assert _ids(db, "proposed_changes", "proposal_id") == ["p1"] + + +def test_a_working_state_created_after_the_point_is_removed(db, scene): + db("character_working_state").insert(state_id="ws2", scene_id=str(scene), created_at=AFTER, modifications=[]) + result = mongodb_rewind_scene(scene, T, working_states={"ws1": {"current_stats": {}, "resources": {}}}) + assert _ids(db, "character_working_state", "state_id") == ["ws1"] + assert result.working_states_removed == 1 + + +def test_a_changed_working_state_without_an_earlier_copy_is_reported(db, scene): + result = mongodb_rewind_scene(scene, T) + (state,) = db("character_working_state").docs + assert state["resources"] == {"HP": 2} # Kept, since nothing says what it was. + assert [m["change"] for m in state["modifications"]] == [-1] + assert result.not_undone and "ws1" in result.not_undone[0] + + +def test_rewinding_again_to_the_same_point_finds_nothing_more(db, scene): + mongodb_rewind_scene(scene, T, working_states={"ws1": {"current_stats": {}, "resources": {}}}) + again = mongodb_rewind_scene(scene, T) + assert (again.turns_removed, again.proposals_removed, again.memory_ids_removed) == (0, 0, []) + assert again.not_undone == [] and again.working_states_restored == 0 + + +def test_a_turn_after_a_rewind_counts_as_a_change_again(db, scene): + mongodb_rewind_scene(scene, T, working_states={"ws1": {"current_stats": {}, "resources": {}}}) + db("character_working_state").docs[0]["updated_at"] = datetime.now(UTC).replace(tzinfo=None) + timedelta(seconds=1) + assert mongodb_rewind_scene(scene, T).not_undone From 457681a005b74140439ae0eb914bd11937a24872 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 6 Nov 2026 22:30:42 -0300 Subject: [PATCH 3/3] chore(scripts): RS-1 remove the script that seeded Death in Space as a builtin It wrote the game straight into game_systems with is_builtin set, another way of building one named game into the platform. Nothing imports it. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/GM_AS_AUTHORITY.md | 1 - scripts/death_in_space_game_system.py | 112 -------------------------- 2 files changed, 113 deletions(-) delete mode 100644 scripts/death_in_space_game_system.py diff --git a/docs/architecture/GM_AS_AUTHORITY.md b/docs/architecture/GM_AS_AUTHORITY.md index 836f6019..1bedb55f 100644 --- a/docs/architecture/GM_AS_AUTHORITY.md +++ b/docs/architecture/GM_AS_AUTHORITY.md @@ -196,7 +196,6 @@ involved. - `packages/agents/tests/test_narrator_refines.py` (refinement path) - `packages/agents/tests/test_resolver_gm_loop.py` (GM-driven resolver) - `scripts/e2e_full_loop.py` (the E2E harness) -- `scripts/death_in_space_game_system.py` (DiS fixture) - `docs/testing/E2E_GM_AUTHORITY_.md` (per-session analysis) ## Post-refactor call: do we still need a small custom SLM? diff --git a/scripts/death_in_space_game_system.py b/scripts/death_in_space_game_system.py deleted file mode 100644 index 6d39893e..00000000 --- a/scripts/death_in_space_game_system.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -""" -Define + seed a Death in Space game system into the ``game_systems`` -collection, and expose ``DIS_GAME_CONTEXT`` for the character-creation / -scene loops. - -Modeled on the existing builtin systems (resources-based, like VtM). DiS -uses a d20 dice pool with attribute-only rolls (no skills) plus Void Points -as a meta-currency. Resources are Stress / Oxygen / Void. -""" -from __future__ import annotations - -from uuid import UUID - -DIS_SYSTEM_ID = UUID("22222222-2222-4222-8222-222222222222") # stable id for reuse - -DIS_GAME_CONTEXT = { - "system_id": str(DIS_SYSTEM_ID), - "name": "Death in Space", - "description": ( - "Blue-collar sci-fi survival on the edges of colonized space. " - "Characters roll d20 + attribute modifier (Body / Dexterity / Savvy / " - "Tech) against a Difficulty Rating (DR). Failed rolls may earn Void " - "Points, a meta-currency that can be spent for Advantage." - ), - "version": "1.0", - "is_builtin": True, - "core_mechanic": { - "type": "d20", - "formula": "1d20 + attribute_modifier vs DR", - "success_type": "meet_or_beat", - "success_threshold": "DR (set per check)", - "advantage": "Spend 1 Void Point to roll 2d20 keep highest", - "disadvantage": "Spend 1 Void Point (GM option) to roll 2d20 keep lowest", - }, - "attributes": [ - {"name": "Body", "abbreviation": "BOD", "min_value": -3, "max_value": 3, "default_value": 0, - "description": "Strength, endurance, hauling heavy things."}, - {"name": "Dexterity", "abbreviation": "DEX", "min_value": -3, "max_value": 3, "default_value": 0, - "description": "Agility, reflexes, fine motor."}, - {"name": "Savvy", "abbreviation": "SAV", "min_value": -3, "max_value": 3, "default_value": 0, - "description": "Awareness, intuition, gut instinct."}, - {"name": "Tech", "abbreviation": "TEC", "min_value": -3, "max_value": 3, "default_value": 0, - "description": "Mechanical, electrical, technical."}, - ], - "skills": [], # DiS uses attribute-only rolls; no skill list - "resources": [ - {"name": "Stress", "abbreviation": "STR", "min_value": 0, "max_value": 10, - "recovers_on": "rest", "depleted_effect": "collapse"}, - {"name": "Oxygen", "abbreviation": "O2", "min_value": 0, "max_value": 15, - "recovers_on": "refill", "depleted_effect": "suffocation"}, - {"name": "Void Points", "abbreviation": "VP", "min_value": 0, "max_value": 5, - "recovers_on": "milestone", "depleted_effect": "void corruption risk"}, - ], - "tracks": [ - {"name": "Stress", "min_value": 0, "max_value": 10, "min_threshold": 0, "max_threshold": 10, - "depleted_effect": "collapse", "maxed_effect": "hardened"}, - {"name": "Oxygen", "min_value": 0, "max_value": 15, "min_threshold": 0, "max_threshold": 15, - "depleted_effect": "suffocation", "maxed_effect": "fully pressurized"}, - {"name": "Void Points", "min_value": 0, "max_value": 5, "min_threshold": 0, "max_threshold": 5, - "depleted_effect": "void corruption risk", "maxed_effect": "void-touched"}, - ], - "conditions": [ - {"name": "Bleeding", "roll_modifier": -1, "roll_mode_override": "disadvantage"}, - {"name": "Inspired", "roll_modifier": 1, "roll_mode_override": "advantage"}, - {"name": "Low Oxygen", "roll_modifier": -2, "roll_mode_override": "disadvantage", - "reason_text": "Suit pressure dropping"}, - {"name": "Void-Touched", "tags": ["corruption"]}, - ], - "scenery_rules": [ - {"keyword": "zero-g", "trigger_verbs": ["pilot", "drift", "maneuver"], - "roll_modifier": -1, "roll_mode_override": "disadvantage", - "reason_text": "Zero-G orientation is tricky"}, - {"keyword": "pressurized", "trigger_verbs": ["search", "hack", "work"], - "roll_modifier": 0, "roll_mode_override": "normal", - "reason_text": "Cabin pressure is stable"}, - {"keyword": "debris", "trigger_verbs": ["drift", "search", "run"], - "roll_modifier": -1, "roll_mode_override": "disadvantage", - "reason_text": "Debris everywhere"}, - ], - "core_mechanic_summary": "d20 + attribute_mod vs DR; DR typically 10/12/15.", -} - - -def seed_dis_system() -> str: - """Seed DiS into MongoDB. Returns the system_id string. Idempotent.""" - import os - import sys - - sys.path.insert(0, "packages/data-layer/src") - from pymongo import MongoClient - - client = MongoClient( - os.getenv( - "MONGODB_URI", - "mongodb://monitor:changeme-mongodb@localhost:27017/monitor?authSource=admin", - ) - ) - col = client["monitor"]["game_systems"] - doc = dict(DIS_GAME_CONTEXT) - existing = col.find_one({"system_id": doc["system_id"]}) - if existing: - col.replace_one({"_id": existing["_id"]}, doc) - print(f"Updated: Death in Space ({doc['system_id']})") - else: - col.insert_one(doc) - print(f"Inserted: Death in Space ({doc['system_id']})") - return doc["system_id"] - - -if __name__ == "__main__": - seed_dis_system() \ No newline at end of file