5.6.2 • Published 2 years ago

@plaited/behavioral v5.6.2

Weekly downloads
-
License
ISC
Repository
github
Last release
2 years ago

@plaited/behavioral

This package exports bProgram a utility used for creating behavioral programs in JavaScript.

Requirements

JavaScript runtime options

  1. Node >= v18
  2. Any modern evergreen browser

Installing

npm install --save @plaited/behavioral

import { bProgram } from 'https://esm.sh/@plaited/behavioral'

Example Usage

In each of the scenarios below we'll demonstrate usage of bProgram, via our tests. Think of them as guided TDD!.

Scenario: water flow control

We want to create an app that controls hot and cold water taps, whose output flows are mixed.

1. Lets make our app add hot water 3 times:

import { expect, test } from 'bun:test'
import { bProgram, DevCallback } from '@plaited/behavioral'

test('Add hot water 3 times', () => {
  const actual: string[] = []
  const {
    /** adds behavioral threads to behavioral program  **/
    addThreads,
    /**
     * creates a behavioral thread from synchronization sets and/or other  behavioral threads
     */
    thread,
    /**
     * At synchronization points, each behavioral thread
     * specifies three sets of events:
     * 1. requested events: the thread proposes that these be
     * considered for triggering
     * 2. waitFor events: the thread asks to be notified when
     * any of them is triggered
     * 3. blocked events: the threads currently forbids
     * triggering any of these events
     */
    sync,
    /** trigger the run of the behavioral program by requesting
     * the event passed as an argument
     */
    trigger,
    /** connect an action callback to the behavioral program that is
     * called when request event type of the same name as our
     * callback is selected by our behavioral program's
     * central event arbiter
     */
    feedback,
  } = bProgram()
 addThreads({
    addHot: thread(
      sync({ request: { type: 'hot' } }),
      sync({ request: { type: 'hot' } }),
      sync({ request: { type: 'hot' } }),
    ),
  })
  feedback({
    hot() {
      actual.push('hot')
    },
  })
  trigger({ type: 'start' })
  expect(actual).toEqual(['hot', 'hot', 'hot'])
})

Alright that looks good!

Note : On trigger({ type: "start" }); the type value in our event could be any string really to trigger a run on our program but it's probably best that it's not trigger({ type: "hot" }) or trigger({ type: "cold" }) as those are events we're requesting in our synchronization points. If we used either we'd be prematurely triggering the action callbacks we passed to our feedback function.

2. Now let's also add cold water 3 times:

test('Add hot/cold water 3 times', () => {
  const actual: string[] = []
  const { addThreads, thread, sync, trigger, feedback } = bProgram()
  addThreads({
    addHot: thread(
      sync({ request: { type: 'hot' } }),
      sync({ request: { type: 'hot' } }),
      sync({ request: { type: 'hot' } }),
    ),
    addCold: thread(
      sync({ request: { type: 'cold' } }),
      sync({ request: { type: 'cold' } }),
      sync({ request: { type: 'cold' } }),
    ),
  })
  feedback({
    hot() {
      actual.push('hot')
    },
    cold() {
      actual.push('cold')
    },
  })
  trigger({ type: 'start' })
  expect(actual).toEqual(['hot', 'hot', 'hot', 'cold', 'cold', 'cold'])
})

Hmmm... it's not mixing the two.We gotta fix that

3. Let's mix the the flow of hot and cold water

We want to interleave the hot and cold request events. We'll use loop to do so by looping back and forth between blocking one event while we wait for the other event at each synchronization step of our bProgram run.

test('interleave', () => {
  const actual: string[] = []
  const {
    addThreads,
    thread,
    sync,
    trigger,
    feedback,
    /**
     * A behavioral thread that loops infinitely or until some callback returns false.This function returns a threads
     */
    loop,
  } = bProgram()
  addThreads({
    addHot: thread(
      sync({ request: { type: 'hot' } }),
      sync({ request: { type: 'hot' } }),
      sync({ request: { type: 'hot' } }),
    ),
    addCold: thread(
      sync({ request: { type: 'cold' } }),
      sync({ request: { type: 'cold' } }),
      sync({ request: { type: 'cold' } }),
    ),
    mixHotCold: loop([
      sync({
        waitFor:  'hot' ,
        block:  'cold' ,
      }),
      sync({
        waitFor:  'cold' ,
        block:  'hot' ,
      }),
    ]),
  })
  feedback({
    hot() {
      actual.push('hot')
    },
    cold() {
      actual.push('cold')
    },
  })
  trigger({ type: 'start' })
  expect(actual).toEqual(['hot', 'cold', 'hot', 'cold', 'hot', 'cold'])
})

Note how we use the loop function above. We'll be using loop a lot in our reactive user interfaces, more often than thread actually. For example consider when a autocomplete shifts from open to close. Controlling the visual feedback of this transition along with whether to block events that occur in the open vs closed mode will leverage a callback passed to loop as the function's second argument. That callback would return true or false based on an open value we would store and update on our component.

Scenario: tic-tac-toe

Now how about something more challenging let's make a tic-tac-toe app in 8 easy(ish) steps.

1. Let's setup our bProgram

We need to do a little setup to iteratively develop our app in a TDD like manner. First we'll import our testing utils. Then we'll import bProgram, loop, thread, and sync form @plaited/behavioral.

import { expect, test } from 'bun:test'
import { bProgram, loop, RulesFunc, sync, thread } from '@plaited/behavioral'

const winConditions = [
  //rows
  [0, 1, 2],
  [3, 4, 5],
  [6, 7, 8],
  // columns
  [0, 3, 6],
  [1, 4, 7],
  [2, 5, 8],
  // diagonals
  [0, 4, 8],
  [2, 4, 6],
]

const squares = [0, 1, 2, 3, 4, 5, 6, 7, 8]

2. Let's write some code that allows our bProgram to detect wins

Now we will create our player's win detection threads using a function, playerWins, that takes the player X or O and returns our threads by using reduce to iterate over the winConditions, thus creating threads to detect when a player has won.

const playerWins = (player: 'X' | 'O') =>
  winConditions.reduce((acc: Record<string, RulesFunc>, win) => {
    acc[`${player}Wins (${win})`] = thread(
      sync<{ square: number }>({
        waitFor: ({ type, detail }) => type === player && win.includes(detail.square),
      
      }),
      sync<{ square: number }>({
        waitFor: ({ type, detail }) => type === player && win.includes(detail.square),
      
      }),
      sync<{ square: number }>({
        waitFor: ({ type, detail }) => type === player && win.includes(detail.square),
      
      }),
      sync<{ win: number[] }>({
        request: { type: `${player}Win`, detail: { win } },
      }),
    )
    return acc
  }, {})

test('detect wins', () => {
  const { addThreads, feedback, trigger } = bProgram()
  const actual: number[] = []
  addThreads({
    ...playerWins('X'),
  })
  feedback({
    XWin(deatil: { win: [number, number, number] }) {
      Object.assign(actual, deatil.win)
    },
  })
  trigger({ type: 'X', detail: { square: 1 } })
  trigger({ type: 'X', detail: { square: 4 } })
  trigger({ type: 'X', detail: { square: 7 } })
  expect(actual).toEqual([1, 4, 7])
})

It works and we're off to a good start!

3. Let's write some code to enforce turn taking

We'll next create a new thread, enforceTurns, that uses our loop function to interleave moves.

const enforceTurns = loop([
  sync({ waitFor:  'X' , block:  'O'  }),
  sync({ waitFor:  'O' , block:  'X'  }),
])

test('enforceTurns', () => {
  const { addThreads, feedback, trigger } = bProgram()
  let actual: {
    player: 'X' | 'O'
    square: number
  }
  addThreads({
    ...playerWins('O'),
    ...playerWins('X'),
    enforceTurns,
  })

  feedback({
    X({ square }: { square: number }) {
      actual = {
        player: 'X',
        square,
      }
    },
    O({ square }: { square: number }) {
      actual = {
        player: 'X',
        square,
      }
    },
  })
  trigger({ type: 'X', detail: { square: 1 } })
  trigger({ type: 'X', detail: { square: 4 } })
  trigger({ type: 'X', detail: { square: 7 } })
  expect(actual).toEqual({ player: 'X', square: 1 })
})

Alright!!! Alright!!!

4. Let's write some code to remove a square on the board from play

Using the same reduce approach we took with the playerWins function we iterate over the squares to create our squaresTaken threads.

const squaresTaken = squares.reduce((acc: Record<string, RulesFunc>, square) => {
  acc[`(${square}) taken`] = thread(
    sync<{ square: number }>({
      waitFor:  ({ detail }) => square === detail.square ,
    }),
    sync<{ square: number }>({
      block:  ({ detail }) => square === detail.square ,
    }),
  )
  return acc
}, {})
test('squaresTaken', () => {
  const { addThreads, feedback, trigger } = bProgram()
  const actual: {
    player: 'X' | 'O'
    square: number
  }[] = []

  addThreads({
    ...playerWins('O'),
    ...playerWins('X'),
    enforceTurns: loop([
      sync({ waitFor:  'X', block:  'O' }),
      sync({ waitFor:  'O', block:  'X' }),
    ]),
    ...squaresTaken,
  })
  feedback({
    O({ square }: { square: number }) {
      actual.push({
        player: 'O',
        square,
      })
    },
  })
  trigger({ type: 'X', detail: { square: 0 } })
  trigger({ type: 'O', detail: { square: 0 } })
  trigger({ type: 'X', detail: { square: 4 } })
  trigger({ type: 'O', detail: { square: 2 } })
  trigger({ type: 'X', detail: { square: 8 } })
  expect(actual).toEqual([{ player: 'O', square: 2 }])

5. Let's write some code to stop the game when a player wins

We'll create a simple thread that will wait for one the win events, XWin | OWin and then block a future move by the next player up.

const stopGame = thread(
  sync({ waitFor: [ 'XWin',  'OWin'] }),
  sync({ block: [ 'X',  'O'] }),
)

test('stopGame', () => {
  const { addThreads, feedback, trigger } = bProgram()
  const actual: ({ player: 'X' | 'O'; square: number } | { player: 'X' | 'O'; win: number[] })[] = []

  addThreads({
    ...playerWins('O'),
    ...playerWins('X'),
    enforceTurns,
    ...squaresTaken,
    stopGame,
  })
  feedback({
    X({ square }: { square: number }) {
      actual.push({
        player: 'X',
        square,
      })
    },
    O({ square }: { square: number }) {
      actual.push({
        player: 'O',
        square,
      })
    },
    XWin({ win }: { win: [number, number, number] }) {
      actual.push({
        player: 'X',
        win,
      })
    },
    OWin({ win }: { win: [number, number, number] }) {
      actual.push({
        player: 'O',
        win,
      })
    },
  })
  trigger({ type: 'X', detail: { square: 0 } })
  trigger({ type: 'O', detail: { square: 1 } })
  trigger({ type: 'X', detail: { square: 4 } })
  trigger({ type: 'O', detail: { square: 2 } })
  trigger({ type: 'X', detail: { square: 8 } })
  trigger({ type: 'O', detail: { square: 7 } })
  expect(actual).toEqual([
    { player: 'X', square: 0 },
    { player: 'O', square: 1 },
    { player: 'X', square: 4 },
    { player: 'O', square: 2 },
    { player: 'X', square: 8 },
    { player: 'X', win: [0, 4, 8] },
  ])
})

Easy!!!

6. Let's give O some default moves

We've decided this is going be a one player game and the app will take the role of player O. We need to give O some default actions to take. So we'll use the array method Map to iterate over the squares to create a list of events to request.

Note that the first square will have greater priority than the last square unless that square has already been taken and the move blocked by our squaresTaken threads.

const defaultMoves = squares.reduce((threads, square) => {
  threads[`defaultMoves(${square})`] = loop([
    sync({
      request:{
        type: 'O',
        detail: { square },
      }
    })
  ])
  return threads
}, {})

test('defaultMoves', () => {
  const { addThreads, feedback, trigger } = bProgram()
  const actual: ({ player: 'X' | 'O'; square: number } | { player: 'X' | 'O'; win: number[] })[] = []

  addThreads({
    ...playerWins('O'),
    ...playerWins('X'),
    enforceTurns,
    ...squaresTaken,
    stopGame,
    ...defaultMoves,
  })
  feedback({
    X({ square }: { square: number }) {
      actual.push({
        player: 'X',
        square,
      })
    },
    O({ square }: { square: number }) {
      actual.push({
        player: 'O',
        square,
      })
    },
    XWin({ win }: { win: [number, number, number] }) {
      actual.push({
        player: 'X',
        win,
      })
    },
    OWin({ win }: { win: [number, number, number] }) {
      actual.push({
        player: 'O',
        win,
      })
    },
  })
  trigger({ type: 'X', detail: { square: 0 } })
  trigger({ type: 'X', detail: { square: 4 } })
  trigger({ type: 'X', detail: { square: 8 } })
  expect(actual).toEqual([
    { player: 'X', square: 0 },
    { player: 'O', square: 1 },
    { player: 'X', square: 4 },
    { player: 'O', square: 2 },
    { player: 'X', square: 8 },
    { player: 'X', win: [0, 4, 8] },
  ])
})

Nice!!!

7. Let's have O start at the center if it's open

We want our program to be a little more strategic in it's play. So we'll add startAtCenter thread above our defaultMoves. This will give the startAtCenter thread higher priority when the central arbiter of our bProgram selects a requested event.

const startAtCenter = thread(
  sync({
    request: {
      type: 'O',
      detail: { square: 4 },
    },
  }),
)

test('startAtCenter', () => {
  const { addThreads, feedback, trigger } = bProgram()
  const actual: ({ player: 'X' | 'O'; square: number } | { player: 'X' | 'O'; win: number[] })[] = []

  addThreads({
    ...playerWins('O'),
    ...playerWins('X'),
    enforceTurns,
    ...squaresTaken,
    stopGame,
    startAtCenter,
    ...defaultMoves,
  })
  feedback({
    X({ square }: { square: number }) {
      actual.push({
        player: 'X',
        square,
      })
    },
    O({ square }: { square: number }) {
      actual.push({
        player: 'O',
        square,
      })
    },
    XWin({ win }: { win: [number, number, number] }) {
      actual.push({
        player: 'X',
        win,
      })
    },
    OWin({ win }: { win: [number, number, number] }) {
      actual.push({
        player: 'O',
        win,
      })
    },
  })
  trigger({ type: 'X', detail: { square: 0 } })
  trigger({ type: 'X', detail: { square: 4 } })
  trigger({ type: 'X', detail: { square: 8 } })
  expect(actual).toEqual([
    { player: 'X', square: 0 },
    { player: 'O', square: 4 },
    { player: 'X', square: 8 },
    { player: 'O', square: 1 },
  ])
})

8. Let's add our last requirement for our bProgram

We're going too up the app smarts one more time. We want O to try and prevent the completion of a line with two squares taken by X. So we'll add our preventCompletionOfLineWithTwoXs threads before our startAtCenter thread thus giving these threads an even greater priority when the central arbiter of our bProgram selects a requested event.

test('prevent completion of line with two Xs', () => {
  const board = new Set(squares)
  const { addThreads, feedback, trigger } = bProgram()
  const actual: ({ player: 'X' | 'O'; square: number } | { player: 'X' | 'O'; win: number[] })[] = []
  const preventCompletionOfLineWithTwoXs = winConditions.reduce((acc: Record<string, RulesFunc>, win) => {
    acc[`StopXWin(${win})`] = thread(
      sync<{ square: number }>({
        waitFor:  ({ type, detail }) => type === 'X' && win.includes(detail.square),
        
      }),
      sync<{ square: number }>({
        waitFor:  ({ type, detail }) => type === 'X' && win.includes(detail.square),
        
      }),
      sync<{ square: number }>({
        request: () => ({ type: 'O', detail: { square: win.find(num => board.has(num)) } }),
      }),
    )
    return acc
  }, {})
  addThreads({
    ...playerWins('O'),
    ...playerWins('X'),
    enforceTurns,
    ...squaresTaken,
    stopGame,
    ...preventCompletionOfLineWithTwoXs,
    startAtCenter,
    ...defaultMoves,
  })
  feedback({
    X({ square }: { square: number }) {
      actual.push({
        player: 'X',
        square,
      })
      board.delete(square)
    },
    O({ square }: { square: number }) {
      actual.push({
        player: 'O',
        square,
      })
      board.delete(square)
    },
    XWin({ win }: { win: [number, number, number] }) {
      actual.push({
        player: 'X',
        win,
      })
    },
    OWin({ win }: { win: [number, number, number] }) {
      actual.push({
        player: 'O',
        win,
      })
    },
  })
  trigger({ type: 'X', detail: { square: 0 } })
  trigger({ type: 'X', detail: { square: 3 } })
  //@ts-ignore: test
  expect(actual).toEqual([
    { player: 'X', square: 0 },
    { player: 'O', square: 4 },
    { player: 'X', square: 3 },
    { player: 'O', square: 6 },
  ])
})

There we have it, a working implementation of the logic for a tic-tac-toe app. Maybe we should create a user interface next???

Types of note

Event Detail

Our request events can optionally contain a detail of the type unknown. This detail object is data passed along when the request event is selected. It is also used by our central arbiter when evaluating wether a requested or triggered event is being Listened to. This allows waitFor events and block and block events respectively.

Request event and trigger argument

This is type of the object used by the sync function as value to be passed to the request key and the argument for our trigger function.

export type BPEvent<T = unknown> = {type: string, detail?: T}

Request further can accept another type of event

export type BPEventTemplate<T = unknown> = () => BPEvent<T>

WaitFor and block events

This is type of object used by the sync function as the value to be passed to the waitFor and block keys

export type BPListener<T = unknown> =  string |  ((args: { type: string; detail: T }) => boolean)

Sync argument

When creating a sync statement using our sync function we pass it an object of the following type.

export type RuleSet<T extends Detail = Detail> = {
  waitFor?: BPListener<T> | BPListener<T>[]
  request?: BPEvent<T> | BPEventTemplate<T>
  block?: BPListener<T> | BPListener<T>[]
}
5.6.2

2 years ago

5.6.1

2 years ago

5.6.0

2 years ago

5.0.6

2 years ago

5.5.0

2 years ago

5.4.1

2 years ago

5.0.5

2 years ago

5.4.0

2 years ago

5.0.4

2 years ago

5.3.0

2 years ago

5.1.2

2 years ago

5.0.3

2 years ago

5.2.0

2 years ago

5.0.2

2 years ago

5.0.1

2 years ago

5.0.0

2 years ago

5.0.0-next-1

2 years ago

3.3.1

2 years ago

3.3.0

2 years ago

3.2.0

2 years ago

4.1.0

2 years ago

4.0.1

2 years ago

4.0.0

2 years ago

5.0.8

2 years ago

5.0.7

2 years ago

3.0.2

2 years ago

3.1.0

2 years ago

3.0.1

2 years ago

1.0.17-rc

2 years ago

1.0.0-rc

2 years ago

1.0.20-rc

2 years ago

1.0.21-rc

2 years ago

3.0.0

2 years ago

1.0.1-rc

2 years ago

1.0.2-rc

2 years ago

1.0.10-rc

2 years ago

1.0.3-rc

2 years ago

1.0.5-rc

2 years ago

1.0.9-rc

2 years ago

1.0.8-rc

2 years ago

1.0.6-rc

2 years ago

1.0.0

3 years ago