From 40961d60f1537f285e8afb0d38a3d26f2a324ec8 Mon Sep 17 00:00:00 2001 From: The Wyrd One Date: Tue, 12 Aug 2025 22:05:38 -0400 Subject: [PATCH 1/2] feat: table_roll steps set TableRollResult objects as result --- .../executors/table_executor.py | 31 ++++++++++++----- .../src/grimoire_runner/models/roll_result.py | 20 +++++++++++ .../systems/flow_test/flows/table-flow.yaml | 2 +- .../knave_1e/flows/character_creation.yaml | 34 +++++++++---------- .../flows/determine_npc_reaction.yaml | 29 ++++++++++++++++ .../knave_1e/tables/misc/npc-reactions.yaml | 12 +++++++ 6 files changed, 101 insertions(+), 27 deletions(-) create mode 100644 systems/knave_1e/flows/determine_npc_reaction.yaml create mode 100644 systems/knave_1e/tables/misc/npc-reactions.yaml diff --git a/grimoire-runner/src/grimoire_runner/executors/table_executor.py b/grimoire-runner/src/grimoire_runner/executors/table_executor.py index 52aa7a9..e6a42bb 100644 --- a/grimoire-runner/src/grimoire_runner/executors/table_executor.py +++ b/grimoire-runner/src/grimoire_runner/executors/table_executor.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any from ..integrations.dice_integration import DiceIntegration +from ..models.roll_result import RollResult, TableRollResult from .base import BaseStepExecutor if TYPE_CHECKING: @@ -20,6 +21,17 @@ class TableExecutor(BaseStepExecutor): def __init__(self): self.dice_integration = DiceIntegration() + def _dice_result_to_roll_result(self, dice_result) -> RollResult: + """Convert a DiceResult from DiceIntegration to a RollResult.""" + return RollResult( + total=dice_result.total, + detail=dice_result.detailed_result + or f"{dice_result.total} ({dice_result.expression})", + expression=dice_result.expression, + breakdown=dice_result.breakdown, + individual_rolls=dice_result.rolls, + ) + def execute( self, step: "StepDefinition", context: "ExecutionContext", system: "System" ) -> "StepResult": @@ -87,6 +99,9 @@ def _execute_table_roll( dice_result = self.dice_integration.roll_expression("1d100") roll_value = dice_result.total + # Convert dice result to RollResult + roll_result = self._dice_result_to_roll_result(dice_result) + # Get the result from the table table_result = table.get_entry_by_range(roll_value) if table_result is None: @@ -102,14 +117,12 @@ def _execute_table_roll( f"table_result={table_result}, typed_result type={type(typed_result).__name__}" ) - result_data = { - "table": table_name, - "roll": roll_value, - "result": typed_result, - "dice_expression": table.roll or "1d100", - } + # Create the TableRollResult object + table_roll_result = TableRollResult( + entry=typed_result, roll_result=roll_result + ) - results.append(result_data) + results.append(table_roll_result) # Log the individual table roll result logger.debug( @@ -119,10 +132,10 @@ def _execute_table_roll( # Execute table actions with result context if table_roll.actions: logger.debug( - f"Executing table actions with typed_result: {type(typed_result).__name__}" + f"Executing table actions with table_roll_result: {type(table_roll_result).__name__}" ) self._execute_table_actions( - table_roll.actions, context, system, typed_result, results + table_roll.actions, context, system, table_roll_result, results ) # If only one result, return it directly, otherwise return list diff --git a/grimoire-runner/src/grimoire_runner/models/roll_result.py b/grimoire-runner/src/grimoire_runner/models/roll_result.py index 259d608..33ea238 100644 --- a/grimoire-runner/src/grimoire_runner/models/roll_result.py +++ b/grimoire-runner/src/grimoire_runner/models/roll_result.py @@ -80,3 +80,23 @@ def __eq__(self, other) -> bool: def __ne__(self, other) -> bool: """Inequality comparison based on total.""" return not self.__eq__(other) + + +@dataclass +class TableRollResult: + """Result of a table roll operation containing both the entry and roll information.""" + + entry: Any + """The entry selected from the table (type depends on table's entry_type).""" + + roll_result: RollResult + """The RollResult object for the dice roll that was performed.""" + + def __str__(self) -> str: + """String representation shows the entry and roll details.""" + return f"{self.entry} (rolled {self.roll_result.total})" + + @property + def result(self) -> Any: + """Alias for entry to support legacy template usage.""" + return self.entry diff --git a/grimoire-runner/tests/systems/flow_test/flows/table-flow.yaml b/grimoire-runner/tests/systems/flow_test/flows/table-flow.yaml index 78e35b8..db38302 100644 --- a/grimoire-runner/tests/systems/flow_test/flows/table-flow.yaml +++ b/grimoire-runner/tests/systems/flow_test/flows/table-flow.yaml @@ -19,4 +19,4 @@ steps: - type: "set_value" data: path: "outputs.character.name" - value: "{{ result }}" + value: "{{ result.entry }}" diff --git a/systems/knave_1e/flows/character_creation.yaml b/systems/knave_1e/flows/character_creation.yaml index ae81513..cc2d257 100644 --- a/systems/knave_1e/flows/character_creation.yaml +++ b/systems/knave_1e/flows/character_creation.yaml @@ -123,14 +123,14 @@ steps: flow: add_item_to_character inputs: character: "outputs.knave" - item: "{{ result }}" + item: "{{ result.entry }}" - table: helmets_and_shields actions: - flow_call: flow: add_item_to_character inputs: character: "outputs.knave" - item: "{{ result }}" + item: "{{ result.entry }}" - table: dungeoneering_gear count: 2 actions: @@ -138,21 +138,21 @@ steps: flow: add_items_to_character inputs: character: "outputs.knave" - items: "{{ results }}" + items: "{{ results | map(attribute='entry') | list }}" - table: general_gear_1 actions: - flow_call: flow: add_item_to_character inputs: character: "outputs.knave" - item: "{{ result }}" + item: "{{ result.entry }}" - table: general_gear_2 actions: - flow_call: flow: add_item_to_character inputs: character: "outputs.knave" - item: "{{ result }}" + item: "{{ result.entry }}" additional_actions: - flow_call: flow: add_item_to_character @@ -171,57 +171,57 @@ steps: actions: - set_value: path: "outputs.knave.traits.physique" - value: "{{ result }}" + value: "{{ result.entry }}" - table: face actions: - set_value: path: "outputs.knave.traits.face" - value: "{{ result }}" + value: "{{ result.entry }}" - table: skin actions: - set_value: path: "outputs.knave.traits.skin" - value: "{{ result }}" + value: "{{ result.entry }}" - table: hair actions: - set_value: path: "outputs.knave.traits.hair" - value: "{{ result }}" + value: "{{ result.entry }}" - table: clothing actions: - set_value: path: "outputs.knave.traits.clothing" - value: "{{ result }}" + value: "{{ result.entry }}" - table: virtue actions: - set_value: path: "outputs.knave.traits.virtue" - value: "{{ result }}" + value: "{{ result.entry }}" - table: vice actions: - set_value: path: "outputs.knave.traits.vice" - value: "{{ result }}" + value: "{{ result.entry }}" - table: speech actions: - set_value: path: "outputs.knave.traits.speech" - value: "{{ result }}" + value: "{{ result.entry }}" - table: background actions: - set_value: path: "outputs.knave.traits.background" - value: "{{ result }}" + value: "{{ result.entry }}" - table: misfortunes actions: - set_value: path: "outputs.knave.traits.misfortunes" - value: "{{ result }}" + value: "{{ result.entry }}" - table: alignment actions: - set_value: path: "outputs.knave.traits.alignment" - value: "{{ result }}" + value: "{{ result.entry }}" - id: choose_gender_choice name: "Gender Determination Choice" @@ -269,7 +269,7 @@ steps: actions: - set_value: path: "outputs.knave.gender" - value: "{{ result }}" + value: "{{ result.entry }}" next_step: choose_name - id: choose_name diff --git a/systems/knave_1e/flows/determine_npc_reaction.yaml b/systems/knave_1e/flows/determine_npc_reaction.yaml new file mode 100644 index 0000000..a2bb486 --- /dev/null +++ b/systems/knave_1e/flows/determine_npc_reaction.yaml @@ -0,0 +1,29 @@ +id: determine_npc_reaction +type: flow +name: "Determine NPC Reaction" +description: >- + Determines the NPC's reaction based on the context provided. +version: "1.0" +inputs: [] + +outputs: + - type: str + id: npc_reaction + description: "The reaction of the NPC" + +variables: [] + +steps: + - id: roll_npc_reaction + name: "Determine NPC Reaction" + type: table_roll + prompt: "Rolling for NPC reaction..." + tables: + - table: npc_reactions + actions: + - set_value: + path: "outputs.npc_reaction" + value: "{{ result.entry }}" + actions: + - log_message: + message: "NPC reaction determined: {{ result.entry }} ({{ result.roll_result.detail }})" diff --git a/systems/knave_1e/tables/misc/npc-reactions.yaml b/systems/knave_1e/tables/misc/npc-reactions.yaml new file mode 100644 index 0000000..a9a481c --- /dev/null +++ b/systems/knave_1e/tables/misc/npc-reactions.yaml @@ -0,0 +1,12 @@ +kind: table +id: npc_reactions +name: "NPC Reactions" +version: "1.0" +roll: 2d6 +description: "Reactions of NPCs based on context" +entries: + 2: Hostile + 3-5: Unfriendly + 6-8: Unsure + 9-11: Talkative + 12: Helpful From 3cdfc7180f303246e421425a6ff8e47750eea36a Mon Sep 17 00:00:00 2001 From: The Wyrd One Date: Tue, 12 Aug 2025 22:54:44 -0400 Subject: [PATCH 2/2] fix: prompt set first message for dice roll now --- .../src/grimoire_runner/models/flow.py | 3 +- .../src/grimoire_runner/ui/rich_tui.py | 9 +- .../systems/flow_test/flows/enum-flow.yaml | 77 +++++++ .../tests/test_flow_definitions.py | 201 +++++++++++++++++- .../flows/combat/perform_initiative.yaml | 27 +++ .../add_item_to_character.yaml | 0 .../perform_saving_throw.yaml | 0 .../roll_opposed_save.yaml | 0 .../roll_saving_throw.yaml | 0 9 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 grimoire-runner/tests/systems/flow_test/flows/enum-flow.yaml create mode 100644 systems/knave_1e/flows/combat/perform_initiative.yaml rename systems/knave_1e/flows/{ => inventory}/add_item_to_character.yaml (100%) rename systems/knave_1e/flows/{ => saving_throws}/perform_saving_throw.yaml (100%) rename systems/knave_1e/flows/{ => saving_throws}/roll_opposed_save.yaml (100%) rename systems/knave_1e/flows/{ => saving_throws}/roll_saving_throw.yaml (100%) diff --git a/grimoire-runner/src/grimoire_runner/models/flow.py b/grimoire-runner/src/grimoire_runner/models/flow.py index b967495..4ec153e 100644 --- a/grimoire-runner/src/grimoire_runner/models/flow.py +++ b/grimoire-runner/src/grimoire_runner/models/flow.py @@ -38,9 +38,9 @@ class OutputDefinition: id: str validate: bool = False description: str | None = None + enum: list[str] | None = None -@dataclass @dataclass class VariableDefinition: """Flow variable definition.""" @@ -49,6 +49,7 @@ class VariableDefinition: type: str = "unknown" description: str | None = None default: Any = None # Default value for the variable + enum: list[str] | None = None @dataclass diff --git a/grimoire-runner/src/grimoire_runner/ui/rich_tui.py b/grimoire-runner/src/grimoire_runner/ui/rich_tui.py index b9fc42d..707008e 100644 --- a/grimoire-runner/src/grimoire_runner/ui/rich_tui.py +++ b/grimoire-runner/src/grimoire_runner/ui/rich_tui.py @@ -29,10 +29,13 @@ } -def get_step_start_message(step_type: StepType) -> str: - """Get generic start message for a step type.""" +def get_step_start_message(step_type: StepType, step_prompt: str = None) -> str: + """Get start message for a step type, prioritizing custom prompt.""" emoji = STEP_TYPE_EMOJIS.get(step_type, "⚙️") + if step_prompt: + return f"{emoji} {step_prompt}" + messages = { StepType.DICE_ROLL: "Rolling dice...", StepType.DICE_SEQUENCE: "Rolling dice sequence...", @@ -405,7 +408,7 @@ def execute_flow(self, flow_obj, context, system) -> bool: def _execute_single_step(self, step, step_num: int) -> tuple[bool, str | None]: """Execute a single step.""" # Show generic start message for step type - start_message = get_step_start_message(step.type) + start_message = get_step_start_message(step.type, step.prompt) self._print_indented(f"[cyan]{start_message}[/cyan]") # Special handling for flow_call steps to show sub-flow execution diff --git a/grimoire-runner/tests/systems/flow_test/flows/enum-flow.yaml b/grimoire-runner/tests/systems/flow_test/flows/enum-flow.yaml new file mode 100644 index 0000000..9a1b83a --- /dev/null +++ b/grimoire-runner/tests/systems/flow_test/flows/enum-flow.yaml @@ -0,0 +1,77 @@ +# Test flow demonstrating enum support for inputs, outputs, and variables +id: "enum-flow" +type: "flow" +name: "Enum Test Flow" +description: "Flow demonstrating enum parameter support" +version: "1.0" + +inputs: + - id: "difficulty" + type: "str" + enum: ["easy", "medium", "hard"] + required: true + description: "Game difficulty level" + - id: "character_class" + type: "str" + enum: ["warrior", "mage", "rogue"] + required: true + description: "Character class selection" + +outputs: + - id: "battle_result" + type: "str" + enum: ["victory", "defeat", "draw"] + description: "The outcome of the battle" + - id: "initiative_side" + type: "str" + enum: ["players", "enemies"] + description: "Which side has initiative" + +variables: + - id: "current_phase" + type: "str" + enum: ["preparation", "combat", "resolution"] + default: "preparation" + description: "Current phase of the encounter" + - id: "player_status" + type: "str" + enum: ["healthy", "wounded", "critical"] + default: "healthy" + description: "Player's current health status" + +steps: + - id: "start" + name: "Initialize Battle" + type: "completion" + prompt: "Starting battle with difficulty: {{ inputs.difficulty }}, class: {{ inputs.character_class }}" + actions: + - type: "set_value" + data: + path: "variables.current_phase" + value: "combat" + next_step: "determine_initiative" + + - id: "determine_initiative" + name: "Roll Initiative" + type: "dice_roll" + roll: "1d6" + actions: + - type: "set_value" + data: + path: "outputs.initiative_side" + value: "{% if result.total > 3 %}players{% else %}enemies{% endif %}" + next_step: "battle_resolution" + + - id: "battle_resolution" + name: "Resolve Battle" + type: "completion" + prompt: "Battle resolving with {{ outputs.initiative_side }} having initiative" + actions: + - type: "set_value" + data: + path: "outputs.battle_result" + value: "{% if inputs.difficulty == 'easy' %}victory{% elif inputs.difficulty == 'hard' %}defeat{% else %}draw{% endif %}" + - type: "set_value" + data: + path: "variables.current_phase" + value: "resolution" diff --git a/grimoire-runner/tests/test_flow_definitions.py b/grimoire-runner/tests/test_flow_definitions.py index 1474238..4590ce2 100644 --- a/grimoire-runner/tests/test_flow_definitions.py +++ b/grimoire-runner/tests/test_flow_definitions.py @@ -26,6 +26,7 @@ StepDefinition, StepType, TableRollDefinition, + VariableDefinition, ) @@ -354,7 +355,7 @@ def test_system_loads_completely_with_flows(self, test_system): # Verify all components are loaded assert len(test_system.models) == 1 # character model assert len(test_system.tables) == 1 # simple-names table - assert len(test_system.flows) == 9 # all test flows including nested test flows + assert len(test_system.flows) == 10 # all test flows including enum-flow # Verify flows reference valid system components assert "character" in test_system.models @@ -442,3 +443,201 @@ def test_step_creation_utility(self): assert step.type == StepType.DICE_ROLL assert step.roll == "2d6" assert step.output == "roll_result" + + +class TestEnumSupport: + """Test enum support for InputDefinition, OutputDefinition, and VariableDefinition.""" + + def test_input_definition_with_enum(self): + """Test InputDefinition supports enum parameter.""" + input_def = InputDefinition( + id="test_input", + type="str", + enum=["option1", "option2", "option3"], + description="Test input with enum", + ) + + assert input_def.id == "test_input" + assert input_def.type == "str" + assert input_def.enum == ["option1", "option2", "option3"] + assert input_def.description == "Test input with enum" + assert input_def.required is True # Default value + + def test_input_definition_without_enum(self): + """Test InputDefinition works without enum parameter.""" + input_def = InputDefinition(id="test_input", type="str") + + assert input_def.id == "test_input" + assert input_def.type == "str" + assert input_def.enum is None + + def test_output_definition_with_enum(self): + """Test OutputDefinition supports enum parameter.""" + output_def = OutputDefinition( + id="test_output", + type="str", + enum=["success", "failure", "pending"], + description="Test output with enum", + ) + + assert output_def.id == "test_output" + assert output_def.type == "str" + assert output_def.enum == ["success", "failure", "pending"] + assert output_def.description == "Test output with enum" + assert output_def.validate is False # Default value + + def test_output_definition_without_enum(self): + """Test OutputDefinition works without enum parameter.""" + output_def = OutputDefinition(id="test_output", type="str") + + assert output_def.id == "test_output" + assert output_def.type == "str" + assert output_def.enum is None + + def test_variable_definition_with_enum(self): + """Test VariableDefinition supports enum parameter.""" + var_def = VariableDefinition( + id="test_variable", + type="str", + enum=["active", "inactive", "disabled"], + default="active", + description="Test variable with enum", + ) + + assert var_def.id == "test_variable" + assert var_def.type == "str" + assert var_def.enum == ["active", "inactive", "disabled"] + assert var_def.default == "active" + assert var_def.description == "Test variable with enum" + + def test_variable_definition_without_enum(self): + """Test VariableDefinition works without enum parameter.""" + var_def = VariableDefinition(id="test_variable", type="str") + + assert var_def.id == "test_variable" + assert var_def.type == "str" + assert var_def.enum is None + assert var_def.default is None + + def test_flow_definition_with_enum_parameters(self): + """Test creating a complete FlowDefinition with enum parameters.""" + flow = FlowDefinition( + id="enum-test-flow", + name="Enum Test Flow", + inputs=[ + InputDefinition( + id="difficulty", + type="str", + enum=["easy", "medium", "hard"], + required=True, + ) + ], + outputs=[ + OutputDefinition(id="result", type="str", enum=["success", "failure"]) + ], + variables=[ + VariableDefinition( + id="status", + type="str", + enum=["running", "completed", "failed"], + default="running", + ) + ], + steps=[ + StepDefinition( + id="test_step", + name="Test Step", + type=StepType.COMPLETION, + prompt="Testing enum support", + ) + ], + ) + + # Test input with enum + input_def = flow.inputs[0] + assert input_def.enum == ["easy", "medium", "hard"] + + # Test output with enum + output_def = flow.outputs[0] + assert output_def.enum == ["success", "failure"] + + # Test variable with enum + var_def = flow.variables[0] + assert var_def.enum == ["running", "completed", "failed"] + + def test_enum_parameter_type_validation(self): + """Test that enum parameters accept proper list types.""" + # Test with list of strings + input_def = InputDefinition(id="string_enum", type="str", enum=["a", "b", "c"]) + assert input_def.enum == ["a", "b", "c"] + + # Test with empty list + output_def = OutputDefinition(id="empty_enum", type="str", enum=[]) + assert output_def.enum == [] + + # Test with None (should work) + var_def = VariableDefinition(id="no_enum", type="str", enum=None) + assert var_def.enum is None + + def test_enum_flow_loading(self, test_system): + """Test that flows with enum parameters load correctly from YAML.""" + flow = test_system.flows.get("enum-flow") + assert flow is not None + assert flow.id == "enum-flow" + assert flow.name == "Enum Test Flow" + + # Test input enums + difficulty_input = next( + (inp for inp in flow.inputs if inp.id == "difficulty"), None + ) + assert difficulty_input is not None + assert difficulty_input.enum == ["easy", "medium", "hard"] + + class_input = next( + (inp for inp in flow.inputs if inp.id == "character_class"), None + ) + assert class_input is not None + assert class_input.enum == ["warrior", "mage", "rogue"] + + # Test output enums + battle_output = next( + (out for out in flow.outputs if out.id == "battle_result"), None + ) + assert battle_output is not None + assert battle_output.enum == ["victory", "defeat", "draw"] + + initiative_output = next( + (out for out in flow.outputs if out.id == "initiative_side"), None + ) + assert initiative_output is not None + assert initiative_output.enum == ["players", "enemies"] + + # Test variable enums + phase_var = next( + (var for var in flow.variables if var.id == "current_phase"), None + ) + assert phase_var is not None + assert phase_var.enum == ["preparation", "combat", "resolution"] + assert phase_var.default == "preparation" + + status_var = next( + (var for var in flow.variables if var.id == "player_status"), None + ) + assert status_var is not None + assert status_var.enum == ["healthy", "wounded", "critical"] + assert status_var.default == "healthy" + + def test_enum_backward_compatibility(self, test_system): + """Test that existing flows without enums still work.""" + # Test basic flow (no enums) + basic_flow = test_system.flows["basic-flow"] + + # All definitions should have enum as None + for input_def in basic_flow.inputs: + assert input_def.enum is None + + for output_def in basic_flow.outputs: + assert output_def.enum is None + + for var_def in basic_flow.variables: + assert var_def.enum is None diff --git a/systems/knave_1e/flows/combat/perform_initiative.yaml b/systems/knave_1e/flows/combat/perform_initiative.yaml new file mode 100644 index 0000000..96ac594 --- /dev/null +++ b/systems/knave_1e/flows/combat/perform_initiative.yaml @@ -0,0 +1,27 @@ +id: perform_initiative +type: flow +name: "Perform Initiative" +description: >- + Determines the who acts first in combat - Players or Enemies. +version: "1.0" +inputs: [] + +outputs: + - type: str + id: side_with_initiative + enum: ["Players", "Enemies"] + description: "The side with the initiative" + +steps: + - id: roll_initiative + name: "Roll Initiative" + type: dice_roll + prompt: "Rolling for initiative..." + roll: 1d6 + actions: + - set_value: + path: "outputs.side_with_initiative" + value: "{% if result.total > 3 %}Players{% else %}Enemies{% endif %}" + - log_message: + message: "Rolled {{ result.detail }}" + result_message: "{{ outputs.side_with_initiative }} have initiative" diff --git a/systems/knave_1e/flows/add_item_to_character.yaml b/systems/knave_1e/flows/inventory/add_item_to_character.yaml similarity index 100% rename from systems/knave_1e/flows/add_item_to_character.yaml rename to systems/knave_1e/flows/inventory/add_item_to_character.yaml diff --git a/systems/knave_1e/flows/perform_saving_throw.yaml b/systems/knave_1e/flows/saving_throws/perform_saving_throw.yaml similarity index 100% rename from systems/knave_1e/flows/perform_saving_throw.yaml rename to systems/knave_1e/flows/saving_throws/perform_saving_throw.yaml diff --git a/systems/knave_1e/flows/roll_opposed_save.yaml b/systems/knave_1e/flows/saving_throws/roll_opposed_save.yaml similarity index 100% rename from systems/knave_1e/flows/roll_opposed_save.yaml rename to systems/knave_1e/flows/saving_throws/roll_opposed_save.yaml diff --git a/systems/knave_1e/flows/roll_saving_throw.yaml b/systems/knave_1e/flows/saving_throws/roll_saving_throw.yaml similarity index 100% rename from systems/knave_1e/flows/roll_saving_throw.yaml rename to systems/knave_1e/flows/saving_throws/roll_saving_throw.yaml