Modelling software with types! (Part 2)
Image by freepik

Modelling software with types! (Part 2)

?? Previous Part

In this part, we will continue to model the UNO Card Game we have designed in the 1st part. Here, we will focus on the sub-workflows, which are the smaller pieces inside our main workflows. Some might think this part is borderline unnecessary, as it touches too deep into the implementation details. However, we’ll see how much the models can be improved by just digging a bit deeper into the realm sub-workflows.

Let’s get started!

Start Game

Here is the type model for this workflow:

type StartGame = (game: UnstartedGame) => PlayingGame;        

Shuffle Deck

Let’s see what an UnstartedGame model look like:

type UnstartedGame = {
  deck: FreshDeck;
  players: PlayerCandidates;
};        

When we’re starting a game session, the first thing to do is to shuffle the deck. The resulting state of the game would be ShuffledUnstartedGame where the deck is shuffled and the players are still on the PlayerCandidates state, as they have no cards in their hands yet. This will be our first new sub-workflow in the StartGame workflow. Let’s call it ShuffleDeck .

type ShuffleDeck = (game: UnstartedGame) => ShuffledUnstartedGame;

type ShuffledUnstartedGame = {
  deck: ShuffledDeck;
  players: PlayerCandidates;
};

type ShuffledDeck = Array<Card>;        

Notice how ShuffledDeck is a different type to FreshDeck. This is done to distinguish fresh and shuffled deck. While the underlying structure is the same - a list of cards - fresh deck is not the same as shuffled deck!

Deal Cards

Now comes the part where the shuffled deck are dealt to the player candidates.

type DealCards = (game: ShuffledUnstartedGame) => DealtUnstartedGame;

type DealtUnstartedGame = {
  deck: DealtDeck;
  players: PlayersInWaiting;
};

type DealtDeck = Array<Card>;

type PlayersInWaiting = Array<Player>;        

This sub-workflow coverts ShuffledUnstartedGame into a new type called DealtUnstartedGame. This type is significantly different to the previous ShuffledUnstartedGame.

It has a DealtDeck type, which represents a shuffled deck with an incomplete number of cards, as they have been dealt into the players in the game. Once again, this should be different to ShuffledDeck.

Another newcomer is PlayersInWaiting type, which is also different to PlayerCandidates. It represents a list of players, with cards on their hand, who are waiting for their turn to play.

Initiate Starting Card

After the dealing process is done, a single card from the DealtDeck will be put onto the DiscardPile. This is the starting card, and it should be initiated in this sub-workflow.

type InitiateStartingCard = (game: DealtUnstartedGame) => InitiatedUnstartedGame;

type InitiatedUnstartedGame = {
  deck: PlayableDeck;
  discardPile: DiscardPile;
  players: PlayersInWaiting;
};

type PlayableDeck = Array<Card>;

type DiscardPile = Array<Card>;        

The resulting type from InitiateStartingCard sub-workflow is a new type called InitiatedUnstartedGame. This time, it has two new types we have never seen before.

The first type is PlayableDeck, which indicates that the deck has gone through all necessary preprocessing and is ready to be played.

The second type is DiscardPile, which represents cards that have been played. The topmost card in the pile indicates cards to be either playable or unplayable in a turn.

Determine First Player

Now that everything is set in the cards department, the final step in this workflow would be determining the first player to play.

type DetermineFirstPlayer = (game: InitiatedUnstartedGame) => PlayingGame;        

Finally, we have a function that can produce a PlayingGame type! However, this type needs a bit of improvement.

type PlayingGame = {
  deck: PlayableDeck;
  discardPile: DiscardPile;
  playerInTurn: PlayerInTurn;
  playersInWaiting: PlayersInWaiting;
};

type PlayerInTurn = {
  name: PlayerName;
  playableHand: PlayableHand;
  unplayableHand: UnplayableHand;
}

type PlayableHand = Array<PlayableCard>;

type UnplayableHand = Array<UnplayableCard>;

type PlayableCard = Card;

type UnplayableCard = Card;        

Now, a PlayerInTurn is its own type instead of just a property in PlayingGame. This type represents a player who are currently in their turn to play, which should be different to the PlayersInWaiting.

The difference is it has PlayableHand and UnplayableHand , which represents (you guess it) playable cards and unplayable cards. Quite self-explanatory. While both are regular Cards, one can be played and the other cannot. That is a pretty significant difference! We’ll see it in the next workflow.

And that is all for the Start Game workflow. We have discovered a lot of new types that were obvious during the workflow modelling. Turns out diving a bit deeper helps!

Play Card

Before we dig into the sub-workflow, let’s see the current model of this workflow.

type PlayGame = (game: PlayingGame) => PlayGameResult;        

There is one glaring problem in this model that I didn’t realize previously. That is, we can’t tell which card is being played! So, let’s modify this workflow to accept them:

type PlayCard = (game: PlayingGame, playableCard: PlayableCard) => PlayCardResult;        

PlayableCard type that we made before seems to be the perfect type for this case. We don’t want UnplayableCard to be valid here. That’s why the distinction between the two is important!

Now, we can confidently continue to the sub-workflows.

Unhand Card

Trust me, naming things are hard. This is the best name I can think about for this sub-workflow.

When the player in turn has decided which card should be played, they will unhand a card from their hand and put them on the discard pile. This process can be modeled as such:

type UnhandCard = (game: PlayingGame, playableCard: PlayableCard) => PossiblyWonGame;

type PossiblyWonGame = PlayingGame;        

A new type that emerges in this model is PossiblyWonGame. Structurally it should be the same as normal PlayingGame. However, this type indicates that the game might already be over, since the player in turn might play their last card in their hand. Checking for the winner over the game type is necessary in order to continue.

Check If Game Won

The game is won when a player - which should be the current player in turn - has no more cards on their hand. This sub-workflow checks for that condition and return a new, interesting type.

type CheckIfGameWon = (game: PossiblyWonGame) => CheckIfGameWonResult;

type CheckIfGameWonResult =
  | { kind: 'continue-playing', value: NoCurrentPlayerGame }
  | { kind: 'finish-playing', value: FinishedGame };
  
type NoCurrentPlayerGame = {
  deck: PlayableDeck;
  discardPile: DiscardPile;
  playersInWaiting: PlayersInWaiting;
};

type FinishedGame = {
  deck: PlayableDeck;
  discardPile: DiscardPile;
  wininngPlayer: PlayerName;
  losingPlayer: LostPlayers;
};

type LosingPlayers = Array<Player>;        

That’s right, we finally get our first OR type in a while. CheckIfGameWonResult type, which represents two possibilities for the game: whether it must be continued (continue-playing) or finished (finish-playing).

When the result is continue-playing, it carries NoCurrentPlayerGame, which represents a game where there are no player in turn. This intermediate value should be transformed into PlayingGame.

Another result, which is finish-playing, carries FinishedGame type. This represents a game that has been finished. We already have this type since the previous part, actually. But it is modified now to have LosingPlayers type, an aptly named type that represents other players who don’t win.

Next Turn

This sub-workflow should only be called if the result from the previous sub-workflow is continue-playing, since it accepts NoCurrentPlayerGame and returns PlayingGame.

type NextTurn = (game: NoCurrentPlayerGame) => PlayingGame;        

This sub-workflow will select a player in PlayersInWaiting inside NoCurrentPlayerGame to become a PlayerInTurn. Basically, it represents that moment in a game where some player asked “whose turn is it?”

Draw Card

Please hold on for a bit, because we are missing one workflow right now thanks to the remodeling of Play Card. When a player has no playable card in their hand, the only action they can do is to draw a card. Somehow, the past me probably designed the original Play Card workflow with this action in mind. Nevertheless, let’s just create a new workflow for drawing a card instead of blaming my past me!

type DrawCard = (game: PlayingGame) => PlayingGame;        

Wait, so this workflow only transforms PlayingGame into another PlayingGame? Correct, as there is no possibility that the game can finish during this workflow.

Draw From Deck

This is the defining sub-workflow of Draw Card. It represents the action of drawing the topmost card of the deck into the player in turn’s hand. Then, the player’s turn is complete, leaving a NoCurrentPlayerGame type as a result.

type DrawFromDeck = (game: PlayingGame) => NoCurrentPlayerGame;        

After the result is gained, the next sub-workflow should transform NoCurrentPlayerGame back into PlayingGame . Thankfully, that sub-workflow already exist, which is Next Turn!

Epilogue

That is all for this part! It might look like it’s finished, but no. There will be the 3rd part where we will try to bring new requirements into our model and see how they evolve once more. But first, let’s appreciate the beauty of type models we’ve made so far here.

Thanks for your attention. See you in the next part!

要查看或添加评论,请登录

Deta Aditya的更多文章

  • Union Type: A Deep Dive

    Union Type: A Deep Dive

    If you have been following my posts since last month, you’ve probably seen one of my first LinkedIn slides called…

  • Modelling software with types! (Part 1)

    Modelling software with types! (Part 1)

    How do you usually model your software before it is developed into code? Some people uses diagrams, whiteboard…

社区洞察

其他会员也浏览了