{"id":"herilfiu/luau-hfsm","name":"luau-hfsm","scope":"herilfiu","platform":"roblox","description":"A reusable HFSM util for Roblox games.","version":"0.4.0","latest":"0.4.0","versions":["0.1.0","0.2.0","0.3.0","0.4.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"feb853582194ca5c6cfbfe47d25a0bf771249915d9baf1806ea72ae17cc81c87","likes":0,"downloads":0,"install":"forest install herilfiu/luau-hfsm","url":"https://forest.dev/p/roblox/herilfiu/luau-hfsm","files":"https://api.forest.dev/ai/package/roblox/herilfiu/luau-hfsm/files","readme":"# luau-hfsm\n\n[![CI](https://github.com/HeriLFIU/luau-hfsm/actions/workflows/ci.yml/badge.svg)](https://github.com/HeriLFIU/luau-hfsm/actions/workflows/ci.yml)\n[![Wally Package](https://img.shields.io/endpoint?url=https%3A%2F%2Ftwirly.dev%2Fwally%2Fv1%2Fherilfiu%2Fluau-hfsm)](https://wally.run/package/herilfiu/luau-hfsm)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)\n\nA hierarchical finite state machine for Roblox game frameworks: compile a statechart\nonce, run it on hundreds of interactive objects.\n\n---\n\n## Features\n\n- **Definition and instance are separate.** `HFSM.compile` returns an immutable graph;\n  `HFSM.create` makes a session that shares it and owns only its own active\n  configuration, queues and teardown scopes. A chart is a class; a session is an object\n  whose behaviour depends on the state it is in.\n- **True hierarchy, SCXML semantics.** Events resolve innermost-first and bubble outward.\n  Transitions exit and enter around the transition domain, precomputed at compile time so\n  a hop costs no traversal.\n- **Orthogonal regions.** Mark a state `parallel` and its children all run at once, each\n  with its own current state, its own scopes and its own answer to an event. A character\n  can be running, mid-cast and chilled without a state named `RunningWhileCastingChilled`.\n- **Internal transitions.** Leave `to` off an edge and it claims its event, runs its\n  action, and never leaves the state — no `onExit`, no teardown, no `onEnter`. A weapon\n  counts rounds and a channel absorbs damage without restarting their own animations.\n- **Time, built in and injected.** `{ after = 5, to = \"Idle\" }` is taken five seconds into\n  a visit, armed on entry and disarmed on exit. The clock is a scheduler you pass to\n  `create`, so the package still touches no engine global and tests run on a fake clock.\n- **Interrupt and resume.** `{ to = \"Casting\", history = \"deep\" }` hands a stunned cast\n  back exactly where it was; an ordinary `to = \"Casting\"` starts it afresh.\n- **Edges that must win.** `preempt = true` lets \"die from anywhere\" on a parent beat\n  every child's own edges, instead of repeating `health > 0` in each of them.\n- **Every callback is handed its session.** Hooks, guards and actions get the session\n  last, the way a method gets `self` — no `context.session = session` cycle to set up.\n  `onEnter` and `onExit` are also told which event caused them.\n- **Failures never corrupt a session.** A transition that has started always finishes,\n  whatever throws on the way, so the configuration stays legal; the error names the hook\n  or edge (`[luau-hfsm] action failed on Idle --Go--> Chasing: …`). Guards cannot change\n  the session they guard.\n- **Teardown that respects the hierarchy.** Every state gets its own cleanup scope. A\n  sibling hop disposes the sibling; a shared parent's resources are never touched, and\n  neither is a neighbouring region's. `cleanup:add(handle, \"unsubscribe\")` binds anything\n  that spells its teardown its own way.\n- **Macrosteps that settle.** One `send` takes every enabled conditionless edge and\n  drains internal events before returning, so a waypoint state never leaks into the game\n  loop. `settle()` re-checks those edges after the context changes, and says whether the\n  machine moved.\n- **Four queues, in a fixed order.** Events a hook raises are served first; external\n  events wait in a `high`, `normal` or `low` band. A death overtakes a queued attack\n  without making the chart non-deterministic — order inside a band never changes.\n- **Intents can be checked before they are taken.** `Session:can(event)` answers whether\n  an event would be claimed by the state the session is actually nested in, without\n  running a single hook — the question a server has to ask of a client's request.\n- **Anything outside can follow along.** `Session:subscribe(listener)` runs a listener\n  once per macrostep that moved the machine — never on a configuration the chart was only\n  passing through — so a HUD redraws from state without the chart knowing a HUD exists.\n- **Machines own machines.** `HFSM.group()` holds a changing set of child sessions -- the\n  players in a round, the abilities on a character, the missiles in the air. Bind it to a\n  state's cleanup scope and every member dies with that state, cascading through groups of\n  groups. Members can be broadcast to, heard from with one `listen`, followed by a UI with\n  `observe`, and handed to a new owner with `release` and `adopt`.\n- **Charts talk back.** `session:emit(\"Damaged\", data)` sends an output to whoever called\n  `session:listen(\"Damaged\", fn)` -- a HUD, a sound system, a parent machine -- delivered\n  once the machine is at rest, never in the middle of a transition.\n- **Write a piece once, mount it anywhere.** A state table, a chain of states or a whole\n  chart can be plugged in as a state, and a function that returns one is a template.\n  `namespace = true` makes a fragment's edges mean its own states wherever it is mounted.\n- **Mistakes fail early and loudly.** Dangling edges, ambiguous targets, missing `initial`\n  children, misspelled hooks and cyclic hierarchies are compile errors that name the\n  state; strict mode makes a misspelled event or state id at runtime an error too.\n- **Strictly typed**: `--!strict` from line 1, generic context types throughout, a public\n  `Session` interface with nothing private on it, and transition helpers that give inline\n  guards and actions their types under either Luau solver.\n- **Zero engine dependencies**: no `game`, no `script`, no `task`. Runs identically under\n  standalone Lune and Roblox.\n- **Specified and tested.** Every promise is a numbered requirement in\n  [the specification](specs/hfsm/spec.md), and every requirement names the test that\n  pins it.\n- **Dual package ecosystem**: native support for Wally and pesde.\n\n---\n\n## Installation\n\n### Wally\n\nAdd `luau-hfsm` to your `wally.toml`:\n\n```toml\n[dependencies]\nluau-hfsm = \"herilfiu/luau-hfsm@^0.4.0\" # x-release-please-version\n```\n\n### pesde\n\nInstall via `pesde`:\n\n```bash\npesde add herilfiu/luau_hfsm\n```\n\n---\n\n## Quick Start\n\n```luau\nlocal HFSM = require(path.to.luau_hfsm)\n\n-- Compile once, at module scope.\nlocal machine = HFSM.compile({\n    initial = \"Closed\",\n    states = {\n        Closed = {\n            transitions = { { event = \"Open\", to = \"Opening\" } },\n        },\n        Opening = {\n            onEnter = function(context, cleanup)\n                -- Bind the thing, not the call: `:Play()` returns nothing, and a state\n                -- binds what has to be undone when it is left.\n                local swing = context.model.Hinge.Swing\n                swing:Play()\n                cleanup:add(function()\n                    swing:Stop()\n                end)\n            end,\n            -- No event: taken as soon as the guard holds, inside the same macrostep.\n            transitions = { { to = \"Open\", guard = function(context)\n                return context.isFullyOpen\n            end } },\n        },\n        Open = {\n            initial = \"Unlocked\",\n            transitions = { { event = \"Close\", to = \"Closed\" } }, -- works from either child\n            states = {\n                Unlocked = { transitions = { { event = \"Lock\", to = \"Locked\" } } },\n                Locked = { transitions = { { event = \"Unlock\", to = \"Unlocked\" } } },\n            },\n        },\n    },\n})\n\n-- Create one lightweight session per object.\nlocal door = HFSM.create(machine, { model = model, isFullyOpen = true }):start()\n\ndoor:send(\"Open\")            --> passes through Opening and settles in Open.Unlocked\ndoor:send(\"Lock\")            --> claimed by Open.Unlocked; Open keeps its cleanup scope\nprint(door:getState())       --> \"Open.Locked\"\nprint(door:matches(\"Open\"))  --> true\ndoor:destroy()               --> exits innermost-first, disposing every bound resource\n```\n\n---\n\n## Coming from a flat state machine\n\nIf you have written a flat machine before, the schema above looks familiar right up until\n`Open` contains `states` of its own. That one addition is the whole idea, and it buys\nthree things a flat machine cannot express.\n\n**A state can be inside another state.** `Open` is not a leaf; it is a *compound* state\ncontaining `Unlocked` and `Locked`. The door is in `Open` *and* in `Open.Unlocked` at\nonce. `getState()` gives you the innermost one, `matches(\"Open\")` asks about the whole\nsubtree, and `getPath()` gives you the chain.\n\n**A parent handles what its children do not.** In a flat machine, `Close` is written on\n`Unlocked`, again on `Locked`, and again on every child added later. Here it is written\nonce on `Open`. An event is offered to the deepest active state first and bubbles outward\nuntil something claims it:\n\n```luau\n-- Flat: every state repeats the edges it shares with its neighbours.\nUnlocked = { transitions = { { event = \"Lock\", to = \"Locked\" }, { event = \"Close\", to = \"Closed\" } } },\nLocked   = { transitions = { { event = \"Unlock\", to = \"Unlocked\" }, { event = \"Close\", to = \"Closed\" } } },\n\n-- Nested: the shared edge is written once, on the region that owns it.\nOpen = {\n    initial = \"Unlocked\",\n    transitions = { { event = \"Close\", to = \"Closed\" } },\n    states = {\n        Unlocked = { transitions = { { event = \"Lock\", to = \"Locked\" } } },\n        Locked = { transitions = { { event = \"Unlock\", to = \"Unlocked\" } } },\n    },\n},\n```\n\n**A parent's resources survive its children changing.** This is the part with no flat\nequivalent. Every state gets its own cleanup scope, and moving between two children\ndisposes only the child being left — the parent is the *domain* of the hop, the innermost\nstate containing both ends, and a domain is never exited. An aggro highlight bound to\n`Combat` lives across every `Melee ⇄ Ranged` switch and dies exactly once, when `Combat`\nitself is left. In a flat machine you would re-create it on every switch, or leak it.\n\nNothing else changes: `send`, `getState` and `destroy` are the calls you already know.\n[Recipe 5](docs/06-recipes.md#recipe-5-an-npc-three-levels-deep) works all three points\nthrough one NPC.\n\n---\n\n## Running several things at once\n\nNesting says *which* state you are in. Regions say how many things are going on at the\nsame time. Mark a state `parallel = true`, leave out `initial`, and its children all run\ntogether — each with its own current state, its own teardown scopes, and its own answer\nto an event:\n\n```luau\nAlive = {\n    parallel = true,\n    transitions = { { event = \"Died\", to = \"Dead\" } },  -- reached from every region\n    states = {\n        Ability = {\n            initial = \"Ready\",\n            states = {\n                Ready = { transitions = { { event = \"Cast\", to = \"Channeling\" } } },\n                Channeling = { transitions = { { event = \"Release\", to = \"Ready\" } } },\n            },\n        },\n        Locomotion = {\n            initial = \"Idle\",\n            states = {\n                Idle = { transitions = { { event = \"Move\", to = \"Running\" } } },\n                Running = { transitions = { { event = \"Stop\", to = \"Idle\" } } },\n            },\n        },\n    },\n},\n```\n\n```luau\ncharacter:send(\"Cast\")\ncharacter:send(\"Move\")           -- claimed by Locomotion; the cast keeps channeling\ncharacter:getLeaves()            --> { \"Alive.Ability.Channeling\", \"Alive.Locomotion.Running\" }\ncharacter:send(\"Died\")           -- both regions bubble it to Alive; taken once\n```\n\nWithout regions this needs a state per combination — `RunningWhileCasting` and every\nother pair. With them the chart grows by addition instead of multiplication, and a\nresource bound in one region is never disposed by something happening in another.\n`getState()` has no single answer while regions are running and says so;\n[Recipe 12](docs/06-recipes.md#recipe-12-a-character-that-moves-and-casts-at-once) works\na three-region character through in full.\n\n---\n\n## API summary\n\n| Member | Purpose |\n| :--- | :--- |\n| `HFSM.compile(schema)` | Validate a schema and return an immutable `Machine`. |\n| `HFSM.create(machine, context, options?)` | Create a `Session`, in the `\"idle\"` status. Options: `schedule`, `strict`, `maxMicrosteps`, `maxListenerRounds`. |\n| `HFSM.on(event, to?, opts?)` · `HFSM.always(to, opts?)` · `HFSM.after(seconds, to?, opts?)` | Build typed transitions; bind once with `local on: HFSM.On<Ctx> = HFSM.on`. |\n| `HFSM.group(options?)` | An empty group of sessions that it owns, creating each with `options`. |\n| `HFSM.Cleanup.new()` | A standalone teardown scope. |\n| `HFSM.VERSION` · `HFSM.getVersion()` | The package version. |\n| `Session:start()` | Enter the initial configuration; returns the session. |\n| `Session:send(event, data?, priority?)` | Queue an external event and run a macrostep to quiescence. |\n| `Session:raise(event, data?)` | Queue an internal event, served before every external band. |\n| `Session:emit(name, data?)` | Emit an output, delivered to listeners once the machine is at rest. |\n| `Session:listen(name, listener)` | Hear one output name; returns a function that unsubscribes. |\n| `Session:settle()` | Re-check the system-driven edges after the context changed; returns whether the machine moved. |\n| `Session:can(event, data?)` | Whether the event would be claimed, without taking it. |\n| `Session:subscribe(listener)` | Watch the session; returns a function that unsubscribes. |\n| `Session:getEvents()` | Every event name the chart mentions, sorted. |\n| `Session:matches(id)` | Whether a state id is in the active configuration. |\n| `Session:getState()` | The innermost active state; throws when regions leave more than one. |\n| `Session:getLeaves()` · `Session:getPath()` | One leaf per region, or the whole configuration. |\n| `Session:getStatus()` | `\"idle\"`, `\"running\"` or `\"destroyed\"`. |\n| `Session:destroy()` | Unwind the whole path and dispose every scope. |\n| `Session.context` | Your data, handed to every hook, guard and action, along with the session itself. |\n| `cleanup:add(binding, disposer?)` | Bind a function, thread, Instance, connection — or anything, given a method name or teardown function. |\n| `Group:spawn(machine, context, key?)` · `Group:adopt(session, key?)` | Create, start and own a member; or take ownership of a running session. |\n| `Group:release(member)` · `Group:remove(member)` | Hand a member back alive; or destroy it. |\n| `Group:get(key)` · `Group:count()` · `Group:members()` | Look-ups, and iteration in join order. |\n| `Group:send(event, data?, priority?)` | Broadcast to every member whose chart mentions the event; returns how many claimed it. |\n| `Group:listen(name, listener)` | Hear one output name from every member, present and future. |\n| `Group:observe(observer)` | Run per member with a cleanup scope that lasts while it stays — the UI hook. |\n| `Group:destroy()` · `Group:isDestroyed()` | Destroy every member, last joined first. |\n\nState fields beyond the basics: `namespace = true` makes a reusable fragment's edges find\nits own states first, and `emits = { ... }` declares the outputs a state emits, which strict\nmode checks.\n\nExported types: `Machine<C>`, `Session<C>`, `Group<C>`, `Context`, `Event<D>`, `Priority`,\n`History`, `Delay<C>`, `Schema<C>`, `StateSchema<C>`, `TransitionSchema<C>`,\n`EdgeOptions<C>`, `On<C>`, `Always<C>`, `After<C>`, `Guard<C>`, `Action<C>`, `EnterHook<C>`,\n`ExitHook<C>`, `Observer<C>`, `Listener<C>`, `MemberListener<C>`, `MemberObserver<C>`,\n`Cleanup`, `CleanupTask`, `Disposer<T>`, `CreateOptions`, `Scheduler`, `Status`, and the\nopaque `CompiledState<C>` and `CompiledTransition<C>`.\n\n### The shapes a transition takes\n\n| Trigger | `to` | Kind | What happens |\n| :--- | :--- | :--- | :--- |\n| `event` | named | **External** | Waits for the event, then exits, acts and enters. |\n| `event` | omitted | **Internal** | Waits for the event, then acts. Nothing is exited or entered. |\n| `after` | named | **Delayed** | Taken that many seconds into a visit, unless the state was left first. |\n| `after` | omitted | **Delayed, internal** | Runs its action once, that man","readmeTruncated":true,"readmeFull":"https://api.forest.dev/v1/package/herilfiu/roblox/luau-hfsm/0.4.0/readme"}