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. :)

No comments: