Monday, January 29, 2024

Enable Guillotine Scoring and Stat Gathering

 

Overview

After finishing the scoring for Dominoes, that leaves scoring for Guillotine for all of the games to be completed. That shouldn't take much to complete, so I'll also dig into collecting stats for the game.

Guillotine Scoring

Guillotine follows similar rules to the other non-Dominoes games only with more things scoring points. The catch is that the first and last tricks are worth 5 points each just for winning them. Building on the existing GLTScorer interface I opted to pass in an optional trick_winners array.
class GLTGuillotineScorer implements GLTScorer {
    function remainingPoints(array $cards_in_hands): bool {
        if (count($cards_in_hands) > 0) {
            return true;
        }
        return false;
    }

    function score(array $player_ids, array $won_cards, array $trick_winners = null): array {
        $player_to_points = [];
        foreach ($player_ids as $player_id) {
            $player_to_points[$player_id] = 0;
        }

        $player_to_points[$trick_winners[FIRST_TRICK_WINNER]] += 5;
        $player_to_points[$trick_winners[LAST_TRICK_WINNER]] += 5;

        foreach ($won_cards as $card) {
            $player_id = $card['location_arg'];

            if ($card['type'] == SPADE) {
                $player_to_points[$player_id] += 5;
            }

            if ($card['type_arg'] == QUEEN) {
                $player_to_points[$player_id] += 10;
            }

            if ($card['type'] == HEART && $card['type_arg'] == KING) {
                $player_to_points[$player_id] += 10;
            }
        }

        return $player_to_points;
    }
}

Now that parameter needs to be populated. I'm less thrilled with this since it requires another place to know which game was selected, but I opted to update the stEndHand function where the scoring is calculated.
$selected_game_id = self::getGameStateValue(SELECTED_GAME);

$trick_winners = null;
if ($selected_game_id == 6) {
    // Guillotine needs to know the winner of the first and last tricks
    $trick_winners = [
        FIRST_TRICK_WINNER => self::getGameStateValue(FIRST_TRICK_WINNER),
        LAST_TRICK_WINNER => self::getGameStateValue(LAST_TRICK_WINNER)
    ];
}

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

As can be seen by the use of self::getGameStateValue I opted to use game state variables to keep track of the first and last trick winners. Which means they need to be defined as part of the __construct in the <game_name>.game.php file. Then kept track of in the stNextPlayer function.
// Guillotine is the only game that worries about this.
if ($this->cards->countCardInLocation(CARDS_WON) == 0) {
    self::setGameStateValue(FIRST_TRICK_WINNER, $winning_player_id);
}
 
...
 
if ($this->cards->countCardInLocation(HAND) == 0) {
    // Guillotine is the only game that cards about LAST_TRICK_WINNER
    self::setGameStateValue(LAST_TRICK_WINNER, $winning_player_id);
    $this->gamestate->nextState("endHand");
} else {
 
...

And finally, reset them just to be safe in the resetTrackingValues function that I'm calling from the stEndHand function.
function resetTrackingValues() {
     self::setGameStateValue(SELECTED_GAME, 0);

    // Dominoes
    self::setGameStateValue(OUT_FIRST, 0);
    self::setGameStateValue(OUT_SECOND, 0);
    self::setGameStateValue(SPINNER, 0);

    // Guillotine
    self::setGameStateValue(FIRST_TRICK_WINNER, 0);
    self::setGameStateValue(LAST_TRICK_WINNER, 0);
}

After doing this it occurred to me that the scorer classes that have remainingPoints calculation might be better if they just check if the the won tricks have all of the points in them. Making that change seems like it might help with validating things. Currently, I'm just assuming that everything is fine, but it's usually better to be a bit more pessimistic and do more validation to ensure things haven't gotten into an unexpected state. Something I'll think more about as I continue on.

Progress Tracking

According to the documentation around the states, at least one of them should update the game progression. I originally had this after each players turn, but reduced it to only be updated at the end of a turn, which is done by setting updateGameProgression => true inside of the configuration for a state. Having that will automatically call the getGameProgression function that is pre-generated to return 0 by default. Since there are 24 games to be played it seemed a simple enough equation to use the count of the games that were played.
function getGameProgression() {
    $game_sql = "SELECT count(*) count FROM player_game WHERE played = 1";

    $played_games = self::getUniqueValueFromDB($game_sql);

    return ($played_games / 24) * 100;
}

End the game

To see any statistics the game needs to come to an end. It seems to make sense to do this at the end of a hand. Given how I've implemented things this could be one of two states - endHand or dominoesEndHand - so I could either duplicate the check or create a separate state. I went with the separate state, though I'm not really sure what the tradeoffs are between the choices. (Probably, less database writing of things and back and forth with the front end if I duplicated the check, so maybe I'll change that in the future.)

I created checkEndGame and updated endHand and dominoesEndHand to point to it.
31 => [
    "name" => "checkEndGame",
    "description" => "",
    "type" => "game",
    "action" => "stCheckEndGame",
    "transitions" => ["newHand" => 2, "endGame" => 99]
],

Then the implementation of the stCheckEndGame function is just checking how many played games there are.
function stCheckEndGame() {
    $game_sql = "SELECT count(*) count FROM player_game WHERE played = 1";
    $played_games = self::getUniqueValueFromDB($game_sql);

    if ($played_games == 24) {
        $this->gamestate->nextState("endGame");
    } else {
        $this->gamestate->nextState("newHand");
    }
}

Stats

Stats are defined in a stats.json file and have some documentation describing how to set them up and how to use them.

Points per game played

The desire is to know how you did within each type of game played. So for each game, I added a stat to keep track of the number of points the player got for it.
{
    "player": {
        "points_from_parliament": {
            "id": 11,
            "name": "Points from Parliament",
            "type": "int"
        },
        "points_from_spades": {
            "id": 12,
            "name": "Points from Spades",
            "type": "int"
        },
        "points_from_queens": {
            "id": 13,
            "name": "Points from Queens",
            "type": "int"
        },
        "points_from_royalty": {
            "id": 14,
            "name": "Points from Royalty",
            "type": "int"
        },
        "points_from_dominoes": {
            "id": 15,
            "name": "Points from Domnioes",
            "type": "int"
        },
        "points_from_guillotine": {
            "id": 16,
            "name": "Points from Guillotine",
            "type": "int"
        }
}

After updating this you need to press the "Reload statistics configuration" button on the manage game page otherwise, they don't show up. Though for the stats to have any values you need to populate them which is done in a couple of places in the <game_name>.game.php file. The first is just initializing them in the setupNewGame function.
self::initStat('player', POINTS_FROM_PARLIAMENT, 0);
self::initStat('player', POINTS_FROM_SPADES, 0);
self::initStat('player', POINTS_FROM_QUEENS, 0);
self::initStat('player', POINTS_FROM_ROYALTY, 0);
self::initStat('player', POINTS_FROM_DOMINOES, 0);
self::initStat('player', POINTS_FROM_GUILLOTINE, 0);

The real work is updating the stat as hands are scored. Since I created the updateScores function for doing this I made the update there.
self::incStat($points, $game_stat, $player_id);

That line is deceptively simple as it required adding a $game_stat parameter to the updateScores function. Then an update to the two locations that call it. Where it's called in the stDominoesEndHand function the parameter is just hard coded to points_from_dominoes. The other call is in the stEndHand function, which makes use of the GTLScorer objects. So I added another gameStat function to those that would return the appropriate value for the scorer being used.
$this->updateScores($players, $player_to_points, $scorer->gameStat());

Points on specific player's calls

This got a little tricky as what I really wanted was a stat that connects to players, but I couldn't find something that allowed me to dynamically name stats. So instead, I called them points_on_own_calls and points_on_player_1_calls which isn't as clear. So I added these to the stats.json file.
"points_on_own_calls": {
    "id": 17,
    "name": "Points on own calls",
    "type": "int"
},
"points_on_player_1_calls": {
    "id": 18,
    "name": "Points from player to the left calls",
    "type": "int"
},
"points_on_player_2_calls": {
    "id": 19,
    "name": "Points from second player to the left calls",
    "type": "int"
},
"points_on_player_3_calls": {
    "id": 20,
    "name": "Points from player to the right calls",
    "type": "int"
}

Then updated the updateScores function to figure out who the dealer of the game was in relation to the player being scored.
function updateScores($players, $player_to_points, $game_stat) {
    $dealer_id = self::getGameStateValue(DEALER);
    $player_1_id = self::getPlayerAfter($dealer_id);
    $player_2_id = self::getPlayerAfter($player_1_id);
    $player_3_id = self::getPlayerAfter($player_2_id);

    foreach ($player_to_points as $player_id => $points) {
        self::incStat($points, $game_stat, $player_id);

        $player_call = POINTS_ON_OWN_CALLS;
        if ($player_id == $player_1_id) $player_call = POINTS_ON_PLAYER_3_CALLS;
        else if ($player_id == $player_2_id) $player_call = POINTS_ON_PLAYER_2_CALLS;
        else if ($player_id == $player_3_id) $player_call = POINTS_ON_PLAYER_1_CALLS;

        self::incStat($points, $player_call, $player_id);

        $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]);
}

This works, but my head hurts a little looking at it and I should probably rename the variables to reduce some of the confusion. The first part gets the player IDs in clockwise order from the dealer. Then in the foreach block, it figures out which of those IDs match the current player being scored, which determines where the dealer sits in relation to them.

Advance game state

Since functions can be called from the chat, I decided to create a function that would move the game to the final round.
function advanceGameState() {
    $players = self::loadPlayersBasicInfos();
    $player_ids = array_keys($players);

    for ($i=0; $i<20; $i++) {
        $current_player_id = $player_ids[($i % 4)];
        $current_game_id = ($i % 6) + 1;
        // After 12 the games being played repeat, so offsetting it by one.
        if ($i >= 12) {
            $current_game_id = (($i + 1) % 6) + 1;
        }
        $current_game = $this->games[$current_game_id];

        // Update games played
        $this->recordSelectedGame($current_player_id, $current_game_id);

        // Update scores
        $player_to_points = [];
        foreach ($player_ids as $player_id) {
            $player_to_points[$player_id] = 0;
        }

        $point_total = 0;
        switch ($current_game_id) {
            case 1:
                $point_total = -50;
                break;
            case 2:
                $point_total = 30;
                break;
            case 3:
                $point_total = 30;
                break;
            case 4:
                $point_total = 30;
                break;
            case 5:
                $point_total = -40;
                break;
            case 6:
                $point_total = 100;
                break;
        }

        $player_to_points[$current_player_id] = $point_total;

        $this->updateScores($players, $player_to_points, 'points_from_'.$current_game['type']);
    }
}

I could probably make it a little more sophisticated, but my first pass just gives the calling player all of the points for that game. Additionally, the browser needs to be refreshed after running it to see the update completion state and the visual representation of the games that have been selected.

Conclusion

The game can be played to the end and a score with stats can be output. Now we're digging into the nice to-haves and things that will make playing better. Currently on my list are:
  • Allow a player to undo the card they just played. It's not ideal, but it happens and I know I hate it when a game doesn't allow me to fix a silly mistake that I would've in person. Though I know others are more strict, so perhaps this would be a game option to allow this.
  • Allow a player preference for automatically passing if the player cannot play.

I also think there's some refactoring I should do, maybe go back over my notes and revisit those places where I made design decisions and see if they still hold up. Not to mention there's game metadata that I've been lax about and I should update.

Tuesday, January 16, 2024

Enable Scoring of Dominoes

 

Overview

In the last bit of work, I enabled the basic playing of Dominoes and the placement of cards. However, as noted, there were things to finish before the game could be finished. I'll take these on in order:
  • When an Ace is played the player can play as many cards as they want to - as long as they're valid - before passing.
  • Need to end the game, which involves scoring it.
  • Need to tighten up the passing and at least know if the player passed when they could've played.

Allow the player playing an Ace to keep playing until they pass

My thinking for this is to add a new game state variable to keep track that an ace has been played in the playCard function. That variable can be checked when advancing to the next player and skip doing that when it's true. Finally, in the pass action, it will be cleared to allow the game to continue to the next player again. There's a final catch to this, when the Ace is a Spinner it loses the keep-playing ability, but that's a simple enough check.

Update the __construct function to add a new game state label for the ACE_PLAYED label.
self::initGameStateLabels([
    DEALER => 10,
    SELECTED_GAME => 11,
    TRICK_SUIT => 12,
    SPINNER => 13,
    ACE_PLAYED => 14,
]);

In the playCard function add a check to record if an Ace was played only if the game is Dominoes.
if (($current_card['type_arg'] == ACE) && (self::getGameStateValue(SPINNER) != ACE)) {
    self::setGameStateValue(ACE_PLAYED, 1);
}

Update the stDominoesNextPlayer function to skip advancing to the next player if an Ace was played.
function stDominoesNextPlayer() {
    if (!self::getGameStateValue(ACE_PLAYED)) {
        $player_id = self::activeNextPlayer();
        self::giveExtraTime($player_id);
    }

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

Finally, the update to remove the indication that an Ace was played.
self::setGameStateValue(ACE_PLAYED, 0);

After trying that out, I realized it might be good to change the message using the action to indicate they are working through an Ace to give players an additional clue as to why a player is getting repeated turns in a row. This involved updating the argDominoesPlayerTurn function.
$play_message = clienttranslate('a card or pass');
if (self::getGameStateValue(ACE_PLAYED)) {
    $play_message = $play_message . clienttranslate(' (using Ace)');
}
return [
    'play_message' => $play_message,
    'passable' => true,
];

Enable scoring the game

The game of Dominoes ends when the first two players run out of cards. The first player gets -30 and the second gets -10. I created those `Scorer` classes for the other games, but I'm not sure it's worth trying to make this fit into the mold of that interface since that's not a good way to keep track of who went out first. Instead, I'm thinking of using some more game state variables that can be set in the stDominoesNextPlayer function. (Don't forget the need to define the label in the __constructor.)
function stDominoesNextPlayer() {
    $end_hand = false;

    $hand = $this->cards->getCardsInLocation(HAND, $player_id);
    if (count($hand) === 0) {
        if (self::getGameStateValue(OUT_FIRST)) {
            self::setGameStateValue(OUT_SECOND, $player_id);
            $end_hand = true;
        } else {
            self::setGameStateValue(OUT_FIRST, $player_id);
        }
    }

    if ($end_hand) {
        $this->gamestate->nextState("endHand");
    } else {
        if (!self::getGameStateValue(ACE_PLAYED)) {
            $player_id = self::activeNextPlayer();
            self::giveExtraTime($player_id);
        }

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

After writing that up I realized the next state is endHand from the original flow, which is where scoring is handled. So I either need to implement a DominoesScorer, create a different state for ending Dominoes, or hack the stEndHand function to do something a little different for Dominoes.

I'm going to create a separate state for Dominoes end hand because that follows the pattern I've used with most of the other parts of this. (Even though I'm thinking I should take another look and maybe bring it back together.) So update the endHand transitions in the dominoesNextPlayer state to point to this new state.
43 => [
    "name" => "dominoesEndHand",
    "description" => "",
    "type" => "game",
    "action" => "stDominoesEndHand",
    "transitions" => ["nextHand" => 2]
],

I extracted the main part of the stEndHand function so I could reuse it.
function updateScores($player_to_points) {
    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]);

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

Then call it from the stEndHand and stDominoesEndHand functions.
function stDominoesEndHand() {
    $players = self::loadPlayersBasicInfos();
    $player_to_points = [];
    foreach (array_keys($players) as $player_id) {
        $player_to_points[$player_id] = 0;
    }

    $player_to_points[self::getGameStateValue(OUT_FIRST)] = -30;
    $player_to_points[self::getGameStateValue(OUT_SECOND)] = -10;

    $this->updateScores($player_to_points);
}

Because we set some game state variables to keep track of things and there are a bunch of cards shown on the table, we need to do a little cleanup.

In the stNewHand function, we were already resetting the game state tracking the selected game, so it seemed appropriate to put all of it together into a separate function.
function resetTrackingValues() {
    self::setGameStateValue(SELECTED_GAME, 0);
 
    // Dominoes
    self::setGameStateValue(OUT_FIRST, 0);
    self::setGameStateValue(OUT_SECOND, 0);
    self::setGameStateValue(SPINNER, 0);
}

To clean up the cards shown on the table I opted to add a notification to the stDominoesEndHand function.
self::notifyAllPlayers('cleanUp', '', []);

Then subscribe to that on the front end and create the supporting function that just slides and destroys all the cards from the view.
notif_cleanUp: function(notif) {
    document.querySelectorAll('cardontable')
        .forEach(e => this.slideToObjectAndDestroy(e, 'playertables'));
},

Know if a player can pass

When the player goes to take their turn we can compare what they have in their hand to what's on the board to determine if they have any playable cards. I started by focusing on determining which cards would be playable and created a function that accepted the cards in the player's hand and the cards that were on the table.
function getPlayableCardsForDominoes($hand, $played_cards) {
    // Nothing played then everything is possible
    if (count($played_cards) === 0) return array_keys($hand);

    // Initialize possible cards to the spinners.
    $spinner_value = self::getGameStateValue(SPINNER);
    $possible_cards = [];
    foreach ($this->suits as $suit_id => $suit) {
        $possible_cards[$suit_id]['high'] = $spinner_value;
        $possible_cards[$suit_id]['low'] = $spinner_value;
    }

    // Find possible cards one higher and one lower than those played.
    foreach ($played_cards as $card) {
        $suit = $card['type'];
        $value = $card['type_arg'];
        $high_value = (int)$value + 1;
        $low_value = (int)$value - 1;
        if ($possible_cards[$suit]['high'] < $high_value) {
            $possible_cards[$suit]['high'] = $high_value;
        }
        if ($possible_cards[$suit]['low'] > $low_value) {
            $possible_cards[$suit]['low'] = $low_value;
        }
    }

    // Find cards in the player's hand that match the possible cards.
    $playable_card_ids = [];
    foreach ($hand as $card) {
        $suit = $card['type'];
        $value = $card['type_arg'];
        if (($value == $possible_cards[$suit]['high']) ||
            ($value == $possible_cards[$suit]['low']) ||
            ($value == $spinner_value))
        {
            $playable_card_ids[] = $card['id'];
        }
    }

    return $playable_card_ids;
}

Then use this function in the argDominoesPlayerTurn function. This created some significant changes to this function, so here's how it ended up.
function argDominoesPlayerTurn() {
    $cards_in_hands = $this->cards->getCardsInLocation(HAND);
    $hand = $this->cards->getCardsInLocation(HAND, self::getActivePlayerId());

    if (count($cards_in_hands) == 32) {
        return [
            'play_message' => clienttranslate('play the spinner'),
            'passable' => false,
            '_private' => ['active' => ['playable_cards' => array_keys($hand)]]
        ];
    }

    $cards_on_table = $this->cards->getCardsInLocation(CARDS_ON_TABLE);
    $playable_card_ids = $this->getPlayableCardsForDominoes($hand, $cards_on_table);

    if (count($playable_card_ids) === 0) {
        // the player must pass
        return [
            'play_message' => clienttranslate('pass'),
            'passable' => true,
            '_private' => ['active' => ['playable_cards' => $playable_card_ids]]
        ];
    }

    // the player must play a card
    $play_message = clienttranslate('play a card');
    $passable = false;

    if (self::getGameStateValue(ACE_PLAYED)) {
        $play_message = $play_message . clienttranslate(' or pass (using Ace)');
        $passable = true;
    }

    return [
        'play_message' => $play_message,
        'passable' => $passable,
        '_private' => ['active' => ['playable_cards' => $playable_card_ids]]
    ];
}

The big change is if there are no playable cards, then the player must pass, which is true even if they played an Ace. If they have cards to play, then it's doing the same thing it was before - checking if they played an Ace and showing the appropriate message. Additionally, the playable_cards are being passed to the front end so that it can highlight the playable cards. Since all of the data returned from "arg" functions is public by default there's a special "_private" label to ensure it is only available to, in this case, the active player. The highlighting is done in the onEnteringState function.
case 'dominoesPlayerTurn':
    if (this.isCurrentPlayerActive()) {
        const playable_cards = args.args._private.playable_cards;
        const all_cards = this.playerHand.getAllItems();
        for (let i in all_cards) {
            if (playable_cards.includes(all_cards[i].id)) {
                document.getElementById('myhand_item_' + all_cards[i].id).classList.add('playable');
            }
        }
    }
    break;

Add the corresponding styling change.
.playable {
    border: solid green;
}

Some validation could be added to the front end to ensure that a playable card is selected, but that's a nice to have that I might do later since the back end already has that validation.

Fix an issue with a player going out twice

Whoops! Thankfully, caught an issue while I was testing it. If a player goes out and the game gets back around to them, it prompts them to play or pass. Then when they pass it treats them as going out a second time and ends the game.

This required some changes to the stDominoesNextPlayer function. The first was to check if the player was the first out before worrying if they had no cards in their hand. The next change was to skip them when making them an active player. In reality, the second part of that would be sufficient since they wouldn't be made the active player, but I'm okay with extra code to make me feel better about it.

Conclusion

Looks like Dominoes is ready to be played and I'm pretty psyched about how it came together. The last thing to do for the game to be able to be completely played is the scoring for the game Guillotine. Then I think I can get into the stats to record for this game. For instance, we've been curious about how much a player scores on each other player's calls. I suspect there are some bugs and other things that will come up, but it's getting close to being complete.

Thursday, January 11, 2024

Change How Cards are Played for Dominoes

Overview

When we last saw our hero game being developed it was just starting to handle Dominoes - wiring up some of the plumbing and being able to determine if a played card is valid. Now I want to make it look more like how it would in person, by showing the played cards in the center of the table and laid in order by suit.

Layout cards in the center of the table

The first step towards this is to create a place for the cards to go by modifying the <game_name>_<game_name>.tpl file.
<div id="playertables" class="whiteblock">
    <!-- BEGIN player -->
    <div class="playertable playertable_{DIR}">
        <div class="playertablename" style="color:#{PLAYER_COLOR}">{PLAYER_NAME}</div>
        <div id="dealer_p{PLAYER_ID}" class="playertableselectedgame">{SELECTED_GAME}</div>
        <div id="playertablecard_{PLAYER_ID}" class="playertablecard"></div>
    </div>
    <!-- END player -->
    <div id="dominoesplayarea"></div>
</div>

<div id="myhand_wrap" class="whiteblock">
    <h3>{MY_HAND}</h3>
    <div id="myhand">
    </div>
</div>

<script type="text/javascript">

// Javascript HTML templates
var jstpl_card = '<div class="card cardontable" id="cardontable_${card_id}" style="background-position:-${x}px -${y}px"></div>';
var jstpl_game_display = '<div id="glt_game_${game_type}_${player_id}" class="game_display game_display_${game_type} ${played}">${game_abbr}</div>';
</script>

The changes here are moving the "whiteblock" class from the inner div beginning the player to the playertables div and adding the dominoesplayarea div. The last change is a change to the id attribute for the jstpl_card template. Previously it was cardontable_${player_id}. Unfortunately, when the same player puts multiple cards on the table the IDs collide and the behavior gets a little funny when you try moving them around. Fortunately, nothing was looking for them based on the player so the change didn't impact much other than swapping out the player_id for card_id when building the template.

Then the styling needs to be updated to allow for cards to be put in this newly created div.
#dominoesplayarea {
    position: relative;
    width: 440px;
    height: 300px;
    top: 140px;
    left: 180px;
}

#playertables {
    position: relative;
    width: 800px;
    height: 580px;
}

This makes the playertables div take up enough space to allow for the cards to be played and puts the dominoesplayarea tucked inside of the area the player puts their cards during the other games.

Okay, let's actually put a card there

When a card is played the back end sends a playCard notification to the front end that currently makes the card visible under the player's name. I could just modify that to be aware of what game was being played, but I opted to create a different one to try to keep the understanding of what the game is on the back end. So the playCard function in the back end now looks like this.
function playCard($card_id) {
    self::checkAction("playCard");

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

    $selected_game_id = self::getGameStateValue(SELECTED_GAME);
    if ($this->games[$selected_game_id]['type'] == "dominoes") {
        $this->checkPlayableCardForDominoes($current_card);
        $this->cards->moveCard($card_id, CARDS_ON_TABLE, $player_id);

        if (!self::getGameStateValue(SPINNER)) {
            self::setGameStateValue(SPINNER, $current_card['type_arg']);
        }

        self::notifyAllPlayers(
            'playCardForDominoes',
            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,
                'spinner' => self::getGameStateValue(SPINNER)
            ]
        );

        $this->gamestate->nextState("turnTaken");
    } else {
        $this->checkPlayableCard($player_id, $current_card);
        $this->cards->moveCard($card_id, CARDS_ON_TABLE, $player_id);
 
        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");
    }
}

I realized while developing the front end that it needed to know what the spinner was to help with placing cards appropriately. (The spinner is rotated perpendicular to the other cards so players don't forget what rank it is.) Now the front end needs to handle this notification. Reminder - that involves updating the setupNotifications function to subscribe to it and indicate the function to call. I also called setSynchronous for it with a 100 delay, just like for the playCard notification. Here's the notif_playCardForDominoes function.
notif_playCardForDominoes : function(notif) {
     this.playCardInCenter(
        notif.args.player_id,
        notif.args.suit,
        notif.args.value,
        notif.args.card_id,
        notif.args.spinner
    );
},

Yeah - not too exciting, since the same logic will need to be called in the setup function, but we'll get to that after the real work is shown.
playCardInCenter : function(player_id, suit, value, card_id, spinner) {
    dojo.place(this.format_block('jstpl_card', {
        x : this.cardwidth * (value - 2),
        y : this.cardheight * (suit - 1),
        card_id : card_id
    }), 'dominoesplayarea');
 
    if (player_id != this.player_id) {
        // Some opponent played a card
        // Move card from player panel
        this.placeOnObject('cardontable_' + card_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_' + card_id, 'myhand_item_' + card_id);
            this.playerHand.removeFromStockById(card_id);
        }
    }

    dojo.style('cardontable_' + card_id, 'z-index', value);
    const x_pos = 20 + (suit-1) * 110;
    const y_pos = 200 - ((value-7) * 20) - (Number(value) > Number(spinner) ? 30 : 0);
    this.slideToObjectPos('cardontable_' + card_id, 'dominoesplayarea', x_pos, y_pos).play();

    if (value === spinner) {
        this.rotateTo('cardontable_' + card_id, 90);
    }
},

This is very similar to the playCardOnTable function that handles other games, but the jstpl_card template is put into a different div, and the new element for the template is slid to a more specific spot. The z-index is used to avoid cards going hiding on a refresh since they are placed overlapping each other. Finally, if it's a spinner rotate it.

Now we need to update the setup function so when someone reloads the page it won't mess with things.
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;

    if (gamedatas.selected_game_type === 'dominoes') {
        this.playCardInCenter(player_id, suit, value, card.id, gamedatas.spinner);
    } else {
        this.playCardOnTable(player_id, suit, value, card.id);
    }
}

With this function being aware of the selected game it makes me seriously consider the benefit of using a different notification. I'm going to stick with it for now since it's working, but might revisit that choice after Dominoes is supported.

Conclusion

Almost there! I'm psyched with how the cards are showing up for Dominoes and the game can mostly be played through. Just a couple of remaining details.
  • When an Ace is played the player can play as many cards as they want to - as long as they're valid - before passing.
  • Need to end the game, which involves scoring it.
  • Need to tighten up the passing and at least know if the player passed when they could've played.

Start Handling Dominoes

 

Overview

Now that the basics of handling the trick-taking games in Guillotine are done, it's time to shake it up and look at Dominoes. This does a couple of different things than any of the other games. The gist of the game is that you're trying to be the first or second player to play all of their cards. The dealer picks a "spinner" (which is their first card played) and cards of that value are the only ones that can be played from other suits. Players must play a card if they can or pass. A player can only play a card if its value is adjacent to an already played card or one of the spinners.

Given these differences, I expect some things will need to change and we'll see if it is just an alternate path or if it affects all of the games.

Change card sorting order

Unlike the other games, the 10s in Dominoes are in their usual place between 9 and Jack instead of between the King and Ace. This is handled in the front end by the weight specified when adding items. I updated it to recognize the selected game. Here's where the weight is specific in the setup function.
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, gamedatas.selected_game_type);
       
        this.playerHand.addItemType(
            card_type_id, card_weight, g_gamethemeurl + 'img/cards.jpg', card_type_id);
    }
}

The only change here is passing gamedatas.selected_game_type to getCardWeight. getCardWeight was updated to return early if the selected_game_type was 'dominoes'.
getCardWeight: function(suit, value, selected_game_type) {
    var base_weight = this.getCardUniqueId(suit, value);

    if (selected_game_type === 'dominoes') {
        return base_weight;
    }

    if (value == 10) {
        return base_weight + 4;
    } else if (value == 14) {
        return base_weight + 1;
    } else {
        return base_weight;
    }
},

However, gamedatas wasn't being passed a selected_game_type, just a selected_game which is the translated name of the game. So this required a small change to the getAllDatas function in the <game_name>.game.php file to get the type of the selected game and pass that along.

This worked like a champ but required a refresh to see the changes. This would be better if when the game was selected the sorting was updated. So back in the front end I changed the notif_gameSelection function to call another function to do the sorting change.
notif_gameSelection : function(notif) {
    document.getElementById('dealer_p' + notif.args.dealer_id).innerHTML = notif.args.game_name;
    document.getElementById('glt_game_' + notif.args.game_type + '_' + notif.args.dealer_id)
        .classList.add('played');

    this.updateCardSorting(notif.args.game_type);
},

I thought of adding a check to only update the sorting when Dominoes was called, but then I'd need to remember to change the sort at the end of Dominoes. Instead, I opted to always call it figuring that it wasn't an expensive process. Here's the updateCardSorting function.
updateCardSorting : function(selected_game_type) {
    const new_card_weights = [];
    for (var suit = 1; suit <= 4; suit++) {
        const card_type_id = this.getCardUniqueId(suit, 10);
        const card_weight = this.getCardWeight(suit, 10, selected_game_type);
        new_card_weights[card_type_id] = card_weight;
    }

    this.playerHand.changeItemsWeight(new_card_weights);
},

Implementing this caused me some pain mostly because the documentation for the changeItemsWeight function suggested an associative array, which I found doesn't exist in JavaScript. Though specifying obj[key] = value effectively does the same thing, it's just that obj is not technically an array. So I circled around this doing more complicated things than I needed to, before finally taking another look at things that told me JavaScript didn't have an associative array.

Thankfully, with this in place when Dominioes is selected you see the cards shift to their new sorting right away.

Prompt the dealer to select a spinner

This could just be another prompt to play a card, but the first card played represents the spinner and it seems something important to call out to the player. Additionally, I opted to use a separate state to drive this. There's a good chunk of logic that is shared / duplicated between non-Dominoes games and Dominoes, but I thought keeping it separate and extracting functions to share the logic would be a good way to go.

So let's start with the new states.
41 => [
    "name" => "dominoesPlayerTurn",
    "description" => clienttranslate('${actplayer} must play ${play_message}'),
    "descriptionmyturn" => clienttranslate('${you} must play ${play_message}'),
    "type" => "activeplayer",
    "args" => "argDominoesPlayerTurn",
    "possibleactions" => ["playCard", "pass"],
    "transitions" => ["turnTaken" => 42]
],

42 => [
    "name" => "dominioesNextPlayer",
    "description" => "",
    "type" => "game",
    "action" => "stDominoesNextPlayer",
    "updateGameProgression" => true,
    "transitions" => ["nextPlayer" => 41, "endHand" => 30]
],

To get into these states I updated the "gameSelection" state to have an additional transition of "dominoesSelectSpinner" => 41. Which had a change to the gameSelection function to move to that state.
if ($selected_game == 'dominoes') {
    $this->gamestate->nextState("dominoesSelectSpinner");
} else {
    $this->gamestate->nextState("startHand");
}

Notice that in the "dominoesPlayerTurn" state the description property has a message with "must play ${play_message}". The value for that variable comes from the args function, argDominoesPlayerTurn, which currently looks like this.
function argDominoesPlayerTurn() {
    $cards_in_hands = $this->cards->getCardsInLocation(HAND);
    if (count($cards_in_hands) == 32) {
        return [
            'play_message' => clienttranslate('the spinner')
        ];
    }
 
    return [
        'play_message' => clienttranslate('a card or pass')
    ];
}

I can see a future where this method returns the playable cards, then, instead of having a Pass button show up all the time, it can only be there when the player must pass or it can auto-pass. (Though I fear that might be confusing, but we'll see.)

Handle playing cards

There are a lot of changes that need to happen for these cards to be played appropriately, so let's break things into smaller chunks and build it up.

Get the card played

Given the listener that was created to handle playing cards without the need to click an action button, clicking a card tried playing it. However, it fails because the transition from the playCard action, "cardPlayed", wasn't used in the "dominoesPlayerTurn" state. Since the player could pass the transition name of "turnTaken" seems more appropriate. Additionally, there are other changes to support Dominoes, so it's a small thing to use a different transition. To get the card playing I just updated the function to use a different transition.
$selected_game_id = self::getGameStateValue(SELECTED_GAME);
if ($this->games[$selected_game_id]['type'] == "dominoes") {
    $this->gamestate->nextState("turnTaken");
} else {
    $this->gamestate->nextState("cardPlayed");
}

Ensure the card is valid to play

The first card the dealer plays can be anything and is called the spinner. After that, players need to play a card that is one more or one less in value than a card on the table or a spinner from another suit.
function checkPlayableCardForDominoes($current_card) {
    $cards_on_table = $this->cards->getCardsInLocation(CARDS_ON_TABLE);

    // This will be the first card on the table, so we're good
    if (count($cards_on_table) == 0) return;
    // The card is a spinner, so it's good to play
    if (self::getGameStateValue(SPINNER) == $current_card['type_arg']) return;
 
    $cards_of_played_suit = array_filter($cards_on_table, function($card) use (&$current_card) {
        return $card['type'] == $current_card['type'];
    });

    if (count($cards_of_played_suit) == 0) {
     throw new BgaUserException(self::_(
        "Your card cannot be played because the spinner for the suit hasn't been played yet"));
    }

    $played_card_values = array_column($cards_of_played_suit, null, 'type_arg');
    if (!($played_card_values[$current_card['type_arg'] - 1] ?? false) &&
        !($played_card_values[$current_card['type_arg'] + 1] ?? false))
    {
        throw new BgaUserException(self::_(
            "Your card must be a spinner or one higher or lower in value than a card already played"));
    }
}

If the current card is not a valid play an exception is raised giving the player some idea as to what wasn't right.

Allow the player to pass

While I want the system to know if the player needs to pass, I also want to be able to play through a round now. However, looking into this reminded me that the "Play Card" action button needs to be added when the preference for confirmation is enabled. Both of these changes are in the onUpdateActionButtons function in the front end.
case 'dominoesPlayerTurn':
    if (this.prefs[100].value == 1) {
        this.addActionButton('btnPlayCard', _('Play card'), 'onBtnPlayCard');
    }
    if (args.passable) this.addActionButton('btnPass', _('Pass'), 'onBtnPass');
    break;

I updated the argDominoesPlayerTurn function in the backend to pass in the passable argument.
function argDominoesPlayerTurn() {
    $cards_in_hands = $this->cards->getCardsInLocation(HAND);
    if (count($cards_in_hands) == 32) {
        return [
            'play_message' => clienttranslate('the spinner'),
            'passable' => false,
        ];
    }
    return [
        'play_message' => clienttranslate('a card or pass'),
        'passable' => true,
    ];
}

Then I implemented the onBtnPass function to support the new button.
onBtnPass: function() {
    const action = "pass";
    if (!this.checkAction(action)) return;

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

Since that's an action it requires an update to the <game_name>.action.php file.
public function pass() {
    self::setAjaxMode();
    $this->game->pass();
    self::ajaxResponse();
}

Followed by the pass function in the <game_name>.name.php file.
function pass() {
    self::checkAction("pass");

    self::notifyAllPlayers('pass', clienttranslate('${player_name} passed'), [
        'player_name' => self::getActivePlayerName()
    ]);
    $this->gamestate->nextState("turnTaken");
}

Nothing too fancy, just advancing the active player and the state.

Conclusion

There's still plenty to do before being able to play Dominoes, but I think that's enough for now. Next up I'll be redoing how the cards are shown on the table so that they display in columns in a center area.