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.

No comments: