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.

No comments: