Saturday, January 6, 2024

Make Playing a Little Easier

Overview

Finally getting to the point where the game can be played most of the way through. Scoring is available for all but two of the games and which games have been selected are shown for each player. The next step I wanted to take was to make the gameplay a little quicker, so let's get into it.

Player preference to not verify card play

Currently, to play a card the player must select the card, then click the "Play card" button. Some players (and certainly me while testing it) will just want the cards to be played when they are selected. Since this is an individual player preference it should be handled with the user preferences. Which involved creating a new gamepreferences.json file and defining the preference with some details about its behavior.
{
    "100": {
        "name": "Prompt for card play confirmation",
        "needReload": true,
        "values": {
            "1": {
                "name": "Enabled"
            },
            "2": {
                "name": "Disabled"
            }
        },
        "default": 1
    }
}

All of the changes I wanted to make related to this were in the front end and referencing this preference is done through this.prefs[100]. The first thing I did was to not show the action button to the player. This change was done in the onUpdateActionButtons function.
case 'playerTurn':
    if (this.prefs[100].value == 1) {
        this.addActionButton('btnPlayCard', _('Play card'), 'onBtnPlayCard');
    }
    break;

I check if the value is 1, indicating it's enabled and that we want to show the action button.  Then I want to play a card when it is selected. In the setup function, we want to add a listener to the player's hand.
if (this.prefs[100].value == 2) {
    dojo.connect(this.playerHand, 'onChangeSelection', this, 'onHandCardSelect');
}

In this case, we're checking if the value is 2, indicating it's disabled and the action button won't be shown. Here's the onHandCardSelection function.
onHandCardSelect: function(control_name, item_id) {
    console.log("onHandCardSelect listener");
    if (!this.isCurrentPlayerActive()) return;
    if (item_id === undefined) return;

    this.onBtnPlayCard();
},

The check for undefined short circuits this function the second time it is called - when the card is unselected.

Finally, there's the case when someone has preselected the card they want to play and we want to play it as soon as it gets to their turn. This means a change to the onEnteringState function.
onEnteringState: function( stateName, args ) {
    console.log( 'Entering state: '+stateName );
    switch( stateName )
    {
    case 'playerTurn':
        if (this.isCurrentPlayerActive()) {
            if (this.prefs[100].value == 2) {
                const selected_cards = this.playerHand.getSelectedItems();
                if (selected_cards.length === 1) {
                    this.onBtnPlayCard();
                }
            }
        }
        break;
 
    case 'dummmy':
        break;
    }
},

End a hand early when there are no more points

Some games need to be played all the way through, but others can have all of the points in the hand come out in the first round. It's a little tedious to need to play through the rest of the cards when nothing will come of it. Since this will vary based on the game selected it seems like a good place to revisit the scorer classes I created for calculating how many points each player took in their hands. I added a new remainingPoints function that returns true if there are cards in hand that score points for the game. Let's take a look at the implementation for the game Queens.
function remainingPoints(array $cards_in_hands): bool {
    foreach ($cards_in_hands as $card) {
        if ($card['type_arg'] == QUEEN || ($card['type'] == HEART && $card['type_arg'] == KING)) {
            return true;
        }
    }
    return false;
}

This checks the given array of cards to see if there are any Queens or the King of Hearts. This will be used in the stNextPlayer function in the <game_name>.game.php file.
if ($this->cards->countCardInLocation(HAND) == 0) {
    $this->gamestate->nextState("endHand");
} else {
    $scorer = $this->getScorer();
    $cards_in_hands = $this->cards->getCardsInLocation(HAND);
    if (!$scorer->remainingPoints($cards_in_hands)) {
        $cards_left = [];
        $players = $this->loadPlayersBasicInfos();
        foreach ($players as $player_id => $player) {
            $cards_left_list = [];
            $hand = $this->cards->getCardsInLocation(HAND, $player_id);
            usort($hand, [$this, "sortCards"]);
            foreach ($hand as $card) {
                $cards_left_list[] = $this->suits[$card['type']]['name'].''.$this->values_label[$card['type_arg']];
            }
            $cards_left[] = self::getPlayerNameById($player_id).' - '.implode(', ', $cards_left_list);
        }
        $cards_left_final = implode('<br>', $cards_left);
        self::notifyAllPlayers('earlyEnd', clienttranslate('Ending the hand early as all scoring cards are out<br><br>Cards left:<br>${cards_left}'), [
            'cards_left' => $cards_left_final,
            'remaining_cards' => $cards_in_hands,
        ]);
        $this->gamestate->nextState("endHand");
    } else {
        $this->gamestate->nextState("nextTrick");
    }
}

If no points are remaining in anyone's hand, this provides a notification to inform players what cards are still out there. The front end will make use of that notification to update where the cards are visually. As is usual the <game_name>.js file needs to be updated in two places. The first is adding a subscription to the "earlyEnd" notification in the setupNotifications function. The next is the implementation of the function called for that notification - notif_earlyEnd.
notif_earlyEnd: function(notif) {
    for (let i in notif.args.remaining_cards) {
        const card = notif.args.remaining_cards[i];
        this.playCardOnTable(card.location_arg, card.type, card.type_arg, card.id);
    }

    document.querySelectorAll('.cardontable').forEach(e => this.slideToObjectAndDestroy(e, 'playertables'));
},

This puts all of the cards onto the table, then slides them to the "playertables" container, which holds the played cards and removes them from view.

Conclusion

I thought checking for point cards was going to be harder than it ended up being. I started exploring creating a Card class to encapsulate checking for equality and seeing if I could make it read better when they were created, but I ended up giving up on it. It seemed a small improvement for the work, though maybe I'll have another idea around it in the future.

There are still some things that might speed up the gameplay - for instance, recognizing if the player can't lose the rest of the tricks - but I think I want to be able to finish a game. That means finishing up the scoring for Guillotine and finally getting around to dealing with Dominoes. That might need to be split over a couple of sessions as it plays completely differently.

Wednesday, January 3, 2024

Continue to Next Hand

 

Overview

Currently, one hand can be played through, but it won't continue through a full game. Some simple tweaks need to be done to allow the next hand of cards to be dealt out, but the majority of the work is figuring out how to keep track of the games that a player has played.

Deal out the next hand

Allow the "endHand" state to transition to the "newHand" state.
30 => [
"name" => "endHand",
"description" => "",
"type" => "game",
"action" => "stEndHand",
"transitions" => ["nextHand" => 2]
],

Update the stEndHand function to follow that transition by adding $this->gamestate->nextState("nextHand"); to the end of it. This moved to the next hand but didn't deal out the cards to the players because the cards weren't in the deck. To get them requires updating the stNewHand function to move all the cards into the deck by calling $this->cards->moveAllCardsInLocation(null, DECK); before the cards are shuffled. Passing null as the first argument gathers cards from all locations. I created a constant for the 'deck' location to avoid typos.

Finally, to get the cards showing on the front end it needs to subscribe to the "newHand" notification, which the supporting function looks like the following.
notif_newHand : function(notif) {
// We received a new full hand of 8 cards.
this.playerHand.removeAll();
for ( var i in notif.args.cards) {
var card = notif.args.cards[i];
var color = card.type;
var value = card.type_arg;
this.playerHand.addToStockWithId(this.getCardUniqueId(color, value), card.id);
}
},

Now things should look appropriate after finishing a hand in the game.

Deadlock error

While playing through I encountered a deadlock error that seemed to be related to accessing state values. This suggested to me that maybe I had entered the action for a player before the previous action had fully been processed. So I introduced some synchronous notifications with some delays. The delays will help give players time to process what changed in the game. I'm also assuming synchronous notifications require that all clients have processed the notification before moving on, which should help with that deadlock as well.

Keep track of games played

To indicate that a game has been played there needs to be some way of keeping track of when they are selected.  I considered trying to use game states to keep track of this, but that seemed unwieldy at best and I wasn't seeing a great way to get the state of all of the games. In addition, this seemed like a good time to explore creating a database table (besides the instructions given in the Hearts demo).
CREATE TABLE IF NOT EXISTS `player_game` (
    `player_game_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
    `player_id` INT NOT NULL,
    `game_id` SMALLINT NOT NULL,
    `played` BOOL NOT NULL DEFAULT 0,
    PRIMARY KEY (`player_game_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ;

When the game is initialized this new table needs to be populated with all the games for each player. So in the setupNewGame function I called the following. (Technically, I pulled it out to a separate function...)
// Populate games to play for player
$players = $this->loadPlayersBasicInfos();
$game_sql = "INSERT INTO player_game (player_id, game_id) VALUES ";
$game_values = [];
foreach ($players as $player_id => $player) {
    foreach ($this->games as $game_id => $game) {
        $game_values[] = "('$player_id', '$game_id')";
    }
}
$game_sql .= implode($game_values, ',');
self::DbQuery($game_sql);

Next, I wanted to rely on those entries to populate the available games for the player to pick from. Way back when that logic was being wired up I'd hard-coded the values in the argSelectGame function. So now it will pull the games from the database.
function argSelectGame() {
    $player_id = self::getActivePlayerId();

    return [
        "available_games" => $this->gameStates($player_id, true)
    ];
}

I pulled most of the logic out to a separate function since I wanted to also know which games had been played as part of the setup.
function gameStates($player_id, $available_only = false) {
    $game_sql = "SELECT player_game_id, player_id, game_id, played FROM player_game WHERE player_id=$player_id";

    if ($available_only) {
        $game_sql .= " AND played=0";
    }

    $available_games = self::getCollectionFromDb($game_sql);
    $game_states = [];
    foreach ($available_games as &$player_game) {
        $current_game = $this->games[$player_game['game_id']];
        $game_states[] = [
            'player_id' => $player_id,
            'game_id' => $player_game['game_id'],
            'game_type' => $current_game['type'],
            'game_name' => $current_game['name'],
            'played' => $player_game['played']
        ];
    }

    return $game_states;
}

Understanding DB queries

I'm showing the finished product, but when I first put this together I didn't have the player_game_id column in the database. I had defined the player_id and game_id columns as a joined primary key. So when I was testing getting the tables for all players I was only getting one game for each, not six.

That's because by default the DB query tool returns an associative array using the first column in the query as the key. (You can see this in the documentation for thegetCollectionFromDB function.)

I opted to add a separate primary key and add it to the query to avoid the issue.

Show game selection state

While it's important to show the player the correct games that they have remaining to select from, invariably others want to know this as well. It also helps to understand how far through the game folks are if they can see everyone's options. BGA has player panel sections intended to provide a nice summary of the current game state. So let's add the games for each player to that and show if they've been played or not.

The first step was to make the data available as part of the getAllDatas function in the back end.
$player_game_states = [];
foreach ($result['players'] as $player) {
    $player_game_states[$player['id']] = $this->gameStates($player['id']);
}
 
$result['player_game_states'] = $player_game_states;

This builds up an array of game states for each player which will be walked through in the <game_name>.js file. The 'player_board_' + player_id is the ID attribute for the player panel for that particular player. See the documentation around the player panel for more details.
// Setting up player boards
for( var player_id in gamedatas.players )
{
    var player = gamedatas.players[player_id];
 
    for (var i in gamedatas.player_game_states[player_id]) {
        var game_state = gamedatas.player_game_states[player_id][i];
        dojo.place(this.format_block('jstpl_game_display', {
            player_id: player_id,
            game_type: game_state.game_type,
            game_name: game_state.game_name,
            game_abbr: game_state.game_name.substring(0, 1),
            played: ((game_state.played === '1') ? 'played' : 'available')
        }), 'player_board_' + player_id);
        this.addTooltipToClass('game_display_' + game_state.game_type, _(game_state.game_name), '');
    }
}

This is making use of a template variable "jstpl_game_display" that is added to the <game_name>_<game_name>.tpl file. I'm not certain how I populate game_abbr is appropriate for translations, but figured that the tooltip giving the full name should be sufficient for getting players around it.
var jstpl_game_display = '<div id="glt_game_${game_type}_${player_id}" class="game_display game_display_${game_type} ${played}">${game_abbr}</div>';

Finally, add a little style to the <game_name>.css file so that the games take up a single row and provide a visual clue if they've been played.
.game_display {
    font-size: smaller;
    font-weight: bold;
    float: left;
    margin-left: 10px;
}
 
.game_display.played {
    font-weight: normal;
    text-decoration: line-through;
    color: gray;
}

Update the player panel when they select a game

When the player has selected a game the system needs to record the decision they made. When I was going about doing this I opted to update how games were defined in the material.inc.php file to key off from the ID instead of the type. In truth, this change was needed to implement the gameStates function above and I forgot to mention it.
$this->games = [
    1 => [
        'type' => 'parliament',
        'name' => clienttranslate('Parliament')
    ],
    2 => [
        'type' => 'spades',
        'name' => clienttranslate('Spades')
    ],
    3 => [
        'type' => 'queens',
        'name' => clienttranslate('Queens')
    ],
    4 => [
        'type' => 'royalty',
        'name' => clienttranslate('Royalty')
    ],
    5 => [
        'type' => 'dominoes',
        'name' => clienttranslate('Dominoes')
    ],
    6 => [
        'type' => 'guillotine',
        'name' => clienttranslate('Guillotine')
    ],
];

This led to the gameSelection function being updated to be the following.
function gameSelection($selected_game) {
    self::checkAction("gameSelection");

    $player_id = self::getActivePlayerId();

    $game_id = null;
    $game_name = null;
    foreach ($this->games as $id => $game) {
        if ($game['type'] == $selected_game) {
            $game_id = $id;
            $game_name = $game['name'];
            break;
        }
    }
 
    self::setGameStateValue(SELECTED_GAME, $game_id);
    $this->recordSelectedGame($player_id, $game_id);

    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,
            'game_type' => $selected_game,
        ]
    );

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

The significant changes to it were adding the call to recordSelectedGame (which will be shown next) and adding the game_type to the notification so that we can find the appropriate game in the view to show that it's been selected. Here's the recordSelectedGame function.
function recordSelectedGame($player_id, $game_id) {
  self::DbQuery("UPDATE player_game SET played=1 WHERE player_id='$player_id' AND game_id='$game_id'");
}

Finally, in the front end, the notif_gameSelection function is updated to add the "played" class to the element for a different styling.
document.getElementById('glt_game_' + notif.args.game_type + '_' + notif.args.dealer_id)
    .classList.add('played');

Conclusion

There's still plenty to do to make this function nicely, but it can be played all the way through and it keeps track of the games the player has played.

Granted the game doesn't reach an ending state, but I think before that I want to add in some nice to-haves to make it a little easier to play. Some current thoughts:
  • Provide an option to play the card when clicked instead of requiring the player to click the card, and then the play card button.
  • When a hand has no more points to gain in it, provide a way to end the hand right away.
Hopefully, adding those features will make testing it a little easier too. :)

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.