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/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/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/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/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/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 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 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 # ---------------------------------------------------------------------------