diff --git a/data/manifest.json b/data/manifest.json index 481e7291c..c34e0e8d5 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -1130,6 +1130,70 @@ } ] }, + { + "id": "bs.fsm", + "name": "Final State Machine", + "slug": "bookshelf-fsm", + "description": "Bookshelf final state machine module.", + "documentation": "https://docs.mcbookshelf.dev/en/latest/modules/fsm.html", + "kind": "data_pack", + "tags": [ + "runtime" + ], + "dependencies": [ + "bs.random" + ], + "features": [ + { + "id": "#bs.fsm:new", + "kind": "function_tag", + "documentation": "https://docs.mcbookshelf.dev/en/latest/modules/fsm.html#new", + "authors": [ + "theogiraudet" + ], + "created": { + "date": "2025/07/16", + "minecraft_version": "1.21.7" + }, + "updated": { + "date": "2025/07/16", + "minecraft_version": "1.21.7" + } + }, + { + "id": "#bs.fsm:start", + "kind": "function_tag", + "documentation": "https://docs.mcbookshelf.dev/en/latest/modules/fsm.html#start", + "authors": [ + "theogiraudet" + ], + "created": { + "date": "2025/07/20", + "minecraft_version": "1.21.8" + }, + "updated": { + "date": "2025/07/20", + "minecraft_version": "1.21.8" + } + }, + { + "id": "#bs.fsm:start_as", + "kind": "function_tag", + "documentation": "https://docs.mcbookshelf.dev/en/latest/modules/fsm.html#start-as", + "authors": [ + "theogiraudet" + ], + "created": { + "date": "2025/07/20", + "minecraft_version": "1.21.8" + }, + "updated": { + "date": "2025/07/20", + "minecraft_version": "1.21.8" + } + } + ] + }, { "id": "bs.generation", "name": "Generation", diff --git a/docs/_static/bookshelf.css b/docs/_static/bookshelf.css index 085b968e0..2ab69b009 100644 --- a/docs/_static/bookshelf.css +++ b/docs/_static/bookshelf.css @@ -404,3 +404,8 @@ ul.navbar-icon-links { footer, #pst-secondary-sidebar { --pst-color-link: var(--pst-color-text-base) } + +.mermaid { + background-color: var(--pst-color-background); + border: none; +} diff --git a/docs/conf.py b/docs/conf.py index 45ca59920..98941ae9f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,6 +20,7 @@ "sphinx_minecraft", "sphinx_togglebutton", "sphinx_treeview", + "sphinxcontrib.mermaid", ] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] diff --git a/docs/modules/fsm.md b/docs/modules/fsm.md new file mode 100644 index 000000000..4617abad3 --- /dev/null +++ b/docs/modules/fsm.md @@ -0,0 +1,403 @@ +# 🔄 Finite State Machine + +**`#bs.fsm:help`** + +A powerful Finite State Machine (FSM) system for managing complex state-based behaviors in Minecraft. + +```{epigraph} +FSMs are without a doubt the most commonly used technology in game AI programming today. +They are conceptually simple, efficient, easily extensible, and yet powerful enough to handle a wide variety of situations. + +-- Daniel D. Fu & Ryan Houlette +``` + +The FSM module provides a comprehensive system for creating, managing, and executing finite state machines. +It allows you to define states, transitions, and behaviors in a declarative way, making complex state management simple and maintainable. + +--- + +## 🔧 Functions + +You can find below all functions available in this module. + +--- + +### New + +```{function} #bs.fsm:new + +Create a new Finite State Machine (FSM) with the specified configuration. + +:Inputs: + **Function macro**: + :::{treeview} + - {nbt}`compound` Arguments + - {nbt}`string` **name**: Unique identifier for the FSM. + - {nbt}`compound` **fsm**: FSM configuration object. + - {nbt}`string` **initial**: Name of the initial state (must exist in states array). + - {nbt}`string` **on_cancel**: Function to call when the FSM is cancelled (optional). + - {nbt}`list` **states**: Array of state definitions. + - {nbt}`compound` State + - {nbt}`string` **name**: Unique name for the state. + - {nbt}`string` **on_tick**: Function to call every tick while in this state (optional). + - {nbt}`string` **on_enter**: Function to call when entering this state (optional). + - {nbt}`string` **on_exit**: Function to call when exiting this state (optional). + - {nbt}`bool` **final**: Whether this state is a final state (optional, default: false). + - {nbt}`list` **transitions**: Array of transition definitions (optional). + - {nbt}`compound` Transition + - {nbt}`string` **name**: Name of the transition (optional). + - {nbt}`string` {nbt}`compound` **condition**: Transition condition. One of the following: + - {nbt}`compound` Predicate-based transition. + - {nbt}`string` **type**: Must be "manual". + - {nbt}`string` **wait**: A signal sent manually to the FSM using the `#bs.fsm:emit` feature. + - {nbt}`compound` Predicate-based transition. + - {nbt}`string` **type**: Must be "predicate". + - {nbt}`string` **wait**: Predicate to check to trigger the transition. + - {nbt}`compound` Command-based transition. + - {nbt}`string` **type**: Must be "command". + - {nbt}`string` **wait**: Command to check to trigger the transition. + - {nbt}`compound` Hook-based transition. + - {nbt}`string` **type**: Must be "hook". + - {nbt}`string` **wait**: Hook function to evaluate. + - {nbt}`compound` Time-based transition. + - {nbt}`string` **type**: Must be "delay". + - {nbt}`string` **wait**: Time delay in ticks. + - {nbt}`string` **to**: Name of the target state (must exist in states array). + ::: + +:Outputs: + **Return**: Success (1) if FSM was created successfully, failure (0) otherwise. + + **State**: The FSM is registered and available for use. +``` + +*Example: Create a simple light FSM with on/off states:* + +```mcfunction +# Create a light FSM +function #bs.fsm:new { \ + name: "light_fsm", \ + fsm: { \ + initial: "off", \ + states: [ \ + { \ + name: "off", \ + on_enter: "setblock ~ ~ ~ minecraft:redstone_lamp", \ + transitions: [ \ + { \ + name: "turn_on", \ + condition: "manual", \ + to: "on" \ + } \ + ] \ + }, \ + { \ + name: "on", \ + on_enter: "setblock ~ ~ ~ minecraft:redstone_lamp[lit=true]", \ + transitions: [ \ + { \ + name: "turn_off", \ + condition: "manual", \ + to: "off" \ + } \ + ] \ + } \ + ] \ + } \ +} +``` + +> **Credits**: theogiraudet + +--- + +### Start + +:::::{tab-set} +::::{tab-item} Global Instance + +```{function} #bs.fsm:start + +Start a new global instance of a Finite State Machine. + +:Inputs: + **Function macro**: + :::{treeview} + - {nbt}`compound` Arguments + - {nbt}`string` **fsm_name**: Name of the FSM to instantiate (must exist). + - {nbt}`string` **instance_name**: Unique identifier for this FSM instance. + ::: + +:Outputs: + **Return**: Success (1) if instance was started successfully, failure (0) otherwise. + + **State**: The FSM instance is created globally and begins execution in its initial state. +``` + +*Example: Start a light FSM instance:* + +```mcfunction +# Start a light FSM instance +function #bs.fsm:start { fsm_name: "light_fsm", instance_name: "main_light" } + +# The light FSM is now running globally and will execute its initial state +``` + +> **Credits**: theogiraudet + +:::: +::::{tab-item} Local Instance + +```{function} #bs.fsm:start_as + +Start new local instances of a Finite State Machine bound to the executing entities. +The different commands and predicates used in the FSM will be executed as and at the executing entities. +If the entity is killed during the execution of the FSM, the module will automatically stop the tick commands and transitions evaluation for this entity. + +:Inputs: + **Execution `as `**: Entities to bind. The entities must not be players. + + **Function macro**: + :::{treeview} + - {nbt}`compound` Arguments + - {nbt}`string` **fsm_name**: Name of the FSM to instantiate (must exist). + - {nbt}`string` **instance_name**: Unique identifier for this FSM instance in this context. + ::: + +:Outputs: + **Return**: Success (1) if instance was started successfully, failure (0) otherwise. + + **State**: The FSM instances are created locally for the executing entities and begins execution in their initial state. +``` + +*Example: Start a light FSM instance for an entity:* + +```mcfunction +# Start a light FSM instance bound to the executing entity +execute as @n[type=zombie] run function #bs.fsm:start_as { fsm_name: "light_fsm", instance_name: "entity_light" } + +# The light FSM is now running locally for this zombie and will execute its initial state +``` + +> **Credits**: theogiraudet + +:::: +::::: + +--- + +### Emit + +```{function} #bs.fsm:emit + +Emit a signal to a running FSM instance. +This signal may or may not trigger a transition, according to the current state of the FSM instance. + +:Inputs: + **Function macro**: + :::{treeview} + - {nbt}`compound` Arguments + - {nbt}`string` **instance_name**: Name of the FSM instance to emit the signal to. + - {nbt}`string` **signal**: Name of the signal to emit. + ::: +``` + +*Example: Emit a signal to a FSM instance:* + +```mcfunction +# Emit a signal to a global FSM instance +function #bs.fsm:emit { instance_name: "main_light", signal: "turn_on" } +``` + +--- + +### Cancel + +```{function} #bs.fsm:cancel + +Cancel and stop a running FSM instance. + +:Inputs: + **Function macro**: + :::{treeview} + - {nbt}`compound` Arguments + - {nbt}`string` **instance_name**: Name of the FSM instance to cancel. + - {nbt}`string` **bind**: Binding type of the instance. + - **"global"**: Instance is bound globally. + - **"local"**: Instance is bound to the current execution context. + ::: + +:Outputs: + **Return**: Success (1) if instance was cancelled successfully, failure (0) otherwise. + + **State**: The FSM instance is stopped and cleaned up. If the FSM has an on_cancel function, it will be called. +``` + +*Example: Cancel a door FSM instance:* + +```mcfunction +# Cancel the door FSM instance +function #bs.fsm:cancel { instance_name: "main_door", bind: "global" } + +# The door FSM instance is now stopped +``` + +> **Credits**: theogiraudet + +--- + +### Delete + +```{function} #bs.fsm:delete + +Delete a Finite State Machine definition and all its instances. + +:Inputs: + **Function macro**: + :::{treeview} + - {nbt}`compound` Arguments + - {nbt}`string` **fsm_name**: Name of the FSM to delete. + ::: + +:Outputs: + **Return**: Success (1) if FSM was deleted successfully, failure (0) otherwise. + + **State**: The FSM definition and all its running instances are removed. +``` + +*Example: Delete a door FSM:* + +```mcfunction +# Delete the door FSM +function #bs.fsm:delete { fsm_name: "door_fsm" } + +# The door FSM and all its instances are now removed +``` + +> **Credits**: theogiraudet + +--- + +## ❓ What is a FSM? + +A Finite State Machine (FSM) is a conceptual model used to describe how a system behaves in response to events. +It defines a limited set of possible states that the system can be in at any given moment. +The system starts in an initial state and, when something happens, such as receiving an input or a signal, it may change its state following predefined rules. +These changes are called transitions, and each one depends on the current state and the event received. + +What makes FSMs powerful is their simplicity and clarity. +By reducing a system's behavior to a set of states and transitions, we can describe even complex logic in a very structured and predictable way. +At any point in time, the system is in exactly one state, and the logic for moving between states is well defined. +This helps avoid ambiguity and makes it easier to understand how the system reacts to different situations. + +In Minecraft, Finite State Machines can be particularly useful to manage tree dialog, boss phases, or any system state. +Outside Minecraft, Finite State Machines are widely used in many fields because they provide a clean way to manage systems that have different modes or stages. +In software development, they are useful for designing user interfaces, game character behavior, communication protocols, and more. +In hardware and control systems, they are often used to manage sequences of operations or reactions to sensor inputs. +Overall, FSMs are a fundamental tool for modeling reactive systems in a way that is both rigorous and easy to reason about. + +## 💡 Example in Minecraft + +```{mermaid} +stateDiagram-v2 + [*] --> Idle + + Idle --> Alert : if player detected + + Alert --> Attack : after 5s AND player still detected + Alert --> Idle : after 5s AND player gone + + Attack --> Searching : if player lost + + Searching --> Attack : if player found + Searching --> Idle : after 10s AND player not found + + Attack --> Idle : if player defeated +``` + +This finite state machine controls the behavior of a custom mob in Minecraft: a sentinel that guards a specific area. +It begins in the **Idle** state, where it stays mostly still, occasionally performing small ambient animations. +When a player enters its detection radius, as determined by a custom command or predicate, the FSM transitions to the **Alert** state. +In the **Alert** state, the sentinel visually or audibly signals that it has detected an intruder. +This state is time-based, lasting about five seconds. +If the player is still present when this period ends, the sentinel moves to the **Attack** state. +During **Attack**, the mob actively pursues and attacks the player. +If the player escapes or is no longer detectable, the FSM transitions to the **Searching** state. +There, the sentinel wanders the area near the last known location of the intruder for a set amount of time. +If it finds the player again during this search, it returns to **Attack**. +Otherwise, if the timer runs out without detecting anyone, it returns to the **Idle** state and resumes its guard duty. +If the player is defeated, the FSM transitions to the **Idle** state and resumes its guard duty. + + + +--- +## 📋 Validation Rules + +The FSM system enforces several validation rules to ensure proper operation: + +### Initiality +- The FSM must have an `initial` state specified +- The initial state must exist in the states array + +### Unicity +- All state names must be unique within the FSM +- All transition names must be unique within a state (if specified) + +### Acceptability +- The FSM must have at least one final state +- Final states are states marked with `final: true` + +### Reachability +- All final states must be reachable from the initial state +- This is determined by analyzing the transition graph + +### Transition Validation +- All transition target states must exist in the states array +- Transition conditions must be valid according to their type + +--- + +## 🔄 State Lifecycle + +Each state in an FSM follows a specific lifecycle: + +1. **Enter**: The `on_enter` function is called when entering the state +2. **Tick**: The `on_tick` function is called every tick while in the state +3. **Transition evaluation**: When a transition condition is met, the state transitions +4. **Exit**: The `on_exit` function is called when leaving the state + +--- + +## ⚡ Transition Types + +The FSM system supports several types of transitions: + +### Manual +Triggered by external function calls. +Useful for player interactions or external events. + +### Predicate +Triggered when a predicate returns true. +Useful for conditional logic. + +### Command +Triggered when a command succeeds. +Useful for complex conditions. + +### Hook +Triggered by hook system events. +Useful for integration with other systems. + +### Delay +Triggered after a specified time delay. +Useful for timed behaviors. + +--- + +## ⚠️ Best Practices + +1. **Keep states focused**: Each state should represent a single, well-defined behavior +2. **Use meaningful names**: State and transition names should clearly describe their purpose +3. **Handle edge cases**: Always consider what happens when transitions fail +4. **Clean up resources**: Use the on_exit functions to clean up state-specific resources +5. **Define a cancel command**: Use a `on_cancel` command to clean up resources when the FSM is cancelled diff --git a/docs/modules/index.md b/docs/modules/index.md index 957886683..7f60f3216 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -30,6 +30,7 @@ bitwise block color environment +fsm generation health hitbox diff --git a/modules/bs.fsm/data/bs.fsm/function/__load__.mcfunction b/modules/bs.fsm/data/bs.fsm/function/__load__.mcfunction new file mode 100644 index 000000000..7d244a5c1 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/__load__.mcfunction @@ -0,0 +1,7 @@ +forceload add -30000000 1600 + +execute unless entity B5-0-0-0-1 run summon minecraft:marker -30000000 0 1600 {UUID:[I;181,0,0,1],Tags:["bs.entity","bs.persistent","smithed.entity","smithed.strict"]} +execute unless entity B5-0-0-0-2 run summon minecraft:text_display -30000000 0 1600 {UUID:[I;181,0,0,2],Tags:["bs.entity","bs.persistent","smithed.entity","smithed.strict"],view_range:0f,alignment:"center"} +execute unless data storage bs:data fsm run data modify storage bs:data fsm set value { fsm: {}, running_instances: {}, listened_transitions: [], ticks: [] } + +# execute if data storage bs:data fsm.ticks[0] run schedule function bs.fsm:run/tick 1t diff --git a/modules/bs.fsm/data/bs.fsm/function/cancel.mcfunction b/modules/bs.fsm/data/bs.fsm/function/cancel.mcfunction new file mode 100644 index 000000000..da4114d75 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/cancel.mcfunction @@ -0,0 +1,3 @@ +# Input: +# Macro: instance_name: string +# Macro: bind: "global" | "local" diff --git a/modules/bs.fsm/data/bs.fsm/function/check/acceptability.mcfunction b/modules/bs.fsm/data/bs.fsm/function/check/acceptability.mcfunction new file mode 100644 index 000000000..8ceb2e15c --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/check/acceptability.mcfunction @@ -0,0 +1,29 @@ +# Input: +# Storage: bs:ctx _.fsm (a FSM) + +# Output: +# Storage: bs:ctx _.finals (a list of states) +# Return 0 or 1 (0/fail if the FSM is not acceptable, 1 if it is) + +# Goal: check if the FSM is acceptable, ie, if it has at least one final state +# Also check if the final states do not have any transition + +data modify storage bs:ctx _.finals set value [] +data modify storage bs:ctx _.finals append from storage bs:ctx _.fsm.states[{final: true}] +execute unless data storage bs:ctx _.finals[0] run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:new", \ + tag: "new", \ + message: [{text: "The FSM has no final state."}] \ +} +execute unless data storage bs:ctx _.finals[0] run return fail + +execute if data storage bs:ctx _.finals[].transitions[0] run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:new", \ + tag: "new", \ + message: [{text: "At least one final state has a transition."}] \ +} +execute if data storage bs:ctx _.finals[].transitions[0] run return fail + +return 1 diff --git a/modules/bs.fsm/data/bs.fsm/function/check/initiality.mcfunction b/modules/bs.fsm/data/bs.fsm/function/check/initiality.mcfunction new file mode 100644 index 000000000..c3376125d --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/check/initiality.mcfunction @@ -0,0 +1,14 @@ +# Input: +# Storage: bs:ctx _.fsm (a FSM) + +# Goal: check if the specified initial state exist in the FSM +execute store success score #s bs.ctx run function bs.fsm:check/internal/initiality with storage bs:ctx _.fsm +execute if score #s bs.ctx matches 0 run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:new", \ + tag: "new", \ + message: [{text: "The initial state does not exist in the FSM."}] \ +} +execute if score #s bs.ctx matches 0 run return fail + +return 1 diff --git a/modules/bs.fsm/data/bs.fsm/function/check/internal/explore_state.mcfunction b/modules/bs.fsm/data/bs.fsm/function/check/internal/explore_state.mcfunction new file mode 100644 index 000000000..cb984b740 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/check/internal/explore_state.mcfunction @@ -0,0 +1,23 @@ +# Input: +# Storage: bs:ctx _.states_to_browse (a list of states name) +# Storage: bs:ctx _.states_to_find (a list of states name) +# Storage: bs:ctx _.fsm (a FSM) + +# If we have no more state to browse, we return +execute unless data storage bs:ctx _.states_to_browse[-1] run return 1 + +# We select the first state of the stack to get states having a transition to it +data modify storage bs:ctx _.current_state set from storage bs:ctx _.states_to_browse[-1] + +# This function places the states having a transition to the current state in storage bs:ctx _.states_to_find +function bs.fsm:check/internal/get_input_states with storage bs:ctx _ + +# We remove the state from the stack +data remove storage bs:ctx _.states_to_browse[-1] + +# We update the list of states to find, if we fail, we return since it means that one of the states does not exist (even if this is supposed to be impossible) +# This will also add the found states to the list of states to browse +execute unless function bs.fsm:check/internal/update_found_states run return fail + +# We continue to browse the states, we return the result of the function call to propagate errors +return run function bs.fsm:check/internal/explore_state diff --git a/modules/bs.fsm/data/bs.fsm/function/check/internal/get_input_states.mcfunction b/modules/bs.fsm/data/bs.fsm/function/check/internal/get_input_states.mcfunction new file mode 100644 index 000000000..7ff1cda4e --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/check/internal/get_input_states.mcfunction @@ -0,0 +1,9 @@ +# Input: +# Macro: $(current_state) (a state name) +# Storage: bs:ctx _.fsm (a FSM) + +# Output: +# Storage: bs:ctx _.source_states (a list of states name) + +data modify storage bs:ctx _.source_states set value [] +$data modify storage bs:ctx _.source_states append from storage bs:ctx _.fsm.states[{transitions: [{to: "$(current_state)"}]}] diff --git a/modules/bs.fsm/data/bs.fsm/function/check/internal/initiality.mcfunction b/modules/bs.fsm/data/bs.fsm/function/check/internal/initiality.mcfunction new file mode 100644 index 000000000..e8b930a2a --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/check/internal/initiality.mcfunction @@ -0,0 +1,12 @@ +# Input: +# Storage: bs:ctx _.fsm (a FSM) +# Macro: initial: state + +# Output: +# Storage: bs:ctx _.initial (a state) +# Return 0 or 1 (0 if the state does not exist, 1 if it does) + +data remove storage bs:ctx _.initial +# We save the initial property directly in the state object +$data modify storage bs:ctx _.fsm.states[{name: $(initial)}].initial set value true +$return run data modify storage bs:ctx _.initial set from storage bs:ctx _.fsm.states[{name: $(initial)}] diff --git a/modules/bs.fsm/data/bs.fsm/function/check/internal/select_state.mcfunction b/modules/bs.fsm/data/bs.fsm/function/check/internal/select_state.mcfunction new file mode 100644 index 000000000..4b264b6e2 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/check/internal/select_state.mcfunction @@ -0,0 +1,9 @@ +# Input: +# Macro: $(state) (a state name) +# Storage: bs:ctx _.states_to_find (a list of states name) + +# Output: +# Set the state has selected +# If no state, return fail + +$return run data modify storage bs:ctx _.states_to_find[{name: '$(state)'}].selected set value true diff --git a/modules/bs.fsm/data/bs.fsm/function/check/internal/update_found_states.mcfunction b/modules/bs.fsm/data/bs.fsm/function/check/internal/update_found_states.mcfunction new file mode 100644 index 000000000..0befa94ed --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/check/internal/update_found_states.mcfunction @@ -0,0 +1,34 @@ +# Input: +# Storage: bs:ctx _.source_states (a list of states name) +# Storage: bs:ctx _.states_to_find (a list of states name) +# Storage: bs:ctx _.states_to_browse (a list of states name) + +# If we have no more state to find, we directly return to stop the recursive loop +execute unless data storage bs:ctx _.source_states[0] run return 1 + +data modify storage bs:ctx _.state set from storage bs:ctx _.source_states[0].name +data remove storage bs:ctx _.source_states[0] + +# First, we select the state to find, to avoid the use of multiple macro commands +execute store success score #s bs.ctx run function bs.fsm:check/internal/select_state with storage bs:ctx _ + +# If we fail to select the state, that is because the state does not exist (even if this is supposed to be impossible), we log an error and return +execute if score #s bs.ctx matches 0 run function #bs.log:error { \ + namespace: bs.fsm, \ + path: "#bs.fsm:new", \ + tag: "new", \ + message: [{text: "The state '"}, {nbt: "_.state",storage: "bs:ctx"},{text: "' does not exist."}] \ +} +execute if score #s bs.ctx matches 0 run return fail + +# If we succeed to write the state as found, it means that the state was not found before +execute store success score #s bs.ctx run data modify storage bs:ctx _.states_to_find[{selected: true}].found set value true +data remove storage bs:ctx _.states_to_find[{selected: true}].selected + +# If we succeed, we add the state to the list of states to browse +execute if score #s bs.ctx matches 1 run data modify storage bs:ctx _.states_to_browse append from storage bs:ctx _.state + +# Else, that means we already browse the state, or we already plan to browse it. Since we do not want to browse the same state twice, we do nothing + +# We need to run this function until we have no more state to find, we directly return the result of the function call to propagate errors +return run function bs.fsm:check/internal/update_found_states diff --git a/modules/bs.fsm/data/bs.fsm/function/check/internal/well_formedness_state.mcfunction b/modules/bs.fsm/data/bs.fsm/function/check/internal/well_formedness_state.mcfunction new file mode 100644 index 000000000..253522dc5 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/check/internal/well_formedness_state.mcfunction @@ -0,0 +1,28 @@ +# Input: +# Storage: bs:ctx _.states (a list of states) +# Entity: B5-0-0-0-1 Tags (the list of all states names) + +# Output: +# Fail if the current state is not valid + +# If we don't have any state to check, we return +execute unless data storage bs:ctx _.states[0] run return 1 + +# We check if the current state has a name +execute unless data storage bs:ctx _.states[0].name run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:new", \ + tag: "new", \ + message: [{text: "A state does not have a name."}] \ +} +execute unless data storage bs:ctx _.states[0].name run return fail + +# If the state has transitions, we check if they are valid +execute if data storage bs:ctx _.states[0].transitions store success score #s bs.ctx run function bs.fsm:check/internal/well_formedness_transition + +# We propagate the error if the transitions are not valid +execute if score #s bs.ctx matches 0 run return fail + +# We check the next state +data remove storage bs:ctx _.states[0] +return run function bs.fsm:check/internal/well_formedness_state diff --git a/modules/bs.fsm/data/bs.fsm/function/check/internal/well_formedness_transition.mcfunction b/modules/bs.fsm/data/bs.fsm/function/check/internal/well_formedness_transition.mcfunction new file mode 100644 index 000000000..2abae907c --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/check/internal/well_formedness_transition.mcfunction @@ -0,0 +1,84 @@ +# Input: +# Storage: bs:ctx _.states (a list of states) +# Entity: B5-0-0-0-1 Tags (the list of all states names) + +# Output: +# Fail if the current transition is not valid + +execute unless data storage bs:ctx _.states[0].transitions[0] run return 1 + +# First, we have if the 'to' state exists +# For that, we will use the entity Tags array property which cannot store multiple occurrences of the same value +# So if we add inside the name of all the states, then the 'to' state, the size of the array will not change +data modify storage bs:ctx _.tags set from entity B5-0-0-0-1 Tags + +execute store result score #a bs.ctx run data get entity B5-0-0-0-1 Tags + +data modify entity B5-0-0-0-1 Tags append from storage bs:ctx _.states[0].transitions[0].to +execute store result score #b bs.ctx run data get entity B5-0-0-0-1 Tags + +# We restore the previous tags +data modify entity B5-0-0-0-1 Tags set from storage bs:ctx _.tags + +execute unless score #a bs.ctx = #b bs.ctx run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:new", \ + tag: "new", \ + message: [{text: "A transition of '"}, {nbt: "_.states[0].name", storage: "bs:ctx"}, {text: "' refers an unknown state: "}, {nbt: "_.states[0].transitions[0].to", storage: "bs:ctx"}] \ +} +execute unless score #a bs.ctx = #b bs.ctx run return fail + + +# We check if the transition has a condition +execute unless data storage bs:ctx _.states[0].transitions[0].condition run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:new", \ + tag: "new", \ + message: [{text: "A transition of '"}, {nbt: "_.states[0].name", storage: "bs:ctx"}, {text: "' does not have a condition."}] \ +} +execute unless data storage bs:ctx _.states[0].transitions[0].condition run return fail + +# We check if the condition is "manual" +data modify storage bs:ctx _.condition set value "manual" +execute store success score #s bs.ctx run data modify storage bs:ctx _.condition set from storage bs:ctx _.states[0].transitions[0].condition +# If we fail to overwrite "manual", it means that the condition is "manual" so we can return +execute if score #s bs.ctx matches 0 run return 1 + +# If the condition is not "manual", we need to check if the condition is an object +execute unless data storage bs:ctx _.states[0].transitions[0].condition.type run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:new", \ + tag: "new", \ + message: [{text: "A transition of '"}, {nbt: "_.states[0].name", storage: "bs:ctx"}, {text: "' has an invalid condition."}] \ +} +execute unless data storage bs:ctx _.states[0].transitions[0].condition.type run return fail + +# Now, we need to check the validity of the condition object, notably the wait +execute unless data storage bs:ctx _.states[0].transitions[0].condition.wait run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:new", \ + tag: "new", \ + message: [{text: "A transition of '"}, {nbt: "_.states[0].name", storage: "bs:ctx"}, {text: "' does not have a wait in its condition."}] \ +} +execute unless data storage bs:ctx _.states[0].transitions[0].condition.wait run return fail + +# We check if the condition type is valid +data modify storage bs:ctx _.condition set value [] +data modify storage bs:ctx _.condition append from storage bs:ctx _.states[0].transitions[0].condition + +execute store success score #s bs.ctx unless data storage bs:ctx _.states[0].transitions[0].condition[{type: "predicate"}] \ +unless data storage bs:ctx _.states[0].transitions[0].condition[{type: "command"}] \ +unless data storage bs:ctx _.states[0].transitions[0].condition[{type: "hook"}] \ +unless data storage bs:ctx _.states[0].transitions[0].condition[{type: "delay"}] + +execute if score #s bs.ctx matches 0 run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:new", \ + tag: "new", \ + message: [{text: "A transition of '"}, {nbt: "_.states[0].name", storage: "bs:ctx"}, {text: "' has an invalid condition."}] \ +} +execute if score #s bs.ctx matches 0 run return fail + +# If we pass all the checks, we can continue to the next transition +data remove storage bs:ctx _.states[0].transitions[0] +return run function bs.fsm:check/internal/well_formedness_transition diff --git a/modules/bs.fsm/data/bs.fsm/function/check/is_valid.mcfunction b/modules/bs.fsm/data/bs.fsm/function/check/is_valid.mcfunction new file mode 100644 index 000000000..4ba12d22a --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/check/is_valid.mcfunction @@ -0,0 +1,18 @@ +# Input: +# Storage: bs:ctx _.fsm (a FSM) + +# Missing checks: +# - Check the structural validity of the FSM +# - Check if all transitions refer to existing states + +execute store success score #s bs.ctx run function bs.fsm:check/well_formedness +# Also save the initial property in the initial state object +execute if score #s bs.ctx matches 1 run execute store success score #s bs.ctx run function bs.fsm:check/initiality +execute if score #s bs.ctx matches 1 store success score #s bs.ctx run function bs.fsm:check/unicity +# Need to be call before reachability, since this latter uses the finals states +execute if score #s bs.ctx matches 1 store success score #s bs.ctx run function bs.fsm:check/acceptability +execute if score #s bs.ctx matches 1 store success score #s bs.ctx run function bs.fsm:check/reachability + +execute if score #s bs.ctx matches 0 run return fail + +return 1 diff --git a/modules/bs.fsm/data/bs.fsm/function/check/reachability.mcfunction b/modules/bs.fsm/data/bs.fsm/function/check/reachability.mcfunction new file mode 100644 index 000000000..93cf86198 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/check/reachability.mcfunction @@ -0,0 +1,53 @@ +# Input: +# Storage: bs:ctx _.fsm (a FSM) +# Storage: bs:ctx _.finals (a list of states) + +# Storage: bs:ctx _.found_states (a list of states name) + +# Goal: check if from any state of the FSM, we can reach a final state. +# In more technical terms, we want to check if the FSM is a weakly connected graph with a path existing from any state to a final state. + +# How do we proceed? Depth-first search algorithm +# 1. We initialize an empty stack to store the states to explore +# 1. We stack into it the final states +# 2. We unstack the first state of the stack and identify the states having a transition to it (internal/explore_state) +# 3. We add them to the stack if we have not already visited them (internal/update_found_states) +# 4. We repeat the process until the stack is empty (internal/explore_state) +# → If we visit all the states, that means that from any state of the FSM, we can reach a final state +# → Else, some states cannot reach a final state + +# We also profit from this function to check if all states used in transitions are defined in the FSM + + +# Initialization +data modify storage bs:ctx _.states_to_find set value [] +data modify storage bs:ctx _.states_to_find append from storage bs:ctx _.fsm.states[] +data modify storage bs:ctx _.states_to_find[].found set value false +# We set the final states as found +data modify storage bs:ctx _.states_to_find[{final: true}].found set value true + +# We initialize the stack of states to start exploring with the final states +data modify storage bs:ctx _.states_to_browse set value [] +data modify storage bs:ctx _.states_to_browse append from storage bs:ctx _.finals[].name + +# We start the depth-first search algorithm +execute store success score #s bs.ctx run function bs.fsm:check/internal/explore_state + +# If we fail to explore the states, that means that some states used in transitions are not defined in the FSM +execute if score #s bs.ctx matches 0 run return fail + +# Else, we check if all states are found +data modify storage bs:ctx _.not_found_states set value [] +data modify storage bs:ctx _.not_found_states append from storage bs:ctx _.states_to_find[{found: false}] + +# If we have some states that cannot reach a final state, we log an error and return +execute if data storage bs:ctx _.not_found_states[0] run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:new", \ + tag: "new", \ + message: [{text: "The states '"}, {nbt: "_.not_found_states[].name", storage: "bs:ctx"},{text: "' cannot reach a final state."}] \ +} +execute if data storage bs:ctx _.not_found_states[0] run return fail + +# Else, we return success +return 1 diff --git a/modules/bs.fsm/data/bs.fsm/function/check/unicity.mcfunction b/modules/bs.fsm/data/bs.fsm/function/check/unicity.mcfunction new file mode 100644 index 000000000..88f4ac882 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/check/unicity.mcfunction @@ -0,0 +1,36 @@ +# Input: +# Storage: bs:ctx _.fsm (a FSM) + +# Goal: check if the names of the states are unique +# How do we proceed? +# We will use a specific behavior of Minecraft mob's tag: the list of tags cannot have duplicates +# Following that, we can compare the size of the list of tags with the size of the list of states +# If they are different, that means that there are duplicate names + +data modify storage bs:ctx _.tags set from entity B5-0-0-0-1 Tags + +# We get the size of the list of states names +execute store result score #a bs.ctx run data get storage bs:ctx _.fsm.states +# We get the size of the list of tags to substract at the end +execute store result score #s bs.ctx run data get entity B5-0-0-0-1 Tags +# We set the list of tags to the list of states names +data modify entity B5-0-0-0-1 Tags append from storage bs:ctx _.fsm.states[].name +# We get the list of tags +execute store result score #b bs.ctx run data get entity B5-0-0-0-1 Tags +# We reset the tags to the default tags +data modify entity B5-0-0-0-1 Tags set from storage bs:ctx _.tags + +# As our list of tags has our state names with the default tags, we need to substract the size of the list of tags before our append to the size of the list of states +scoreboard players operation #b bs.ctx -= #s bs.ctx + +# We compare the size of the list of tags with the size of the list of states, if they are different, that means that there are duplicate names so we log an error and return +execute unless score #a bs.ctx = #b bs.ctx run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:check/unicity", \ + tag: "unicity", \ + message: [{text: "The names of the states are not unique."}] \ +} +execute unless score #a bs.ctx = #b bs.ctx run return fail + +# Else, we return success +return 1 diff --git a/modules/bs.fsm/data/bs.fsm/function/check/well_formedness.mcfunction b/modules/bs.fsm/data/bs.fsm/function/check/well_formedness.mcfunction new file mode 100644 index 000000000..35bd80d88 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/check/well_formedness.mcfunction @@ -0,0 +1,41 @@ +# Input: +# Storage: bs:ctx _.fsm (a FSM) + +# We copy the states names to the entity tag since an entity tag array can only store one occurrence of a value +# This array will be useful to know if a transition refers to a state that does not exist +scoreboard players set #r bs.ctx 1 +data modify storage bs:ctx _.saved_tags set from entity B5-0-0-0-1 Tags +data modify entity B5-0-0-0-1 Tags append from storage bs:ctx _.fsm.states[].name + +data modify storage bs:ctx _.states set value [] +data modify storage bs:ctx _.states append from storage bs:ctx _.fsm.states[] + +# We check if the FSM is valid + +# First, we check if the FSM has an initial state +execute unless data storage bs:ctx _.fsm.initial run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:new", \ + tag: "new", \ + message: [{text: "The FSM does not have an initial state."}] \ +} +execute unless data storage bs:ctx _.fsm.initial run scoreboard players set #r bs.ctx 0 + +# Then, we check if the FSM has at least one state +execute unless data storage bs:ctx _.states[0] run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:new", \ + tag: "new", \ + message: [{text: "The FSM does not have any state."}] \ +} +execute unless data storage bs:ctx _.states[0] run scoreboard players set #r bs.ctx 0 + +# Finally, we have to check each state +execute unless score #r bs.ctx matches 0 store success score #r bs.ctx run function bs.fsm:check/internal/well_formedness_state + +# We restore the previous tags +data modify entity B5-0-0-0-1 Tags set from storage bs:ctx _.saved_tags + +# We return the result of the check +execute if score #r bs.ctx matches 0 run return fail +return 1 diff --git a/modules/bs.fsm/data/bs.fsm/function/delete.mcfunction b/modules/bs.fsm/data/bs.fsm/function/delete.mcfunction new file mode 100644 index 000000000..161a9e269 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/delete.mcfunction @@ -0,0 +1,2 @@ +# Input: +# Macro: fsm_name: string diff --git a/modules/bs.fsm/data/bs.fsm/function/new.mcfunction b/modules/bs.fsm/data/bs.fsm/function/new.mcfunction new file mode 100644 index 000000000..2353f4469 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/new.mcfunction @@ -0,0 +1,40 @@ +# Input: +# Macro: name: string +# Macro: fsm: { +# initial: state +# on_cancel?: command +# states: [ +# { +# name: string +# on_tick?: command +# on_exit?: command +# on_enter?: command +# final?: boolean +# transitions?: [ +# { +# name?: string +# condition: 'manual' | { type: 'predicate', wait: string } | { type: 'command', wait: string } | { type: 'hook', wait: string } | { type: 'delay', wait: string } +# to: state +# } +# ] +# } +# ] +# } + +# Check if the FSM already exists. +$execute if data storage bs:data fsm.fsm.'$(name)' run function #bs.log:error { \ + namespace: bs.fsm, \ + path: "#bs.fsm:new", \ + tag: "new", \ + message: ["A FSM with the name '$(name)' already exists."] \ +} +$execute if data storage bs:data fsm.fsm.'$(name)' run return fail + +$data modify storage bs:ctx _ set value { fsm: $(fsm) } + +# Check if the FSM is valid +execute store success score #s bs.ctx run function bs.fsm:check/is_valid +execute if score #s bs.ctx matches 0 run return fail + +$data modify storage bs:data fsm.fsm.'$(name)' set value $(fsm) +data remove storage bs:ctx _ diff --git a/modules/bs.fsm/data/bs.fsm/function/new_vanilla.mcfunction b/modules/bs.fsm/data/bs.fsm/function/new_vanilla.mcfunction new file mode 100644 index 000000000..09fa95ad7 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/new_vanilla.mcfunction @@ -0,0 +1,322 @@ +# ------------------------------------------------------------------------------------------------------------ +# Copyright (c) 2025 Gunivers +# +# This file is part of the Bookshelf project (https://github.com/mcbookshelf/bookshelf). +# +# This source code is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Conditions: +# - You may use this file in compliance with the MPL v2.0 +# - Any modifications must be documented and disclosed under the same license +# +# For more details, refer to the MPL v2.0. +# ------------------------------------------------------------------------------------------------------------ + +## === SETUP === + +# Clear any existing FSM data +data remove storage bs:data fsm.fsm + +## === VALID FSM CREATION === + +# Test 1: Create a simple valid FSM +function #bs.fsm:new { \ + name: "test_fsm", \ + fsm: { \ + initial: "idle", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + transitions: [ \ + { \ + name: "start", \ + condition: "manual", \ + to: "active" \ + } \ + ] \ + }, \ + { \ + name: "active", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + final: true \ + } \ + ] \ + } \ +} +execute unless data storage bs:data fsm.fsm.test_fsm run say "Test 1: Failed to create a valid FSM" + +## === DUPLICATE FSM ERROR === + +# Test 2: Try to create the same FSM again (should fail) +execute store success score #s bs.ctx run function #bs.fsm:new { \ + name: "test_fsm", \ + fsm: { \ + initial: "idle", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit" \ + } \ + ] \ + } \ +} +execute unless score #s bs.ctx matches 0 run say "Test 2: Failed to return an error when creating a duplicate FSM" + +## === INVALID FSM - MISSING INITIAL STATE === + +# Test 3: Create FSM with missing initial state +execute store success score #s bs.ctx run function #bs.fsm:new { \ + name: "invalid_fsm_1", \ + fsm: { \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit" \ + } \ + ] \ + } \ +} +execute unless score #s bs.ctx matches 0 run say "Test 3: Failed to return an error when FSM has no initial state" + +## === INVALID FSM - INITIAL STATE NOT FOUND === + +# Test 4: Create FSM with initial state that doesn't exist +execute store success score #s bs.ctx run function #bs.fsm:new { \ + name: "invalid_fsm_2", \ + fsm: { \ + initial: "nonexistent", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit" \ + } \ + ] \ + } \ +} +execute unless score #s bs.ctx matches 0 run say "Test 4: Failed to return an error when initial state doesn't exist" + +## === INVALID FSM - DUPLICATE STATE NAMES === + +# Test 5: Create FSM with duplicate state names +execute store success score #s bs.ctx run function #bs.fsm:new { \ + name: "invalid_fsm_3", \ + fsm: { \ + initial: "idle", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit" \ + }, \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit" \ + } \ + ] \ + } \ +} +execute unless score #s bs.ctx matches 0 run say "Test 5: Failed to return an error when FSM has duplicate state names" + +## === INVALID FSM - TRANSITION TO NONEXISTENT STATE === + +# Test 6: Create FSM with transition to non-existent state +execute store success score #s bs.ctx run function #bs.fsm:new { \ + name: "invalid_fsm_4", \ + fsm: { \ + initial: "idle", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + transitions: [ \ + { \ + name: "start", \ + condition: "manual", \ + to: "nonexistent" \ + }, \ + { \ + name: "start", \ + condition: "manual", \ + to: "active" \ + } \ + ] \ + }, \ + { \ + name: "active", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + final: true \ + } \ + ] \ + } \ +} +execute unless score #s bs.ctx matches 0 run say "Test 6: Failed to return an error when transition points to non-existent state" + +## === VALID FSM WITH COMPLEX TRANSITIONS === + +# Test 7: Create a valid FSM with different transition types +function #bs.fsm:new { \ + name: "complex_fsm", \ + fsm: { \ + initial: "start", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "start", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + transitions: [ \ + { \ + name: "manual_transition", \ + condition: "manual", \ + to: "waiting" \ + }, \ + { \ + name: "predicate_transition", \ + condition: { type: "predicate", wait: "bs.fsm:test/condition" }, \ + to: "processing" \ + }, \ + { \ + name: "function_transition", \ + condition: { type: "command", wait: "bs.fsm:test/function" }, \ + to: "processing" \ + }, \ + { \ + name: "hook_transition", \ + condition: { type: "hook", wait: "bs.fsm:test/hook" }, \ + to: "processing" \ + }, \ + { \ + name: "delay_transition", \ + condition: { type: "delay", wait: "20t" }, \ + to: "processing" \ + } \ + ] \ + }, \ + { \ + name: "waiting", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + transitions: [ \ + { \ + name: "manual_transition", \ + condition: "manual", \ + to: "processing" \ + } \ + ] \ + }, \ + { \ + name: "processing", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + final: true \ + } \ + ] \ + } \ +} +execute unless data storage bs:data fsm.fsm.complex_fsm run say "Test 7: Failed to create a complex FSM with various transition types" + +## === VALID FSM WITH MINIMAL CONFIGURATION === + +# Test 8: Create a minimal valid FSM +function #bs.fsm:new { \ + name: "minimal_fsm", \ + fsm: { \ + initial: "state1", \ + states: [ \ + { \ + name: "state1", \ + final: true \ + } \ + ] \ + } \ +} +execute unless data storage bs:data fsm.fsm.minimal_fsm run say "Test 8: Failed to create a minimal FSM" + +## === INVALID FSM - UNREACHABLE FINAL STATE === + +# Test 9: Create FSM with unreachable final state +execute store success score #s bs.ctx run function #bs.fsm:new { \ + name: "invalid_fsm_5", \ + fsm: { \ + initial: "idle", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + final: false \ + }, \ + { \ + name: "final_state", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + final: true \ + } \ + ] \ + } \ +} +execute unless score #s bs.ctx matches 0 run say "Test 9: Failed to return an error when FSM has unreachable final state" + +## === INVALID FSM - NO FINAL STATE === + +# Test 10: Create FSM with no final state +execute store success score #s bs.ctx run function #bs.fsm:new { \ + name: "invalid_fsm_6", \ + fsm: { \ + initial: "idle", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + final: false \ + }, \ + { \ + name: "active", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + final: false \ + } \ + ] \ + } \ +} +execute unless score #s bs.ctx matches 0 run say "Test 10: Failed to return an error when FSM has no final state" + +## === CLEANUP === + +# Clean up test data +data remove storage bs:data fsm.fsm diff --git a/modules/bs.fsm/data/bs.fsm/function/run/check_entity.mcfunction b/modules/bs.fsm/data/bs.fsm/function/run/check_entity.mcfunction new file mode 100644 index 000000000..d86d23335 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/run/check_entity.mcfunction @@ -0,0 +1,4 @@ +# Input: +# - Macro context: + +$return run execute if entity $(context) diff --git a/modules/bs.fsm/data/bs.fsm/function/run/enter_state_global.mcfunction b/modules/bs.fsm/data/bs.fsm/function/run/enter_state_global.mcfunction new file mode 100644 index 000000000..68c8f1398 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/run/enter_state_global.mcfunction @@ -0,0 +1,38 @@ +# Input: +# - Macro instance_name: string +# - Macro state_name: string - new current state name + +# We set the new state as current state +$data modify storage bs:data fsm.running_instances.'$(instance_name)'.states[{name: "$(state_name)"}].current set value true + +$data modify storage bs:ctx _.state set from storage bs:data fsm.running_instances.'$(instance_name)'.states[{name: "$(state_name)"}] + +# We prepare the transitions to be listened +data modify storage bs:ctx _.tmp set from storage bs:ctx _.state.transitions +# We remove the manual transitions since we do not need to listen to them +data remove storage bs:ctx _.tmp[{condition: "manual"}] +data modify storage bs:ctx _.tmp[].source set from storage bs:ctx _.state.name +data modify storage bs:ctx _.tmp[].context set value "global" +data modify storage bs:ctx _.tmp[].global set value true +$data modify storage bs:ctx _.tmp[].instance_name set value "$(instance_name)" + +# We check the listened_transitions list size +execute store result score #s bs.ctx run data get storage bs:data fsm.listened_transitions + +# We add the transitions to the listened transitions list +data modify storage bs:data fsm.listened_transitions append from storage bs:ctx _.tmp[] + +# If before, the listened_transitions list was empty, we start the listened transitions loop +execute if score #s bs.ctx matches ..0 run schedule function bs.fsm:run/evaluate_transitions 1t + +# We execute the on_enter command +execute if data storage bs:ctx _.state.on_enter run data modify storage bs:ctx _.command set from storage bs:ctx _.state.on_enter +execute if data storage bs:ctx _.state.on_enter run function bs.fsm:run/run_command_global with storage bs:ctx _ + +# We register the on_tick command +execute if data storage bs:ctx _.state.on_tick run data modify storage bs:ctx _.tmp set value {} +execute if data storage bs:ctx _.state.on_tick run data modify storage bs:ctx _.tmp.command set from storage bs:ctx _.state.on_tick +$execute if data storage bs:ctx _.state.on_tick run data modify storage bs:ctx _.tmp.instance_name set value "$(instance_name)" +execute if data storage bs:ctx _.state.on_tick run data modify storage bs:data fsm.ticks append from storage bs:ctx _.tmp +# If this is the only command on the ticks list, we start the tick loop +execute unless data storage bs:data fsm.ticks[1] run schedule function bs.fsm:run/tick 1t diff --git a/modules/bs.fsm/data/bs.fsm/function/run/enter_state_local.mcfunction b/modules/bs.fsm/data/bs.fsm/function/run/enter_state_local.mcfunction new file mode 100644 index 000000000..608ec7ab4 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/run/enter_state_local.mcfunction @@ -0,0 +1,40 @@ +# Input: +# - Macro instance_name: string +# - Macro state_name: string - new current state name +# - Macro context: + +# We set the new state as current state +$data modify entity $(context) data.bs:fsm.running_instances.'$(instance_name)'.states[{name: "$(state_name)"}].current set value true + +$data modify storage bs:ctx _.state set from entity $(context) data.bs:fsm.running_instances.'$(instance_name)'.states[{name: "$(state_name)"}] +$data modify storage bs:ctx _.context set value "$(context)" + +# We prepare the transitions to be listened +data modify storage bs:ctx _.tmp set from storage bs:ctx _.state.transitions +# We remove the manual transitions since we do not need to listen to them +data remove storage bs:ctx _.tmp[{condition: "manual"}] +data modify storage bs:ctx _.tmp[].source set from storage bs:ctx _.state.name +data modify storage bs:ctx _.tmp[].context set from storage bs:ctx _.context +$data modify storage bs:ctx _.tmp[].instance_name set value "$(instance_name)" + +# We check the listened_transitions list size +execute store result score #s bs.ctx run data get storage bs:data fsm.listened_transitions + +# We add the transitions to the listened transitions list +data modify storage bs:data fsm.listened_transitions append from storage bs:ctx _.tmp[] + +# If before, the listened_transitions list was empty, we start the listened transitions loop +execute if score #s bs.ctx matches ..0 run schedule function bs.fsm:run/evaluate_transitions 1t + +# We execute the on_enter command +execute if data storage bs:ctx _.state.on_enter run data modify storage bs:ctx _.command set from storage bs:ctx _.state.on_enter +execute if data storage bs:ctx _.state.on_enter run function bs.fsm:run/run_command_local with storage bs:ctx _ + +# We register the on_tick command, for that we create an object with the context (i.e., the UUID of the entity) and the command +execute if data storage bs:ctx _.state.on_tick run data modify storage bs:ctx _.tmp set value {} +execute if data storage bs:ctx _.state.on_tick run data modify storage bs:ctx _.tmp.context set from storage bs:ctx _.context +execute if data storage bs:ctx _.state.on_tick run data modify storage bs:ctx _.tmp.command set from storage bs:ctx _.state.on_tick +$execute if data storage bs:ctx _.state.on_tick run data modify storage bs:ctx _.tmp.instance_name set value "$(instance_name)" +execute if data storage bs:ctx _.state.on_tick run data modify storage bs:data fsm.ticks append from storage bs:ctx _.tmp +# If this is the only command on the ticks list, we start the tick loop +execute if data storage bs:data fsm.ticks[0] unless data storage bs:data fsm.ticks[1] run schedule function bs.fsm:run/tick 1t diff --git a/modules/bs.fsm/data/bs.fsm/function/run/evaluate_command_transition.mcfunction b/modules/bs.fsm/data/bs.fsm/function/run/evaluate_command_transition.mcfunction new file mode 100644 index 000000000..e10b975e1 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/run/evaluate_command_transition.mcfunction @@ -0,0 +1,19 @@ +data modify storage bs:ctx _.tmp set value {} +data modify storage bs:ctx _.tmp.command set from storage bs:data fsm.listened_transitions[0].condition.wait + +# If this is a global command transition, we run the command and according to the result, we switch to the target state +execute if data storage bs:data fsm.listened_transitions[0].global \ + store success score #s bs.ctx \ + run function bs.fsm:run/run_command_global with storage bs:ctx _.tmp +execute if score #s bs.ctx matches 1 run function bs.fsm:run/switch_state with storage bs:data fsm.listened_transitions[0] +execute if score #s bs.ctx matches 1 run return 1 +execute if data storage bs:data fsm.listened_transitions[0].global run return fail + +# If this is a local command transition, we run the command and according to the result, we switch to the target state +data modify storage bs:ctx _.tmp.context set from storage bs:data fsm.listened_transitions[0].context +execute store success score #s bs.ctx \ + run function bs.fsm:run/run_command_local with storage bs:ctx _.tmp +execute if score #s bs.ctx matches 1 run function bs.fsm:run/switch_state with storage bs:data fsm.listened_transitions[0] +execute if score #s bs.ctx matches 1 run return 1 + +return fail diff --git a/modules/bs.fsm/data/bs.fsm/function/run/evaluate_delay_transition.mcfunction b/modules/bs.fsm/data/bs.fsm/function/run/evaluate_delay_transition.mcfunction new file mode 100644 index 000000000..2d9493e90 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/run/evaluate_delay_transition.mcfunction @@ -0,0 +1,8 @@ +# We check if the delay has passed, if so, we switch to the target state. Otherwise, we decrease the delay +execute store result score #d bs.ctx run data get storage bs:data fsm.listened_transitions[0].condition.wait +execute if score #d bs.ctx matches ..0 run function bs.fsm:run/switch_state with storage bs:data fsm.listened_transitions[0] +execute if score #d bs.ctx matches ..0 run return 1 +execute if score #d bs.ctx matches 1.. run scoreboard players remove #d bs.ctx 1 +execute store result storage bs:data fsm.listened_transitions[0].condition.wait int 1 run scoreboard players get #d bs.ctx + +return fail diff --git a/modules/bs.fsm/data/bs.fsm/function/run/evaluate_global_predicate.mcfunction b/modules/bs.fsm/data/bs.fsm/function/run/evaluate_global_predicate.mcfunction new file mode 100644 index 000000000..845fab051 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/run/evaluate_global_predicate.mcfunction @@ -0,0 +1 @@ +$return run execute if predicate $(predicate) diff --git a/modules/bs.fsm/data/bs.fsm/function/run/evaluate_local_predicate.mcfunction b/modules/bs.fsm/data/bs.fsm/function/run/evaluate_local_predicate.mcfunction new file mode 100644 index 000000000..4e98979cc --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/run/evaluate_local_predicate.mcfunction @@ -0,0 +1 @@ +$return run execute as $(context) at $(context) if predicate $(predicate) diff --git a/modules/bs.fsm/data/bs.fsm/function/run/evaluate_predicate_transition.mcfunction b/modules/bs.fsm/data/bs.fsm/function/run/evaluate_predicate_transition.mcfunction new file mode 100644 index 000000000..5b3e130fb --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/run/evaluate_predicate_transition.mcfunction @@ -0,0 +1,18 @@ +data modify storage bs:ctx _.transition set from storage bs:data fsm.listened_transitions[0] +data modify storage bs:ctx _.transition.predicate set from storage bs:ctx _.transition.condition.wait + +# If this is a global predicate transition, we evaluate the predicate and according to the result, we switch to the target state +execute if data storage bs:data fsm.listened_transitions[0].global \ + store success score #s bs.ctx \ + run function bs.fsm:run/evaluate_global_predicate with storage bs:ctx _.transition +execute if score #s bs.ctx matches 1 run function bs.fsm:run/switch_state with storage bs:data fsm.listened_transitions[0] +execute if score #s bs.ctx matches 1 run return 1 +execute if data storage bs:data fsm.listened_transitions[0].global run return fail + +# If this is a local predicate transition, we evaluate the predicate and according to the result, we switch to the target state +execute store success score #s bs.ctx \ + run function bs.fsm:run/evaluate_local_predicate with storage bs:ctx _.transition +execute if score #s bs.ctx matches 1 run function bs.fsm:run/switch_state with storage bs:data fsm.listened_transitions[0] +execute if score #s bs.ctx matches 1 run return 1 + +return fail diff --git a/modules/bs.fsm/data/bs.fsm/function/run/evaluate_transitions.mcfunction b/modules/bs.fsm/data/bs.fsm/function/run/evaluate_transitions.mcfunction new file mode 100644 index 000000000..74e07132a --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/run/evaluate_transitions.mcfunction @@ -0,0 +1,14 @@ +# Input: +# - Storage bs:data fsm.listened_transitions: {source: , context: , command: , instance_name: , to: , condition: { type: "delay" | "predicate" | "function", wait: string }}[] + +# Unlike the tick function, we will not use a counter to know when to stop the recursion +# Indeed, in the tick function we don't unregister the tick commands, so we cannot loose the count +# In this function, we will remove the transitions that have been evaluated, as well as all the transitions for this instance +# As we don't know how many transitions will be removed, we prefer to use a flag on transitions to directly know from the current transition if we need to continue the recursion + +function bs.fsm:run/evaluate_transitions_rec +# We reset the checked flag for the next evaluation +data remove storage bs:data fsm.listened_transitions[].checked + +# If we still have transitions to evaluate, we schedule again +execute if data storage bs:data fsm.listened_transitions[0] run schedule function bs.fsm:run/evaluate_transitions 1t diff --git a/modules/bs.fsm/data/bs.fsm/function/run/evaluate_transitions_rec.mcfunction b/modules/bs.fsm/data/bs.fsm/function/run/evaluate_transitions_rec.mcfunction new file mode 100644 index 000000000..9b61d47c0 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/run/evaluate_transitions_rec.mcfunction @@ -0,0 +1,38 @@ +# Input: +# - Storage bs:data fsm.listened_transitions: {source: , context: , command: , instance_name: , to: , condition: { type: "delay" | "predicate" | "function", wait: string }}[] + +# Terminal cases: +# The list is empty +execute unless data storage bs:data fsm.listened_transitions[0] run return 1 +# We already checked the current transition +execute if data storage bs:data fsm.listened_transitions[0].checked run return 1 + + +scoreboard players set #s bs.ctx -1 +# If this is a local transition, we check if the entity exists, otherwise we remove the transition from the list and we continue the recursion +execute unless data storage bs:data fsm.listened_transitions[0].global \ + store success score #s bs.ctx \ + run function bs.fsm:run/check_entity with storage bs:data fsm.listened_transitions[0] +execute if score #s bs.ctx matches 0 run data remove storage bs:data fsm.listened_transitions[0] +execute if score #s bs.ctx matches 0 run return run function bs.fsm:run/evaluate_transitions_rec + +scoreboard players set #s bs.ctx 0 + +# Delayed transition +execute store success score #s bs.ctx if data storage bs:data fsm.listened_transitions[0].condition{type: "delay"} run function bs.fsm:run/evaluate_delay_transition + +# Command transition +execute if score #s bs.ctx matches 0 if data storage bs:data fsm.listened_transitions[0].condition{type: "command"} store success score #s bs.ctx run function bs.fsm:run/evaluate_command_transition + +# Predicate transitions +execute if score #s bs.ctx matches 0 if data storage bs:data fsm.listened_transitions[0].condition{type: "predicate"} store success score #s bs.ctx run function bs.fsm:run/evaluate_predicate_transition + +# If we didn't evaluated the current transition, we set the transition as checked and we shift the list +execute if score #s bs.ctx matches 0 run data modify storage bs:data fsm.listened_transitions[0].checked set value true +execute if score #s bs.ctx matches 0 run data modify storage bs:data fsm.listened_transitions append from storage bs:data fsm.listened_transitions[0] +execute if score #s bs.ctx matches 0 run data remove storage bs:data fsm.listened_transitions[0] + +# If the current transition has been evaluated, we don't need to shift the list since the transition has been removed by the switch_state function + +# We continue the recursion +return run function bs.fsm:run/evaluate_transitions_rec diff --git a/modules/bs.fsm/data/bs.fsm/function/run/run_command_global.mcfunction b/modules/bs.fsm/data/bs.fsm/function/run/run_command_global.mcfunction new file mode 100644 index 000000000..d056e421a --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/run/run_command_global.mcfunction @@ -0,0 +1 @@ +$return run $(command) diff --git a/modules/bs.fsm/data/bs.fsm/function/run/run_command_local.mcfunction b/modules/bs.fsm/data/bs.fsm/function/run/run_command_local.mcfunction new file mode 100644 index 000000000..f0f218d07 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/run/run_command_local.mcfunction @@ -0,0 +1 @@ +$return run execute as $(context) at $(context) run $(command) diff --git a/modules/bs.fsm/data/bs.fsm/function/run/switch_state.mcfunction b/modules/bs.fsm/data/bs.fsm/function/run/switch_state.mcfunction new file mode 100644 index 000000000..56e9d1fa4 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/run/switch_state.mcfunction @@ -0,0 +1,31 @@ +# Input: +# - Macro: {source: , context: , instance_name: , to: } + +# We use fsm instead of _ to avoid conflicts with lambdas +$data modify storage bs:ctx fsm set value { context: "$(context)", instance_name: "$(instance_name)", state_name: "$(to)" } + +# We unregister the tick command +$data remove storage bs:data fsm.ticks[{instance_name: "$(instance_name)", context: "$(context)"}] + +# We remove the listened transitions for this instance from the list +$data remove storage bs:data fsm.listened_transitions[{instance_name: "$(instance_name)", context: "$(context)"}] + +# We trigger the on_exit command +# Global context +$execute if data storage bs:ctx fsm{context: 'global'} run data modify storage bs:ctx fsm.command set from storage bs:data fsm.running_instances.'$(instance_name)'.states[{name: "$(source)"}].on_exit +execute if data storage bs:ctx fsm{context: 'global'} run function bs.fsm:run/run_command_global with storage bs:ctx fsm +# Local context +$execute unless data storage bs:ctx fsm{context: 'global'} run data modify storage bs:ctx fsm.command set from entity $(context) data.bs:fsm.running_instances.'$(instance_name)'.states[{name: "$(source)"}].on_exit +execute unless data storage bs:ctx fsm{context: 'global'} run function bs.fsm:run/run_command_local with storage bs:ctx fsm + +# We remove the current flag from the previous state +# Global context +$execute if data storage bs:ctx fsm{context: 'global'} run data remove storage bs:data fsm.running_instances.'$(instance_name)'.states[{name: "$(source)"}].current +# Local context +$execute unless data storage bs:ctx fsm{context: 'global'} run data remove entity $(context) data.bs:fsm.running_instances.'$(instance_name)'.states[{name: "$(source)"}].current + +# We enter in the new state +# Global context +execute if data storage bs:ctx fsm{context: 'global'} run function bs.fsm:run/enter_state_global with storage bs:ctx fsm +# Local context +execute unless data storage bs:ctx fsm{context: 'global'} run function bs.fsm:run/enter_state_local with storage bs:ctx fsm diff --git a/modules/bs.fsm/data/bs.fsm/function/run/tick.mcfunction b/modules/bs.fsm/data/bs.fsm/function/run/tick.mcfunction new file mode 100644 index 000000000..59eb61329 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/run/tick.mcfunction @@ -0,0 +1,5 @@ +execute store result score #c bs.ctx run data get storage bs:data fsm.ticks +function bs.fsm:run/tick_rec + +# If we still have functions to run next tick, we schedule again +execute if data storage bs:data fsm.ticks[0] run schedule function bs.fsm:run/tick 1t diff --git a/modules/bs.fsm/data/bs.fsm/function/run/tick_rec.mcfunction b/modules/bs.fsm/data/bs.fsm/function/run/tick_rec.mcfunction new file mode 100644 index 000000000..c1b121a63 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/run/tick_rec.mcfunction @@ -0,0 +1,30 @@ +# Input: +# - Storage bs:data fsm.ticks: {context: , command: } | { command: } + +# Terminal cases: +# The list is empty +execute unless data storage bs:data fsm.ticks[0] run return 1 +# We already ran all the commands +execute if score #c bs.ctx matches ..0 run return 1 + +# We reduce the counter +scoreboard players remove #c bs.ctx 1 + +# If the command is global, we run it +execute if data storage bs:data fsm.ticks[0].global run function bs.fsm:run/run_command_global with storage bs:data fsm.ticks[0] + +scoreboard players set #s bs.ctx -1 +# Else, we first check if the entity exists, otherwise we remove the command from the tick list +execute unless data storage bs:data fsm.ticks[0].global store success score #s bs.ctx run function bs.fsm:run/check_entity with storage bs:data fsm.ticks[0] +execute if score #s bs.ctx matches 0 run data remove storage bs:data fsm.ticks[0] + +# We run the command only if the entity exists +execute if score #s bs.ctx matches 1 run function bs.fsm:run/run_command_local with storage bs:data fsm.ticks[0] + +# If the entity exists, or if we are on a global command, we shift the tick list +# We do not shift the tick list if the entity does not exist since we already removed the command +execute unless score #s bs.ctx matches 0 run data modify storage bs:data fsm.ticks append from storage bs:data fsm.ticks[0] +execute unless score #s bs.ctx matches 0 run data remove storage bs:data fsm.ticks[0] + +# Recursion to continue the commands execution +function bs.fsm:run/tick_rec diff --git a/modules/bs.fsm/data/bs.fsm/function/start.mcfunction b/modules/bs.fsm/data/bs.fsm/function/start.mcfunction new file mode 100644 index 000000000..5fe3f3788 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/start.mcfunction @@ -0,0 +1,28 @@ +# Input: +# Macro: fsm_name: string +# Macro: instance_name: string + +$data modify storage bs:ctx _ set value { fsm_name: $(fsm_name), instance_name: $(instance_name) } + +# We check if the instance already exists +$execute if score #s bs.ctx matches 0 store success score #e bs.ctx if data storage bs:data fsm.running_instances.'$(instance_name)' +execute if score #e bs.ctx matches 1 run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:start", \ + tag: "start", \ + message: [{text: "An instance with the name '"}, {nbt: "_.instance_name", storage: "bs:ctx"}, {text: "' already exists in the global context."}] \ +} +execute if score #e bs.ctx matches 1 run return fail + +$execute store success score #s bs.ctx run data modify storage bs:data fsm.running_instances.'$(instance_name)' set from storage bs:data fsm.fsm.'$(fsm_name)' +execute if score #s bs.ctx matches 0 run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:start", \ + tag: "start", \ + message: [{text: "The FSM '"}, {nbt: "_.fsm_name", storage: "bs:ctx"}, {text: "' does not exist."}] \ +} +execute if score #s bs.ctx matches 0 run return fail + +# If the FSM is global, we enter the initial state +$data modify storage bs:ctx _.state_name set from storage bs:data fsm.fsm.'$(fsm_name)'.initial +function bs.fsm:run/enter_state_global with storage bs:ctx _ diff --git a/modules/bs.fsm/data/bs.fsm/function/start_as.mcfunction b/modules/bs.fsm/data/bs.fsm/function/start_as.mcfunction new file mode 100644 index 000000000..f1689c982 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/start_as.mcfunction @@ -0,0 +1,37 @@ +# Input: +# Macro: fsm_name: string +# Macro: instance_name: string + +$data modify storage bs:ctx _ set value { fsm_name: $(fsm_name), instance_name: $(instance_name) } + +# We get the String UUID of the entity +tag @s add bs.fsm.entity +data modify entity B5-0-0-0-2 text set value { selector: "@n[tag=bs.fsm.entity]" } +data modify storage bs:ctx _.context set from entity B5-0-0-0-2 text.insertion + +# We check if the instance already exists +$execute store success score #s bs.ctx if data entity @n[tag=bs.fsm.entity] data.bs:fsm.running_instances.'$(instance_name)' +execute if score #s bs.ctx matches 1 run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:start_as", \ + tag: "start_as", \ + message: [{text: "An instance with the name '"}, {nbt: "_.instance_name", storage: "bs:ctx"}, {text: "' already exists for entity '"}, {nbt: "_.context", storage: "bs:ctx"}, {text: "'."}] \ +} +execute if score #s bs.ctx matches 1 run tag @s remove bs.fsm.entity +execute if score #s bs.ctx matches 1 run return fail + +# We create the instance of the FSM for the entity +$execute store success score #s bs.ctx run data modify entity @n[tag=bs.fsm.entity] data.bs:fsm.running_instances.'$(instance_name)' set from storage bs:data fsm.fsm.'$(fsm_name)' +# If the instance was not created, we log an error since the FSM does not exist +execute if score #s bs.ctx matches 0 run function #bs.log:error { \ + namespace: "bs.fsm", \ + path: "#bs.fsm:start_as", \ + tag: "start_as", \ + message: [{text: "The FSM '"}, {nbt: "_.fsm_name", storage: "bs:ctx"}, {text: "' does not exist."}] \ +} +execute if score #s bs.ctx matches 0 run tag @s remove bs.fsm.entity +execute if score #s bs.ctx matches 0 run return fail + +# We enter the initial state +$data modify storage bs:ctx _.state_name set from storage bs:data fsm.fsm.'$(fsm_name)'.initial +function bs.fsm:run/enter_state_local with storage bs:ctx _ diff --git a/modules/bs.fsm/data/bs.fsm/function/test.mcfunction b/modules/bs.fsm/data/bs.fsm/function/test.mcfunction new file mode 100644 index 000000000..356b40f72 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/test.mcfunction @@ -0,0 +1,78 @@ +execute unless entity B5-0-0-0-9 run summon marker ~ ~ ~ {Tags:["bs.entity", "bs.fsm.test"], UUID:[I;181,0,0,9]} + +data modify entity B5-0-0-0-9 data set value {} +data modify storage bs:data fsm set value { fsm: {}, running_instances: {}, listened_transitions: [], ticks: [] } + +# Create a traffic light FSM +function bs.fsm:new { \ + name: "traffic_light", \ + fsm: { \ + initial: "red", \ + states: [ \ + { \ + name: "red", \ + on_enter: "tellraw @a [{\"text\":\"⬤\",\"color\":\"red\"},{\"text\":\" Red light - Stop!\"}]", \ + on_tick: "particle minecraft:block{block_state:{Name:\"red_concrete\"}} ~ ~2 ~ 0.5 0.5 0.5 0 10", \ + transitions: [ \ + { \ + name: "to_green", \ + condition: { type: "delay", wait: 100 }, \ + to: "green" \ + }, \ + { \ + name: "to_off", \ + condition: "manual", \ + to: "off" \ + } \ + ] \ + }, \ + { \ + name: "green", \ + on_enter: "tellraw @a [{\"text\":\"⬤\",\"color\":\"green\"},{\"text\":\" Green light - Go!\"}]", \ + on_tick: "particle minecraft:block{block_state:{Name:\"green_concrete\"}} ~ ~2 ~ 0.5 0.5 0.5 0 10", \ + transitions: [ \ + { \ + name: "to_orange", \ + condition: { type: "delay", wait: 100 }, \ + to: "orange" \ + }, \ + { \ + name: "to_off", \ + condition: "manual", \ + to: "off" \ + } \ + ] \ + }, \ + { \ + name: "orange", \ + on_enter: "tellraw @a [{\"text\":\"⬤\",\"color\":\"gold\"},{\"text\":\" Orange light - Prepare to stop!\"}]", \ + on_tick: "particle minecraft:block{block_state:{Name:\"yellow_concrete\"}} ~ ~2 ~ 0.5 0.5 0.5 0 10", \ + on_exit: "tellraw @a [{\"text\":\" Reset cycle…\"}]", \ + transitions: [ \ + { \ + name: "to_red", \ + condition: { type: "delay", wait: 100 }, \ + to: "red" \ + }, \ + { \ + name: "to_off", \ + condition: "manual", \ + to: "off" \ + } \ + ] \ + }, \ + { \ + name: "off", \ + final: true, \ + on_enter: "tellraw @a [{\"text\":\"⬤\",\"color\":\"black\"},{\"text\":\" Off light - Be careful!\"}]", \ + on_tick: "particle minecraft:block{block_state:{Name:\"black_concrete\"}} ~ ~2 ~ 0.5 0.5 0.5 0 10", \ + } \ + ] \ + } \ +} + +# Start the traffic light FSM +execute as B5-0-0-0-9 run function bs.fsm:start_as { \ + fsm_name: "traffic_light", \ + instance_name: "main_traffic_light" \ +} diff --git a/modules/bs.fsm/data/bs.fsm/function/test2.mcfunction b/modules/bs.fsm/data/bs.fsm/function/test2.mcfunction new file mode 100644 index 000000000..e611dfe4d --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/test2.mcfunction @@ -0,0 +1,78 @@ +execute unless entity B5-0-0-0-9 run summon marker ~ ~ ~ {Tags:["bs.entity", "bs.fsm.test"], UUID:[I;181,0,0,9]} + +data modify entity B5-0-0-0-9 data set value {} +data modify storage bs:data fsm set value { fsm: {}, running_instances: {}, listened_transitions: [], ticks: [] } + +# Create a traffic light FSM +function bs.fsm:new { \ + name: "traffic_light", \ + fsm: { \ + initial: "red", \ + states: [ \ + { \ + name: "red", \ + on_enter: "tellraw @a [{\"text\":\"⬤\",\"color\":\"red\"},{\"text\":\" Red light - Stop!\"}]", \ + on_tick: "particle minecraft:block{block_state:{Name:\"red_concrete\"}} ~ ~2 ~ 0.5 0.5 0.5 0 10", \ + transitions: [ \ + { \ + name: "to_green", \ + condition: { type: "command", wait: "execute if entity @e[tag=bs.fsm.test2]" }, \ + to: "green" \ + }, \ + { \ + name: "to_off", \ + condition: "manual", \ + to: "off" \ + } \ + ] \ + }, \ + { \ + name: "green", \ + on_enter: "tellraw @a [{\"text\":\"⬤\",\"color\":\"green\"},{\"text\":\" Green light - Go!\"}]", \ + on_tick: "particle minecraft:block{block_state:{Name:\"green_concrete\"}} ~ ~2 ~ 0.5 0.5 0.5 0 10", \ + transitions: [ \ + { \ + name: "to_orange", \ + condition: { type: "delay", wait: 100 }, \ + to: "orange" \ + }, \ + { \ + name: "to_off", \ + condition: "manual", \ + to: "off" \ + } \ + ] \ + }, \ + { \ + name: "orange", \ + on_enter: "tellraw @a [{\"text\":\"⬤\",\"color\":\"gold\"},{\"text\":\" Orange light - Prepare to stop!\"}]", \ + on_tick: "particle minecraft:block{block_state:{Name:\"yellow_concrete\"}} ~ ~2 ~ 0.5 0.5 0.5 0 10", \ + on_exit: "tellraw @a [{\"text\":\" Reset cycle…\"}]", \ + transitions: [ \ + { \ + name: "to_red", \ + condition: { type: "delay", wait: 100 }, \ + to: "red" \ + }, \ + { \ + name: "to_off", \ + condition: "manual", \ + to: "off" \ + } \ + ] \ + }, \ + { \ + name: "off", \ + final: true, \ + on_enter: "tellraw @a [{\"text\":\"⬤\",\"color\":\"black\"},{\"text\":\" Off light - Be careful!\"}]", \ + on_tick: "particle minecraft:block{block_state:{Name:\"black_concrete\"}} ~ ~2 ~ 0.5 0.5 0.5 0 10", \ + } \ + ] \ + } \ +} + +# Start the traffic light FSM +execute as B5-0-0-0-9 run function bs.fsm:start_as { \ + fsm_name: "traffic_light", \ + instance_name: "main_traffic_light" \ +} diff --git a/modules/bs.fsm/data/bs.fsm/function/test3.mcfunction b/modules/bs.fsm/data/bs.fsm/function/test3.mcfunction new file mode 100644 index 000000000..a685472f6 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/test3.mcfunction @@ -0,0 +1,78 @@ +execute unless entity B5-0-0-0-9 run summon marker ~ ~ ~ {Tags:["bs.entity", "bs.fsm.test"], UUID:[I;181,0,0,9]} + +data modify entity B5-0-0-0-9 data set value {} +data modify storage bs:data fsm set value { fsm: {}, running_instances: {}, listened_transitions: [], ticks: [] } + +# Create a traffic light FSM +function bs.fsm:new { \ + name: "traffic_light", \ + fsm: { \ + initial: "red", \ + states: [ \ + { \ + name: "red", \ + on_enter: "tellraw @a [{\"text\":\"⬤\",\"color\":\"red\"},{\"text\":\" Red light - Stop!\"}]", \ + on_tick: "particle minecraft:block{block_state:{Name:\"red_concrete\"}} ~ ~2 ~ 0.5 0.5 0.5 0 10", \ + transitions: [ \ + { \ + name: "to_green", \ + condition: { type: "predicate", wait: "bs.fsm:test" }, \ + to: "green" \ + }, \ + { \ + name: "to_off", \ + condition: "manual", \ + to: "off" \ + } \ + ] \ + }, \ + { \ + name: "green", \ + on_enter: "tellraw @a [{\"text\":\"⬤\",\"color\":\"green\"},{\"text\":\" Green light - Go!\"}]", \ + on_tick: "particle minecraft:block{block_state:{Name:\"green_concrete\"}} ~ ~2 ~ 0.5 0.5 0.5 0 10", \ + transitions: [ \ + { \ + name: "to_orange", \ + condition: { type: "delay", wait: 100 }, \ + to: "orange" \ + }, \ + { \ + name: "to_off", \ + condition: "manual", \ + to: "off" \ + } \ + ] \ + }, \ + { \ + name: "orange", \ + on_enter: "tellraw @a [{\"text\":\"⬤\",\"color\":\"gold\"},{\"text\":\" Orange light - Prepare to stop!\"}]", \ + on_tick: "particle minecraft:block{block_state:{Name:\"yellow_concrete\"}} ~ ~2 ~ 0.5 0.5 0.5 0 10", \ + on_exit: "tellraw @a [{\"text\":\" Reset cycle…\"}]", \ + transitions: [ \ + { \ + name: "to_red", \ + condition: { type: "delay", wait: 100 }, \ + to: "red" \ + }, \ + { \ + name: "to_off", \ + condition: "manual", \ + to: "off" \ + } \ + ] \ + }, \ + { \ + name: "off", \ + final: true, \ + on_enter: "tellraw @a [{\"text\":\"⬤\",\"color\":\"black\"},{\"text\":\" Off light - Be careful!\"}]", \ + on_tick: "particle minecraft:block{block_state:{Name:\"black_concrete\"}} ~ ~2 ~ 0.5 0.5 0.5 0 10", \ + } \ + ] \ + } \ +} + +# Start the traffic light FSM +execute as B5-0-0-0-9 run function bs.fsm:start_as { \ + fsm_name: "traffic_light", \ + instance_name: "main_traffic_light" \ +} diff --git a/modules/bs.fsm/data/bs.fsm/function/todo.md b/modules/bs.fsm/data/bs.fsm/function/todo.md new file mode 100644 index 000000000..89407234e --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/function/todo.md @@ -0,0 +1,3 @@ +# TODO +- [ ] Change the way to check if a string is inside a list +- [ ] Fix the bug where the tick list is empty after a restart of the world: issue seems to come from the fact that during one tick after a restart, the entity does not exist yet (unloaded chunk?) to the tick function is removed from the list diff --git a/modules/bs.fsm/data/bs.fsm/predicate/test.json b/modules/bs.fsm/data/bs.fsm/predicate/test.json new file mode 100644 index 000000000..b53de3393 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/predicate/test.json @@ -0,0 +1,8 @@ +{ + "condition": "minecraft:time_check", + "value": { + "min": 0, + "max": 12000 + }, + "period": 24000 +} diff --git a/modules/bs.fsm/data/bs.fsm/tags/function/new.json b/modules/bs.fsm/data/bs.fsm/tags/function/new.json new file mode 100644 index 000000000..44fffeb93 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/tags/function/new.json @@ -0,0 +1,20 @@ +{ + "__bookshelf__": { + "feature": true, + "documentation": "https://docs.mcbookshelf.dev/en/latest/modules/fsm.html#new", + "authors": [ + "theogiraudet" + ], + "created": { + "date": "2025/07/16", + "minecraft_version": "1.21.7" + }, + "updated": { + "date": "2025/07/16", + "minecraft_version": "1.21.7" + } + }, + "values": [ + "bs.fsm:new" + ] +} diff --git a/modules/bs.fsm/data/bs.fsm/tags/function/start.json b/modules/bs.fsm/data/bs.fsm/tags/function/start.json new file mode 100644 index 000000000..e4b9f6b12 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/tags/function/start.json @@ -0,0 +1,20 @@ +{ + "__bookshelf__": { + "feature": true, + "documentation": "https://docs.mcbookshelf.dev/en/latest/modules/fsm.html#start", + "authors": [ + "theogiraudet" + ], + "created": { + "date": "2025/07/20", + "minecraft_version": "1.21.8" + }, + "updated": { + "date": "2025/07/20", + "minecraft_version": "1.21.8" + } + }, + "values": [ + "bs.fsm:start" + ] +} diff --git a/modules/bs.fsm/data/bs.fsm/tags/function/start_as.json b/modules/bs.fsm/data/bs.fsm/tags/function/start_as.json new file mode 100644 index 000000000..62c59ada4 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/tags/function/start_as.json @@ -0,0 +1,20 @@ +{ + "__bookshelf__": { + "feature": true, + "documentation": "https://docs.mcbookshelf.dev/en/latest/modules/fsm.html#start-as", + "authors": [ + "theogiraudet" + ], + "created": { + "date": "2025/07/20", + "minecraft_version": "1.21.8" + }, + "updated": { + "date": "2025/07/20", + "minecraft_version": "1.21.8" + } + }, + "values": [ + "bs.fsm:start_as" + ] +} diff --git a/modules/bs.fsm/data/bs.fsm/test/new.mcfunction b/modules/bs.fsm/data/bs.fsm/test/new.mcfunction new file mode 100644 index 000000000..7dbeb3754 --- /dev/null +++ b/modules/bs.fsm/data/bs.fsm/test/new.mcfunction @@ -0,0 +1,322 @@ +# ------------------------------------------------------------------------------------------------------------ +# Copyright (c) 2025 Gunivers +# +# This file is part of the Bookshelf project (https://github.com/mcbookshelf/bookshelf). +# +# This source code is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Conditions: +# - You may use this file in compliance with the MPL v2.0 +# - Any modifications must be documented and disclosed under the same license +# +# For more details, refer to the MPL v2.0. +# ------------------------------------------------------------------------------------------------------------ + +## === SETUP === + +# Clear any existing FSM data +data remove storage bs:data fsm.fsm + +## === VALID FSM CREATION === + +# Test 1: Create a simple valid FSM +function #bs.fsm:new { \ + name: "test_fsm", \ + fsm: { \ + initial: "idle", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + transitions: [ \ + { \ + name: "start", \ + condition: "manual", \ + to: "active" \ + } \ + ] \ + }, \ + { \ + name: "active", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + final: true \ + } \ + ] \ + } \ +} +execute unless data storage bs:data fsm.fsm.test_fsm run fail "Failed to create a valid FSM" + +## === DUPLICATE FSM ERROR === + +# Test 2: Try to create the same FSM again (should fail) +execute store success score #s bs.ctx run function #bs.fsm:new { \ + name: "test_fsm", \ + fsm: { \ + initial: "idle", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit" \ + } \ + ] \ + } \ +} +execute unless score #s bs.ctx matches 0 run fail "Failed to return an error when creating a duplicate FSM" + +## === INVALID FSM - MISSING INITIAL STATE === + +# Test 3: Create FSM with missing initial state +execute store success score #s bs.ctx run function #bs.fsm:new { \ + name: "invalid_fsm_1", \ + fsm: { \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit" \ + } \ + ] \ + } \ +} +execute unless score #s bs.ctx matches 0 run fail "Failed to return an error when FSM has no initial state" + +## === INVALID FSM - INITIAL STATE NOT FOUND === + +# Test 4: Create FSM with initial state that doesn't exist +execute store success score #s bs.ctx run function #bs.fsm:new { \ + name: "invalid_fsm_2", \ + fsm: { \ + initial: "nonexistent", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit" \ + } \ + ] \ + } \ +} +execute unless score #s bs.ctx matches 0 run fail "Failed to return an error when initial state doesn't exist" + +## === INVALID FSM - DUPLICATE STATE NAMES === + +# Test 5: Create FSM with duplicate state names +execute store success score #s bs.ctx run function #bs.fsm:new { \ + name: "invalid_fsm_3", \ + fsm: { \ + initial: "idle", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit" \ + }, \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit" \ + } \ + ] \ + } \ +} +execute unless score #s bs.ctx matches 0 run fail "Failed to return an error when FSM has duplicate state names" + +## === INVALID FSM - TRANSITION TO NONEXISTENT STATE === + +# Test 6: Create FSM with transition to non-existent state +execute store success score #s bs.ctx run function #bs.fsm:new { \ + name: "invalid_fsm_4", \ + fsm: { \ + initial: "idle", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + transitions: [ \ + { \ + name: "start", \ + condition: "manual", \ + to: "nonexistent" \ + }, \ + { \ + name: "start", \ + condition: "manual", \ + to: "active" \ + } \ + ] \ + }, \ + { \ + name: "active", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + final: true \ + } \ + ] \ + } \ +} +execute unless score #s bs.ctx matches 0 run fail "Failed to return an error when transition points to non-existent state" + +## === VALID FSM WITH COMPLEX TRANSITIONS === + +# Test 7: Create a valid FSM with different transition types +function #bs.fsm:new { \ + name: "complex_fsm", \ + fsm: { \ + initial: "start", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "start", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + transitions: [ \ + { \ + name: "manual_transition", \ + condition: "manual", \ + to: "waiting" \ + }, \ + { \ + name: "predicate_transition", \ + condition: { type: "predicate", wait: "bs.fsm:test/condition" }, \ + to: "processing" \ + }, \ + { \ + name: "function_transition", \ + condition: { type: "command", wait: "bs.fsm:test/function" }, \ + to: "processing" \ + }, \ + { \ + name: "hook_transition", \ + condition: { type: "hook", wait: "bs.fsm:test/hook" }, \ + to: "processing" \ + }, \ + { \ + name: "delay_transition", \ + condition: { type: "delay", wait: "20t" }, \ + to: "processing" \ + } \ + ] \ + }, \ + { \ + name: "waiting", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + transitions: [ \ + { \ + name: "manual_transition", \ + condition: "manual", \ + to: "processing" \ + } \ + ] \ + }, \ + { \ + name: "processing", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + final: true \ + } \ + ] \ + } \ +} +execute unless data storage bs:data fsm.fsm.complex_fsm run fail "Failed to create a complex FSM with various transition types" + +## === VALID FSM WITH MINIMAL CONFIGURATION === + +# Test 8: Create a minimal valid FSM +function #bs.fsm:new { \ + name: "minimal_fsm", \ + fsm: { \ + initial: "state1", \ + states: [ \ + { \ + name: "state1", \ + final: true \ + } \ + ] \ + } \ +} +execute unless data storage bs:data fsm.fsm.minimal_fsm run fail "Failed to create a minimal FSM" + +## === INVALID FSM - UNREACHABLE FINAL STATE === + +# Test 9: Create FSM with unreachable final state +execute store success score #s bs.ctx run function #bs.fsm:new { \ + name: "invalid_fsm_5", \ + fsm: { \ + initial: "idle", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + final: false \ + }, \ + { \ + name: "final_state", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + final: true \ + } \ + ] \ + } \ +} +execute unless score #s bs.ctx matches 0 run fail "Failed to return an error when FSM has unreachable final state" + +## === INVALID FSM - NO FINAL STATE === + +# Test 10: Create FSM with no final state +execute store success score #s bs.ctx run function #bs.fsm:new { \ + name: "invalid_fsm_6", \ + fsm: { \ + initial: "idle", \ + on_cancel: "bs.fsm:test/cancel", \ + states: [ \ + { \ + name: "idle", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + final: false \ + }, \ + { \ + name: "active", \ + on_tick: "bs.fsm:test/tick", \ + on_enter: "bs.fsm:test/enter", \ + on_exit: "bs.fsm:test/exit", \ + final: false \ + } \ + ] \ + } \ +} +execute unless score #s bs.ctx matches 0 run fail "Failed to return an error when FSM has no final state" + +## === CLEANUP === + +# Clean up test data +data remove storage bs:data fsm.fsm diff --git a/modules/bs.fsm/module.json b/modules/bs.fsm/module.json new file mode 100644 index 000000000..95683eedb --- /dev/null +++ b/modules/bs.fsm/module.json @@ -0,0 +1,17 @@ +{ + "extend": "../config.json", + "data_pack": { + "name": "bs.fsm", + "load": "." + }, + "meta": { + "name": "Final State Machine", + "slug": "bookshelf-fsm", + "description": "Bookshelf final state machine module.", + "documentation": "https://docs.mcbookshelf.dev/en/latest/modules/fsm.html", + "tags": ["runtime"], + "dependencies": [ + "bs.random" + ] + } +} diff --git a/pyproject.toml b/pyproject.toml index 12fc589d2..fcc157034 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ docs = [ "sphinx-intl>=2.3.1", "sphinx-minecraft>=1.0.5", "sphinx-togglebutton>=0.3.2", + "sphinxcontrib.mermaid>=1.0.0", "sphinx>=8.2.3", ] diff --git a/uv.lock b/uv.lock index bcc1ebc28..611453b48 100644 --- a/uv.lock +++ b/uv.lock @@ -367,6 +367,7 @@ docs = [ { name = "sphinx-intl" }, { name = "sphinx-minecraft" }, { name = "sphinx-togglebutton" }, + { name = "sphinxcontrib-mermaid" }, ] [package.metadata] @@ -398,6 +399,7 @@ docs = [ { name = "sphinx-intl", specifier = ">=2.3.1" }, { name = "sphinx-minecraft", specifier = ">=1.0.5" }, { name = "sphinx-togglebutton", specifier = ">=0.3.2" }, + { name = "sphinxcontrib-mermaid", specifier = ">=1.0.0" }, ] [[package]] @@ -941,6 +943,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, ] +[[package]] +name = "sphinxcontrib-mermaid" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/69/bf039237ad260073e8c02f820b3e00dc34f3a2de20aff7861e6b19d2f8c5/sphinxcontrib_mermaid-1.0.0.tar.gz", hash = "sha256:2e8ab67d3e1e2816663f9347d026a8dee4a858acdd4ad32dd1c808893db88146", size = 15153, upload-time = "2024-10-12T16:33:03.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/c8/784b9ac6ea08aa594c1a4becbd0dbe77186785362e31fd633b8c6ae0197a/sphinxcontrib_mermaid-1.0.0-py3-none-any.whl", hash = "sha256:60b72710ea02087f212028feb09711225fbc2e343a10d34822fe787510e1caa3", size = 9597, upload-time = "2024-10-12T16:33:02.303Z" }, +] + [[package]] name = "sphinxcontrib-qthelp" version = "2.0.0"