johnameyer / cards-ts

A card game framework and various games in Typescript. The framework allows for bot development and playing card games in the terminal or in a browser.
4 stars 2 forks source link

Add state machine composition #113

Open johnameyer opened 5 months ago

johnameyer commented 5 months ago

Currently our state machines exist as a flat structure

{
  [GameStates.START_GAME]: this.startGame,
  [GameStates.START_ROUND]: this.startRound,
  [GameStates.START_PASS]: this.startPass,
  [GameStates.WAIT_FOR_PASS]: this.waitForPass,
  [GameStates.HANDLE_PASS]: this.handlePass,
  [GameStates.START_FIRST_TRICK]: this.startFirstTrick,
  [GameStates.START_TRICK]: this.startTrick,
  [GameStates.START_PLAY]: this.startPlay,
  [GameStates.WAIT_FOR_PLAY]: this.waitForPlay,
  [GameStates.HANDLE_PLAY]: this.handlePlay,
  [GameStates.END_TRICK]: this.endTrick,
  [GameStates.END_ROUND]: this.endRound,
  [GameStates.END_GAME]: this.endGame,
}

To enable further composition and re-use, we must enable nesting of logic. For example, every startXyz, waitXyz, and handleXyz could all exist as a sub-machine (e.g. WaitStateTransitions), and loops might be their own sub-machine as well (though a loop is just a specialized general state). These constructs would be parameterized such that the specific behavior could be customized (or children states enabled). For the WaitStateTransitions, it would take in before and after handlers, and the event to call on which handlers. Likely the declaration and selection of the next state would remain in effect with the parent.

An open question is if the state controller should still be available for direct access in other controllers or in these handler functions.

Naturally, strong typing and proper serialization (meaning avoiding running handlers multiple times) is ideal. Likely the state variable would be represented by an array of states being pushed on.

Long term, we might want to consider supporting mermaid as an output for validation of flow.

johnameyer commented 5 months ago

Likely this can hook in with #91 wherein we would like to clearly establish which events are acceptable in response.

johnameyer commented 5 months ago

We should consider fixing up the "standard states" as a part of this

johnameyer commented 5 months ago

Potential euchre example:

/ TODO if we are changing handling to be a different mode, do we need to expose waiting / players controllers? 
const stateMachine = sequence([
  { // TODO how does this outer level look?
    id: 'start-game',
    run: controllers => { /* start game - state should already be clean so no-op */ },
  },
  loop({
    id: 'round',
    beforeEach: controllers => { /* clean up state */ },
    afterAll: controllers => { /* update scores and send messages */ },
    condition: controllers => /* no player has yet won */,
    run: sequence([
      handle({
        id: 'order-up', // these can probably be left off for the handler arg if we handle collisions
        mode: 'round-robin',
        // TODO should we have different functions for different modes or will discriminated union work fine?
        handler: 'orderUp',
        startingPosition: controllers => controllers.deck.dealer,
        beforeAll: controllers => { /* message about flipped card */ },
        // TODO ponder about semantics between outer beforeEach and inner beforeAll
        afterEach: controllers => { /* message (incl. going along) */ },
        breakingIf: controllers => controllers.euchre.bidder !== undefined,
        afterBreak: handle({ // TODO better terminology
          mode: 'single',
          handler: 'dealerDiscard',
          position: controllers => controllers.deck.dealer,
          after: controllers => { /* take the discard and give new card */ }, // plays into #116 
        }),
        otherwise: handle({ // TODO better terminology
          mode: 'round-robin',
          handler: 'nameTrump',
          startingPosition: controllers => controllers.deck.dealer,
          breakingIf: controllers => controllers.euchre.bidder !== undefined,
          afterBreak: controllers => { /* message (incl. going along) */ }
          // TODO handle misdeal / no trump selected - likely need to wrap in a loop
        }),
      }),
      loop({
        id: 'trick',
        beforeEach: controllers => { /* send messages, reset states */ },
        afterEach: controllers => { /* message, update leader, update count */ },
        condition: controllers => /* players have cards */,
        run: handle({
          mode: 'round-robin',
          handler: 'turn',
          startingPosition: controllers => controllers.trick.leader,
          condition: controllers => isPlayingThisRound(controllers.turn.get(), controllers)
          after: controllers => { /* send message (setting state currently handled by event-handler merge */ }
        })
      }),
    ]),
  }),
  {
    id: 'end-game',
    run: () => { /* send game over message */ },
  },
]);

Some other questions:

johnameyer commented 2 months ago

As a part of the build / test, we should consider generating a state diagram (mermaid?).

We should try to have the transitions named as a result