Skip to content

Elevator

🏷️ state data
🏷️ copy data on transition
🏷️ conditional transitions
🏷️ immediate (always) transitions
🏷️ delayed transition
🏷️ state entry side-effect
🏷️ send event to self

About

This example models an elevator (aka lift). This model operates completely standalone, but can also be combined into a group of coordinated elevators with the elevators controller.

The elevator machine’s state says where it is now and where it’s going, plus it has a queue of floors to visit next (if any). When a VISIT_FLOOR event is received (either by summoning it at the wall, or a passenger pressing a button inside), the elevator either opens the doors (if already at the floor), else adds the floor to its queue.

State onEntry() side-effects use setTimeout() to send events to the machine to simulate 5s to open/close doors. Similarly up/down travel progress is simulated by moving 1/10th of a floor every 500ms, meaning it takes 5s to go up/down one floor.

This machine’s state data is homogenous and we’re using enableCopyDataOnTransition to simplify some transitions that don’t update the data.

Definition

elevatorMachine.ts
import {
type StateLifecycleSideEffectFunction,
defineMachine,
} from "yay-machine";
export interface ElevatorState {
readonly name:
| "doorsClosing"
| "doorsClosed"
| "doorsOpening"
| "doorsOpen"
| "goingUp"
| "goingDown";
readonly currentFloor: number;
readonly fractionalFloor: number; // workaround for JS number precision; using two integers instead of a float
readonly floorsToVisit: readonly number[];
}
export interface VisitFloorEvent {
readonly type: "VISIT_FLOOR";
readonly floor: number;
}
export interface CloseDoorsEvent {
readonly type: "CLOSE_DOORS";
}
export interface ClosedDoorsEvent {
readonly type: "CLOSED_DOORS";
}
export interface OpenDoorsEvent {
readonly type: "OPEN_DOORS";
}
export interface OpenedDoorsEvent {
readonly type: "OPENED_DOORS";
}
export interface MoveUpEvent {
readonly type: "MOVE_UP";
}
export interface MoveDownEvent {
readonly type: "MOVE_DOWN";
}
export type ElevatorEvent =
| VisitFloorEvent
| CloseDoorsEvent
| ClosedDoorsEvent
| OpenDoorsEvent
| OpenedDoorsEvent
| MoveUpEvent
| MoveDownEvent;
const sleepThen =
(
doneEvent: ElevatorEvent,
time = 5000,
): StateLifecycleSideEffectFunction<ElevatorState, ElevatorEvent> =>
({ send }) => {
const timer = setTimeout(() => send(doneEvent), time);
return () => clearTimeout(timer);
};
const insertFloor = (state: ElevatorState, floor: number): ElevatorState => {
const nextFloor = state.floorsToVisit[0];
let floorsToVisit = [...new Set([...state.floorsToVisit, floor])];
floorsToVisit.sort((a, b) => a - b);
if (nextFloor !== undefined) {
if (nextFloor > state.currentFloor) {
const splitIndex = floorsToVisit.findLastIndex(
(floor) => floor <= state.currentFloor,
);
if (splitIndex !== -1) {
floorsToVisit = floorsToVisit
.slice(splitIndex + 1)
.concat(floorsToVisit.slice(0, splitIndex + 1).toReversed());
}
} else if (nextFloor < state.currentFloor) {
const splitIndex = floorsToVisit.findLastIndex(
(floor) => floor < state.currentFloor,
);
if (splitIndex !== -1) {
floorsToVisit = floorsToVisit
.slice(0, splitIndex + 1)
.toReversed()
.concat(floorsToVisit.slice(splitIndex + 1));
}
}
}
return { ...state, floorsToVisit };
};
const isAtFloor = (state: ElevatorState, floor: number) =>
floor === state.currentFloor && state.fractionalFloor === 0;
/**
* Models an elevator moving between floors
*/
export const elevatorMachine = defineMachine<ElevatorState, ElevatorEvent>({
enableCopyDataOnTransition: true, // most transitions don't change the state-data, so copy it by default
initialState: {
name: "doorsClosed",
currentFloor: 1,
fractionalFloor: 0,
floorsToVisit: [],
},
states: {
doorsClosing: {
onEnter: sleepThen({ type: "CLOSED_DOORS" }),
on: {
OPEN_DOORS: { to: "doorsOpening" },
CLOSED_DOORS: { to: "doorsClosed" },
},
},
doorsClosed: {
on: {
OPEN_DOORS: { to: "doorsOpening" },
},
always: [
{
to: "goingUp",
when: ({ state }) =>
!!state.floorsToVisit[0] &&
state.floorsToVisit[0] > state.currentFloor,
},
{
to: "goingDown",
when: ({ state }) =>
!!state.floorsToVisit[0] &&
state.floorsToVisit[0] < state.currentFloor,
},
],
},
doorsOpening: {
onEnter: sleepThen({ type: "OPENED_DOORS" }),
on: {
OPENED_DOORS: { to: "doorsOpen" },
CLOSE_DOORS: { to: "doorsClosing" },
},
},
doorsOpen: {
onEnter: sleepThen({ type: "CLOSE_DOORS" }),
on: {
VISIT_FLOOR: {
to: "doorsOpen",
when: ({ state, event }) => state.currentFloor === event.floor,
},
CLOSE_DOORS: { to: "doorsClosing" },
},
},
goingUp: {
onEnter: sleepThen({ type: "MOVE_UP" }, 500),
on: {
MOVE_UP: {
to: "goingUp",
data: ({ state }) => ({
...state,
currentFloor:
state.fractionalFloor === 9
? state.currentFloor + 1
: state.currentFloor,
fractionalFloor:
state.fractionalFloor === 9 ? 0 : state.fractionalFloor + 1,
}),
},
},
always: {
to: "doorsOpening",
when: ({ state }) => state.currentFloor === state.floorsToVisit[0]!,
data: ({ state }) => ({
...state,
currentFloor: state.floorsToVisit[0]!,
floorsToVisit: state.floorsToVisit.toSpliced(0, 1),
}),
},
},
goingDown: {
onEnter: sleepThen({ type: "MOVE_DOWN" }, 500),
on: {
MOVE_DOWN: {
to: "goingDown",
data: ({ state }) => ({
...state,
currentFloor:
state.fractionalFloor === 0
? state.currentFloor - 1
: state.currentFloor,
fractionalFloor:
state.fractionalFloor === 0 ? 9 : state.fractionalFloor - 1,
}),
},
},
always: {
to: "doorsOpening",
when: ({ state }) =>
state.currentFloor === state.floorsToVisit[0]! &&
state.fractionalFloor === 0,
data: ({ state }) => ({
...state,
currentFloor: state.floorsToVisit[0]!,
floorsToVisit: state.floorsToVisit.toSpliced(0, 1),
}),
},
},
},
on: {
VISIT_FLOOR: [
{
to: "doorsOpening",
when: ({ state, event }) => isAtFloor(state, event.floor),
},
{
// to: *current-state*
data: ({ state, event }) => insertFloor(state, event.floor),
},
],
},
});

Usage

import { elevatorMachine } from "./elevatorMachine";
const elevator = elevatorMachine.newInstance().start();
// passenger requests elevator at floor 5
elevator.send({ type: "VISIT_FLOOR", floor: 5 });
// time passes...
// ... doors open at floor 5
// passenger enters elevator and presses floor 12 button
elevator.send({ type: "VISIT_FLOOR", floor: 12 });