Friday, December 29, 2023

Unit Testing Scorers

Overview

While working to score hands I mentioned my reason for encapsulating the scoring logic into separate classes was to make it easier to unit test, though I had not created any. First off, shame on me as I call myself a Test-Driven Developer. Secondly, I had a bug in my scoring of Parliament that took me a bit to understand which I'm pretty sure would have been obvious if I had done unit testing. So I'm going to take a bit to create unit tests for my scoring classes.

Setup

In the documentation for Board Game Arena some notes exist for using PHP Unit for automated testing. I struggled to use this to test out the main application because there was a lot of behavior that needed to be mocked out. So while this prompted me to use PHP Unit, I didn't end up needing the autoload.php file since I'm not testing the main application that refers to an external module.

I want these tests to be part of the project and committed to version control. However, I don't want them pushed up to the FTP server where the game is hosted since they will just use up space and not serve any purpose there. Since I'm using the configuration proposed for VS Code I updated the SFTP configuration to ignore the "modules/tests" folder.

Create Tests

The game of Spades scoring is 5 points for every Spade a player wins and -10 for the K of Hearts. The score function expects to receive an array of player IDs and an array of the cards that were won. I came up with the following scenarios:
  • When the player IDs and won cards are empty - which shouldn't happen, but if it does I don't want the code to fail. (Plus, it's a really easy test to setup and make sure things are wired up correctly.)
  • When one player takes all of the cards
  • When one player gets the K of Hearts and the other players share the Spades.

So the test class looks like this.
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

require_once "GLTSpadesScorer.class.php";

final class GLTSpadesScorerTest extends TestCase {
    function testScore_emptyValues() {
        $scorer = new GLTSpadesScorer();

        $actual = $scorer->score([], []);

        $expected = [];

        $this->assertEquals($expected, $actual);
    }

    function testScore_onePlayerGetsEverything() {
        $players = [1,2,3,4];
        $won_cards = [
            ['type' => 1, 'type_arg' => 7, 'location_arg' => 1],
            ['type' => 1, 'type_arg' => 8, 'location_arg' => 1],
            ['type' => 1, 'type_arg' => 9, 'location_arg' => 1],
            ['type' => 1, 'type_arg' => 10, 'location_arg' => 1],
            ['type' => 1, 'type_arg' => 11, 'location_arg' => 1],
            ['type' => 1, 'type_arg' => 12, 'location_arg' => 1],
            ['type' => 1, 'type_arg' => 13, 'location_arg' => 1],
            ['type' => 1, 'type_arg' => 14, 'location_arg' => 1],
            ['type' => 2, 'type_arg' => 7, 'location_arg' => 1],
            ['type' => 2, 'type_arg' => 8, 'location_arg' => 1],
            ['type' => 2, 'type_arg' => 9, 'location_arg' => 1],
            ['type' => 2, 'type_arg' => 10, 'location_arg' => 1],
            ['type' => 2, 'type_arg' => 11, 'location_arg' => 1],
            ['type' => 2, 'type_arg' => 12, 'location_arg' => 1],
            ['type' => 2, 'type_arg' => 13, 'location_arg' => 1],
            ['type' => 2, 'type_arg' => 14, 'location_arg' => 1],
            ['type' => 3, 'type_arg' => 7, 'location_arg' => 1],
            ['type' => 3, 'type_arg' => 8, 'location_arg' => 1],
            ['type' => 3, 'type_arg' => 9, 'location_arg' => 1],
            ['type' => 3, 'type_arg' => 10, 'location_arg' => 1],
            ['type' => 3, 'type_arg' => 11, 'location_arg' => 1],
            ['type' => 3, 'type_arg' => 12, 'location_arg' => 1],
            ['type' => 3, 'type_arg' => 13, 'location_arg' => 1],
            ['type' => 3, 'type_arg' => 14, 'location_arg' => 1],
            ['type' => 4, 'type_arg' => 7, 'location_arg' => 1],
            ['type' => 4, 'type_arg' => 8, 'location_arg' => 1],
            ['type' => 4, 'type_arg' => 9, 'location_arg' => 1],
            ['type' => 4, 'type_arg' => 10, 'location_arg' => 1],
            ['type' => 4, 'type_arg' => 11, 'location_arg' => 1],
            ['type' => 4, 'type_arg' => 12, 'location_arg' => 1],
            ['type' => 4, 'type_arg' => 13, 'location_arg' => 1],
            ['type' => 4, 'type_arg' => 14, 'location_arg' => 1],
        ];

        $scorer = new GLTSpadesScorer();

        $actual = $scorer->score($players, $won_cards);

        $expected = [
            1 => 30,
            2 => 0,
            3 => 0,
            4 => 0
        ];

        $this->assertEquals($expected, $actual);
    }

    function testScore_distributedPointsOnePlayerOnlyGetsKingOfHearts() {
        $players = [1,2,3,4];
        $won_cards = [
            ['type' => 1, 'type_arg' => 7, 'location_arg' => 1],
            ['type' => 1, 'type_arg' => 8, 'location_arg' => 1],
            ['type' => 1, 'type_arg' => 9, 'location_arg' => 1],
            ['type' => 1, 'type_arg' => 10, 'location_arg' => 3],
            ['type' => 1, 'type_arg' => 11, 'location_arg' => 2],
            ['type' => 1, 'type_arg' => 12, 'location_arg' => 1],
            ['type' => 1, 'type_arg' => 13, 'location_arg' => 3],
            ['type' => 1, 'type_arg' => 14, 'location_arg' => 2],
            ['type' => 2, 'type_arg' => 7, 'location_arg' => 1],
            ['type' => 2, 'type_arg' => 8, 'location_arg' => 4],
            ['type' => 2, 'type_arg' => 9, 'location_arg' => 4],
            ['type' => 2, 'type_arg' => 10, 'location_arg' => 2],
            ['type' => 2, 'type_arg' => 11, 'location_arg' => 2],
            ['type' => 2, 'type_arg' => 12, 'location_arg' => 2],
            ['type' => 2, 'type_arg' => 13, 'location_arg' => 4],
            ['type' => 2, 'type_arg' => 14, 'location_arg' => 4],
            ['type' => 3, 'type_arg' => 7, 'location_arg' => 1],
            ['type' => 3, 'type_arg' => 8, 'location_arg' => 1],
            ['type' => 3, 'type_arg' => 9, 'location_arg' => 2],
            ['type' => 3, 'type_arg' => 10, 'location_arg' => 2],
            ['type' => 3, 'type_arg' => 11, 'location_arg' => 1],
            ['type' => 3, 'type_arg' => 12, 'location_arg' => 1],
            ['type' => 3, 'type_arg' => 13, 'location_arg' => 1],
            ['type' => 3, 'type_arg' => 14, 'location_arg' => 2],
            ['type' => 4, 'type_arg' => 7, 'location_arg' => 1],
            ['type' => 4, 'type_arg' => 8, 'location_arg' => 1],
            ['type' => 4, 'type_arg' => 9, 'location_arg' => 3],
            ['type' => 4, 'type_arg' => 10, 'location_arg' => 3],
            ['type' => 4, 'type_arg' => 11, 'location_arg' => 3],
            ['type' => 4, 'type_arg' => 12, 'location_arg' => 3],
            ['type' => 4, 'type_arg' => 13, 'location_arg' => 3],
            ['type' => 4, 'type_arg' => 14, 'location_arg' => 3],
        ];

        $scorer = new GLTSpadesScorer();

        $actual = $scorer->score($players, $won_cards);

        $expected = [
            1 => 20,
            2 => 10,
            3 => 10,
            4 => -10
        ];

        $this->assertEquals($expected, $actual);
    }
}

The setup for the cards is a little annoying and could probably use some refactoring so that it reads better if nothing else.

Run Tests

From the command line, I go into the modules directory and run the command phpunit tests/. This executes all of the test classes in the "tests" directory.

Conclusion

Well, I feel silly as I thought that was going to take me more time, but since I already had PHP Unit installed I basically just needed to write tests. Now that that's done, I can build out the tests for the other games and explore how I want to deal with Guillotine and Dominoes that each have scoring that isn't related to the cards taken.

Scoring a Hand

 

Overview

During the last bit of work, the playing of cards started following the rules and now the game could figure out who won the trick and give them the cards. Now we can make use of the game that was selected at the start of the hand to figure out how to score the hand. Before that, we need to introduce the concept of the end of a hand.

As I was working on this some things were annoying me that I felt the need to address before I worked through the scoring.

Keep track of dealer

Created a new constant in the modules\constant.inc.php file and added a new game state label to keep track of the dealer.
self::initGameStateLabels([
    SELECTED_GAME => 10,
    TRICK_SUIT => 11,
    DEALER => 12,
]);

The setupNewGame function needs to initialize the dealer. I felt compelled to keep the dealer as the initial player selected by making the next player active, then going back two and allowing the logic for a new hand to move it forward one. Logically, it's not going to matter to the players, but it makes me happy.
self::setGameStateInitialValue(DEALER,
    self::getPlayerBefore(self::getPlayerBefore($this->activeNextPlayer())));

Update the dealer in the stNewHand function and update the active player to be the new dealer.
$new_dealer_id = self::getPlayerAfter(self::getGameStateValue(DEALER));
self::setGameStateValue(DEALER, $new_dealer_id);
$this->gamestate->changeActivePlayer($new_dealer_id);

Indicate the game being played

While there's a notification in the game log about what game was selected it seemed like a good idea to show the selected game somewhere else. Putting it with the player's hand seemed like a good way to not only indicate the game but who the dealer was for that round.

Share the dealer and selected game in the getAllDatas function.
$selected_game = null;
$selected_game_id = self::getGameStateValue(SELECTED_GAME);
foreach ($this->games as $game_type => $game) {
    if ($game['id'] == $selected_game_id) {
        $selected_game = $game['name'];
        break;
    }
}

$result[DEALER] = self::getGameStateValue(DEALER);
$result[SELECTED_GAME] = $selected_game;

I modified the template to have a place to show the selected game for the dealer. It'll start with "Choosing game..." and then replace that after the game is selected. Unfortunately, after typing this I realized the default text wouldn't be translated. It'll need to be updated so it can be passed in as a translated value, which will involve putting a placeholder in there instead.
<div id="playertables">
    <!-- BEGIN player -->
    <div class="playertable whiteblock playertable_{DIR}">
        <div class="playertablename" style="color:#{PLAYER_COLOR}">{PLAYER_NAME}</div>
        <div class="playertableselectedgame" id="dealer_p{PLAYER_ID}">Choosing game...</div>
        <div class="playertablecard" id="playertablecard_{PLAYER_ID}"></div>
    </div>
    <!-- END player -->
</div>

There are some styling changes so the selected game only shows if the player is the dealer.
.playertableselectedgame {
    display: none;
}

.playertableselectedgame.show_dealer {
    display: block;
}

Now show the selected game for the dealer in the setup method.
document.getElementById('dealer_p' + this.gamedatas.dealer).classList.add('show_dealer');
document.getElementById('dealer_p' + this.gamedatas.dealer).innerHTML = this.gamedatas.selected_game;

Now we need to show the game name as soon as it is selected. So the back end function of gameSelection needs to be updated to pass the ID of the dealer.
self::notifyAllPlayers('gameSelection',
    clienttranslate('${player_name} selects ${game_name} as the game to play'),
    [
        'i18n' => ['game_name'],
        'player_name' => self::getActivePlayerName(),
        'dealer_id' => $player_id,
        'game_name' => $game_name,
    ]
);

Then, the front end needs to handle the "gameSelection" notification so it shows the game name. Remember to subscribe to it in the setupNotifications function.
notif_gameSelection : function(notif) {
    document.getElementById('dealer_p'+notif.args.dealer_id).innerHTML = notif.args.game_name;
},

Finally, during each round, we need to update the front end with who the current dealer is. For this a new notification was created in the back end in the stNewHand function.
self::notifyAllPlayers('newRound', '', [
    'dealer_id' => $new_dealer_id
]);

The front end subscribes to the "newRound" notification and updates the view by adding the "show_dealer" class to the appropriate player.
notif_newRound : function(notif) {
    document.querySelectorAll('.show_dealer').forEach(e => e.classList.remove('show_dealer'));
    document.getElementById('dealer_p' + notif.args.dealer_id).classList.add('show_dealer');
},

Scoring

Hand end

Let's use a new state, "endHand", and allow "nextPlayer" to transition to it.
22 => [
    "name" => "nextPlayer",
    "description" => "",
    "type" => "game",
    "action" => "stNextPlayer",
    "updateGameProgression" => true,
    "transitions" => ["nextPlayer" => 21, "nextTrick" => 20, "endHand" => 30]
],

30 => [
    "name" => "endHand",
    "description" => "",
    "type" => "game",
    "action" => "stEndHand",
    "transitions" => []
],

The stEndHand function will determine the points each player gained for the hand.
function stEndHand() {
    $players = self::loadPlayersBasicInfos();
    $cards = $this->cards->getCardsInLocation(CARDS_WON);
    $scorer = null;

    $selected_game_id = self::getGameStateValue(SELECTED_GAME);
    switch ($selected_game_id) {
        case 1:
            $scorer = new GLTParliamentScorer();
            break;
        default:
            throw new BgaUserException(sprintf(self::_("The selected game id, %s, does not have a scorer"), $selected_game_id));
            break;
    }

    $player_to_points = $scorer->score(array_keys($players), $cards);

    foreach ($player_to_points as $player_id => $points) {
        $sql = "UPDATE player SET player_score=player_score+$points WHERE player_id='$player_id'";
        self::DbQuery($sql);
        self::notifyAllPlayers(
            "points",
            clienttranslate('${player_name} gained ${points} points'),
            [
                'player_name' => $players[$player_id]['player_name'],
                'points' => $points,
                'player_id' => $player_id,
            ]
        );
    }

    $new_scores = self::getCollectionFromDb("SELECT player_id, player_score FROM player", true);
    self::notifyAllPlayers("newScores", '', ['newScores' => $new_scores]);
}

Currently, this is only scoring the game of Parliament, but a similar pattern should work for the rest (except for maybe Dominoes...). This posts notifications for each player to everyone indicating how many points that player gained this hand. Then it sends another notification that is used to update the score totals.

Let's look at the GLTParliamentScorer class.
<?php

require_once('GLTScorer.interface.php');

class GLTParliamentScorer implements GLTScorer {
    function score(array $player_ids, array $won_cards) {
        $player_to_points = [];
        $player_card_counts = [];
        foreach ($player_ids as $id) {
            $player_to_points[$id] = 0;
            $player_card_counts[$id] = 0;
        }
 
        foreach ($won_cards as $card) {
            $player_id = $card['location_arg'];

            // Find K of hearts
            if ($card['type'] == HEART && $card['type_arg'] == 13) {
                $player_to_points[$player_id] -= 10;
            }

            $player_card_counts[$player_id] += 1;
        }

        foreach ($player_card_counts as $player_id => $count) {
            $player_to_points[$player_id] -= ($count / 4) * 5;
        }

        return $player_to_points;
    }
}

The GLTScorer interface is just defining the interface of the score method. The scoring for Parliament is -5 for each trick and -10 for the player who wins the K of Hearts. I pulled this out to a separate class to encapsulate the behavior, but there are some trade-offs with it. I did it so that they would be easier to unit test (though I didn't do that...), but you lose access to some of the functionality that is available in the main class that extends Table.

The front end subscribes to the "newScores" notification to update the scores in the view.
notif_newScores : function(notif) {
    for (var player_id in notif.args.newScores) {
        this.scoreCtrl[player_id].toValue(notif.args.newScores[player_id]);
    }
},

Conclusion

I'm not going to go into each of the Scorer implementations since they only vary in how the game is scored, but I'm sure Dominoes will end up being its own set of things because of how different that game plays from the others. I'm also realizing in talking about why I encapsulated the scoring in separate files I should take some time to reinforce for myself how I would test those classes. (Maybe I'll find some bugs or clarify how it might work for Dominioes!)

Sunday, December 24, 2023

Clean up Playing a Hand

 

Overview

In the last bit of work, I got it so a hand of cards could be played through, but this ignored the rules of the game. Before going any further I'd like to go back and fill in those details.

Correct hand sorting

Guillotine has a quirky ordering of the cards with the 10 as the second highest under the A - so A,10,K,Q,J,9,8,7. (Except for the game Dominoes in which the 10 goes back to its usual place between the J and 9, but we'll deal with that later.) The sorting of cards is handled in the JavaScript file. Specifically, when creating the cards a weight is specified, and the higher the value of the weight the further to the right it is placed in the hand. So instead of just using the card ID for the weight I tweaked that with the following.
// From the setup function in the <game_name>.js file
for (var suit = 1; suit <= 4; suit++) {
    for (var value = 7; value <= 14; value++) {
        const card_type_id = this.getCardUniqueId(suit, value);
        const card_weight = this.getCardWeight(suit, value);
        this.playerHand.addItemType(card_type_id, card_weight, g_gamethemeurl + 'img/cards.jpg', card_type_id);
    }
}

This method goes under the "Utility methods" section.
getCardWeight: function(suit, value) {
    var base_weight = this.getCardUniqueId(suit, value);
    if (value == 10) {
        return base_weight + 3;
    } else if (value == 14) {
        return base_weight + 1;
    } else {
        return base_weight;
    }
},

Nothing fancy, just bumping up the weight of 10 and the A (to make space for the 10).

Enforce following suit

This requires two changes to the playCard function. The first checks if there's a suit for the trick already defined and if the player didn't play a card of that suit, but has one in their hand then raises an error to let them know. The second sets a value for the current suit if one isn't already set.
function playCard($card_id) {
    self::checkAction("playCard");

    $player_id = self::getActivePlayerId();
    $current_card = $this->cards->getCard($card_id);

    // Here's where we ensure the player is following suit.
    $this->checkPlayableCard($player_id, $current_card);

    $this->cards->moveCard($card_id, 'cardsontable', $player_id);

    // This sets a suit for the trick if a value isn't already set.
    if (!self::getGameStateValue(TRICK_SUIT)) self::setGameStateValue(TRICK_SUIT, $current_card['type']);

    self::notifyAllPlayers('playCard', clienttranslate('${player_name} plays ${suit_displayed}${value_displayed}'), [
        'player_name' => self::getActivePlayerName(),
        'suit_displayed' => $this->suits[$current_card['type']]['name'],
        'value_displayed' => $this->values_label[$current_card['type_arg']],
        'suit' => $current_card['type'],
        'value' => $current_card['type_arg'],
        'card_id' => $card_id,
        'player_id' => $player_id
    ]);

    $this->gamestate->nextState("cardPlayed");
}

Finally, we need to make sure to clear the suit for the trick for each new trick. So modify the stNewTrick function to add the following.
self::setGameStateValue(TRICK_SUIT, 0);

Determine the winner of the trick

When advancing to the next player, check if four cards are on the table. If there are, then find the card with the highest value of the suit lead for the trick. Give the cards to that player and make them the next active player. This change is made in the stNextPlayer function.
function stNextPlayer() {
    if ($this->cards->countCardInLocation(CARDS_ON_TABLE) == 4) {
        $cards_on_table = $this->cards->getCardsInLocation(CARDS_ON_TABLE);
        $trick_suit = self::getGameStateValue(TRICK_SUIT);
        $winning_card = null;

        foreach ($cards_on_table as $card) {
            if ($card['type'] == $trick_suit) {
                $winning_card = $this->higherCard($winning_card, $card);
            }
        }

        $winning_player_id = $winning_card['location_arg'];

        $this->gamestate->changeActivePlayer($winning_player_id);
        $this->cards->moveAllCardsInLocation(CARDS_ON_TABLE, CARDS_WON, null, $winning_player_id);

        $players = self::loadPlayersBasicInfos();
        self::notifyAllPlayers('trickWin', clienttranslate('${player_name} wins the trick'), [
            'player_id' => $winning_player_id,
            'player_name' => $players[ $winning_player_id ]['player_name']
        ]);
        self::notifyAllPlayers('giveAllCardsToPlayer','', [
            'player_id' => $winning_player_id
        ]);

        $this->gamestate->nextState("nextTrick");
    } else {
        // Not end of trick or hand, so move to the next player
        $player_id = self::activeNextPlayer();
        self::giveExtraTime($player_id);
        $this->gamestate->nextState("nextPlayer");
    }
}

function higherCard($higher_card, $new_card) {
    if ($higher_card === null) return $new_card;
    if ($new_card === null) return $higher_card;

    if ($higher_card['type_arg'] == 10) {
        if ($new_card['type_arg'] == 14) return $new_card;
        else return $higher_card;
    } else if ($new_card['type_arg'] == 10) {
        if ($higher_card['type_arg'] == 14) return $higher_card;
        else return $new_card;
    } else if ($new_card['type_arg'] > $higher_card['type_arg']) {
        return $new_card;
    } else return $higher_card;
}

The logic around figuring out which card is the winning card is complicated since 10 ranks under the A, but the image of the cards we're working with has them in the typical rank for a poker deck.

Conclusion

These changes made it start behaving like a trick-taking game. However, it's going to be really hard to win if no score is kept track of. So that's next on the list.

Saturday, December 23, 2023

Play a Hand

Overview

In continuing to build out Guillotine for Board Game Arena (see the previous post), I'm looking to enable playing cards from a hand. For now, I'm going to focus on cycling through the players and collecting the cards, ignoring any of the rules around placement. Mostly because as I was putting this together I had forgotten about some of the details that were covered in the tutorial, so I want to be sure to detail those things.

Enable playing of a card

Like with almost everything in developing behavior in a game for Board Game Arena, it starts with a state. For this, I created two of them. The first, newTrick, was not strictly needed, but I know eventually I'll need to keep track of the suit led for a trick to enforce following suit and this is where it would need to be reset. Also, when adding any new state, another state needs to be updated to transition to the new one, so "gameSelection" was updated for this.
10 => [
    "name" => "gameSelection",
    "description" => clienttranslate('${actplayer} must select the game to play'),
    "descriptionmyturn" => clienttranslate('${you} must select the game to play'),
    "type" => "activeplayer",
    "args" => "argSelectGame",
    "possibleactions" => ["gameSelection"],
    "transitions" => ["startHand" => 20]
],

20 => [
    "name" => "newTrick",
    "description" => "",
    "type" => "game",
    "action" => "stNewTrick",
    "transitions" => ["playerTurn" => 21]
],

21 => [
    "name" => "playerTurn",
    "description" => clienttranslate('${actplayer} must play a card'),
    "descriptionmyturn" => clienttranslate('${you} must play a card'),
    "type" => "activeplayer",
    "args" => "argPlayerTurn",
    "possibleactions" => ["playCard"],
    "transitions" => []
],

Since "newTrick" isn't needed yet, the action backing it is just a function calling the "playerTurn" transition.
function stNewTrick() {
    $this->gamestate->nextState("playerTurn");
}

For "playerTurn", the intention for the args would be to indicate the playable cards so that some visual indicator could be given. For now, that's not critical and the argPlayerTurn function is just returning an empty array. That leaves supporting the playCard possible action which involves several steps.

Action button

Update the onUpdateActionButtons to add an action button as part of the switch statement there.
case 'playerTurn':
    this.addActionButton('btnPlayCard', _('Play card'), 'onBtnPlayCard');
    break;

Then implement the onBtnPlayCard method to pass to the back end what card was selected to be played. This is skipping some validation that should happen to make sure things don't get into a funny state - ensure only one card is selected and that the card is a valid play - the focus, for now, is on just getting the card played.
onBtnPlayCard: function() {
    const action = "playCard";
    if (!this.checkAction(action)) return;

    const selected_cards = this.playerHand.getSelectedItems();
    const card_id = selected_cards[0].id;

    this.playerHand.unselectAll();
    this.ajaxcall(
        "/" + this.game_name + "/" + this.game_name + "/" + action + ".html",
        {lock: true, card_id: card_id}, this, function (result) {}, function (is_error) {}
    );
},

Support action call

To allow the ajaxcall from the button there needs to be an action function defined in the <game_name>.action.php file. As a reminder, the first and last lines are dictated to us as required and the middle part is just about extracting the data passed in and calling a supporting function with the game logic (<game_name>.game.php file).
public function playCard() {
    self::setAjaxMode();

    $card_id = self::getArg("card_id", AT_posint, true);
    $this->game->playCard($card_id);

    self::ajaxResponse();
}

The playCard function handles moving the card to the desired location, then fires off a notification to log that to the players as well as trigger the visual update.
function playCard($card_id) {
    self::checkAction("playCard");

    $player_id = self::getActivePlayerId();

    $current_card = $this->cards->getCard($card_id);
    $this->cards->moveCard($card_id, 'cardsontable', $player_id);

    self::notifyAllPlayers('playCard', clienttranslate('${player_name} plays ${suit_displayed}${value_displayed}'), [
        'player_name' => self::getActivePlayerName(),
        'suit_displayed' => $this->suits[$current_card['type']]['name'],
        'value_displayed' => $this->values_label[$current_card['type_arg']],
        'suit' => $current_card['type'],
        'value' => $current_card['type_arg'],
        'card_id' => $card_id,
        'player_id' => $player_id
    ]);
}

I hadn't internalized the dual nature of these notifications, so I was confused when I was looking over examples that passed back things that had nothing to do with the message. Now that I understand they are serving both, it made things a lot clearer.

Update card visuals

The first step with this is to list to the "playCard" notification we generated in the last step above. This requires updating the setupNotifications function in the <game_name>.js file with the following line.
dojo.subscribe('playCard', this, "notif_playCard");

Then define the referenced "notif_playCard" function.
notif_playCard : function(notif) {
    const player_id = notif.args.player_id;
    const suit = notif.args.suit;
    const value = notif.args.value;
    const card_id = notif.args.card_id;

    dojo.place(this.format_block('jstpl_card', {
        x : this.cardwidth * (value - 2),
        y : this.cardheight * (suit - 1),
        player_id : player_id
    }), 'playertablecard_' + player_id);
 
    if (player_id != this.player_id) {
        // Some opponent played a card
        // Move card from player panel
        this.placeOnObject('cardontable_' + player_id, 'overall_player_board_' + player_id);
    } else {
        // You played a card. If it exists in your hand, move card from there and remove
        // corresponding item
        if ($('myhand_item_' + card_id)) {
            this.placeOnObject('cardontable_' + player_id, 'myhand_item_' + card_id);
            this.playerHand.removeFromStockById(card_id);
        }
    }
 
    // In any case: move it to its final destination
    this.slideToObject('cardontable_' + player_id, 'playertablecard_' + player_id).play();
}

This has some template and styling changes that need to happen to make things work and look ok. The <game_name>_<game_name>.tpl file needs to define "jstpl_card", which is placed in the script section under the "Javascript HTML templates" comment.
var jstpl_card = '<div class="card cardontable" id="cardontable_${player_id}" style="background-position:-${x}px -${y}px"></div>';

The dojo.place call that references the "jstpl_card" template also has 'playertablecard_' + player_id as its second parameter, which indicates where it should insert the template it referenced, so we need to define that in the <game_name>_<game_name>.tpl file. I made use of the layout used for the hearts tutorial game.
<div id="playertables">
    <!-- BEGIN player -->
    <div class="playertable whiteblock playertable_{DIR}">
        <div class="playertablename" style="color:#{PLAYER_COLOR}">{PLAYER_NAME}</div>
        <div class="playertablecard" id="playertablecard_{PLAYER_ID}"></div>
    </div>
    <!-- END player -->
</div>

To get the layout working I also snagged the css from the hearts tutorial. This will create four boxes for each player where their played card can be shown.
#playertables {
    position: relative;
    width: 710px;
    height: 340px;
}

.cardontable {
    position: absolute;
    width: 72px;
    height: 96px;
    background-image: url('img/cards.jpg');
}

.playertablename {
    font-weight: bold;
}

.playertable {
    position: absolute;
    text-align: center;
    width: 180px;
    height: 130px;
}

.playertable_N {
    left: 50%;
    top: 0px;
    margin-left: -90px /* half of 180 */
}

.playertable_S {
    left: 50%;
    bottom: 0px;
    margin-left: -90px /* half of 180 */
}

.playertable_W {
    left: 0px;
    top: 50%;
    margin-top: -55px /* half of 130 */
}

.playertable_E {
    right: 0px;
    top: 50%;
    margin-top: -55px /* half of 130 */
}

.playertablecard {
    display: inline-block;
    position: relative;
    margin-top: 5px;
    width: 72px;
    height: 96px;
}

.playertablename {
    font-weight: bold;
}

Note, I said can be shown. That's because while listening to the notification will remove the card from the player's hand and show the card moving to the table. We won't keep track of the cards that have been played to the table and show them. For that, we need one more piece.

Show cards on the table

Back in the "Support action call" section I mentioned how the playCard method moved the card to the desired location. Just to help connect the dots, here's the specific call I'm talking about:
$this->cards->moveCard($card_id, 'cardsontable', $player_id);

The moveCard function is part of the Deck component and the location value is just a string value that can be anything you want. So the cards that are in the "cardsontable" location need to be passed over as part of the getAllDatas so the front end can work with it.
$result['cardsontable'] = $this->cards->getCardsInLocation('cardsontable');

Then in the front end (<game_name>.js file) in the setup method we need to show the cards on the table.
for (let i in gamedatas.cardsontable) {
    const card = gamedatas.cardsontable[i];
    const suit = card.type;
    const value = card.type_arg;
    var player_id = card.location_arg;
    this.playCardOnTable(player_id, suit, value, card.id);
}

The playCardOnTable function is just the guts of the notif_playCard function extracted to a new method since the same thing was being done in both locations. That should get it so the current play can play a card to the table, but it will stay with the same player and prompt them to play another card.

Advance to the next player

For this to happen we want another state that will change the active player. Again, we're adding a state, so we need something to transition to it - we'll update "playerTurn" with a new transition.
21 => [
    "name" => "playerTurn",
    "description" => clienttranslate('${actplayer} must play a card'),
    "descriptionmyturn" => clienttranslate('${you} must play a card'),
    "type" => "activeplayer",
    "args" => "argPlayerTurn",
    "possibleactions" => ["playCard"],
    "transitions" => ["cardPlayed" => 22]
],

22 => [
    "name" => "nextPlayer",
    "description" => "",
    "type" => "game",
    "action" => "stNextPlayer",
    "updateGameProgression" => true,
    "transitions" => ["nextPlayer" => 21]
],

For the new transition to be used, the playCard function needs to be updated to use it.
$this->gamestate->nextState("cardPlayed");

The stNextPlayer function should go into the "Game state action" section and handle moving to the next player.
function stNextPlayer() {
    $player_id = self::activeNextPlayer();
    self::giveExtraTime($player_id);
    $this->gamestate->nextState("nextPlayer");
}

I saw the giveExtraTime call in the Hearts tutorial and assumed it was a good thing to do, but I haven't looked into it.

Handle finishing a trick

Now that it's advancing to the next player it needs to gather the cards when everyone has played one. Again this is going to skip some logic in the interest of keeping focused on the basic behavior. So let's not worry about who won the trick and just have someone gather them and keep going around the table playing cards. This involves updating the stNextPlayer function.
function stNextPlayer() {
    if ($this->cards->countCardInLocation('cardsontable') == 4) {
        $winning_player_id = self::activeNextPlayer();
        $this->cards->moveAllCardsInLocation('cardsontable', 'cardswon', null, $winning_player_id);

        $players = self::loadPlayersBasicInfos();
        self::notifyAllPlayers('trickWin', clienttranslate('${player_name} wins the trick'), [
            'player_id' => $winning_player_id,
            'player_name' => $players[ $winning_player_id ]['player_name']
        ]);
        self::notifyAllPlayers('giveAllCardsToPlayer','', [
            'player_id' => $winning_player_id
        ]);

        $this->gamestate->nextState("nextTrick");
    } else {
        // Not end of trick or hand, so move to the next player
        $player_id = self::activeNextPlayer();
        self::giveExtraTime($player_id);
        $this->gamestate->nextState("nextPlayer");
    }
}

Now if four cards have been played it pretends the next player won the trick and moves them to the "cardswon" location for that player. Then it sends out two notifications. The first lets all the players know about who one the trick and gives a little pause to give players a chance to see the hand. So in the <game_name>.js file an update is made to the setupNotifications function and the implementation of the function supporting the subscription to the notification.
dojo.subscribe('trickWin', this, "notif_trickWin");
this.notifqueue.setSynchronous('trickWin', 1000);

notif_trickWin : function(notif) {
    // We do nothing here (just wait so players can view the 4 cards played before they're gone).
}

The second notification is to show the gathering of the cards and make room for the next trick of cards to be played.
dojo.subscribe('giveAllCardsToPlayer', this, "notif_giveAllCardsToPlayer");

notif_giveAllCardsToPlayer : function(notif) {
    var winner_id = notif.args.player_id;
    for ( var player_id in this.gamedatas.players) {
        var anim = this.slideToObject('cardontable_' + player_id, 'overall_player_board_' + winner_id);
        dojo.connect(anim, 'onEnd', function(node) {
            dojo.destroy(node);
        });
        anim.play();
    }
},
This makes use of a few things that I haven't dug much into, but are all related to the dojo framework. I'm not going to dig further into that now.

Conclusion

Now all of the cards can be played out, but it won't wrap up the trick and deal out another hand. Seems before starting that I should fix up some of this playing and figure out who won a trick and enforce following of suit.