diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd4a5d7 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# cgi + +![](https://img.shields.io/badge/written%20in-Javascript%20%28node.js%29-blue) + +A minimax AI for the card game Hearts. + +It uses depth-first minimax to evaluate a round. It's not very smart. +Most of the underlying structure should be portable to other card games. + + +## Download + +- [⬇️ sample_game.log](dist-archive/sample_game.log) *(1.84 KiB)* +- [⬇️ cgi.js](dist-archive/cgi.js) *(13.35 KiB)* diff --git a/dist-archive/cgi.js b/dist-archive/cgi.js new file mode 100644 index 0000000..f991869 --- /dev/null +++ b/dist-archive/cgi.js @@ -0,0 +1,491 @@ + +// CARD PACK INFRASTRUCTURE +// ```````````````````````` + +var TEAM_RED = 0; +var TEAM_BLUE = 1; +var TEAM_GREEN = 2; +var TEAM_YELLOW = 3; + +var SUIT_HEARTS = 3; +var SUIT_DIAMONDS = 2; +var SUIT_CLUBS = 1; +var SUIT_SPADES = 0; + +var FACE_ACE = 12; // ace-high for numerical comparisons +var FACE_KING = 11; +var FACE_QUEEN = 10; +var FACE_JACK = 9; +var FACE_TEN = 8; +var FACE_NINE = 7; +var FACE_EIGHT = 6; +var FACE_SEVEN = 5; +var FACE_SIX = 4; +var FACE_FIVE = 3; +var FACE_FOUR = 2; +var FACE_THREE = 1; +var FACE_TWO = 0; + +var deck_create = function() { + var ret = []; + for (var card_id = 0; card_id < (4*13); card_id++) { + ret.push(card_id); + } + deck_shuffle_inplace(ret); + return ret; +}; + +var deck_shuffle_inplace = function(deck) { + for (var i = 0, e = deck.length; i != e; ++i) { + var source_idx = i; + var dest_idx = Math.floor(Math.random() * deck.length); + + var tmp = deck[dest_idx]; + deck[dest_idx] = deck[source_idx]; + deck[source_idx] = tmp; + } +}; + +var card_suit = function(card_id) { + return Math.floor(card_id / 13); // 0..3 +}; + +var card_face = function(card_id) { + return Math.floor(card_id % 13); // 0-indexed +}; + +var card_is = function(card_id, suit, face) { + return card_suit(card_id) == suit && card_face(card_id) == face; +}; + +var card_name_face = function(face) { + switch(face) { + case FACE_ACE: return 'Ace'; + case FACE_TWO: return 'Two'; + case FACE_THREE: return 'Three'; + case FACE_FOUR: return 'Four'; + case FACE_FIVE: return 'Five'; + case FACE_SIX: return 'Six'; + case FACE_SEVEN: return 'Seven'; + case FACE_EIGHT: return 'Eight'; + case FACE_NINE: return 'Nine'; + case FACE_TEN: return 'Ten'; + case FACE_JACK: return 'Jack'; + case FACE_QUEEN: return 'Queen'; + case FACE_KING: return 'King'; + } +}; + +var card_name_suit = function(suit) { + switch(suit) { + case SUIT_HEARTS: return 'Hearts'; + case SUIT_DIAMONDS: return 'Diamonds'; + case SUIT_CLUBS: return 'Clubs'; + case SUIT_SPADES: return 'Spades'; + } +}; + +var card_name = function(card_id) { + if (card_is(card_id, SUIT_SPADES, FACE_QUEEN)) { + return "** THE QUEEN OF SPADES **"; + } else { + return card_name_face(card_face(card_id)) + " of " + card_name_suit(card_suit(card_id)); + } +}; + + +// GAME-AGNOSTIC INFRASTRUCTURE +// ```````````````````````````` + + +var GameState = function() { + this.moveHistory = []; +}; +GameState.prototype.clone = function() { + var ret = new GameState(); + ret.moveHistory = this.moveHistory.slice(0); + return ret; +}; +GameState.prototype.applyMove = function(move) { + this.moveHistory.push(move); +}; +GameState.prototype.getNextPlayerID = function() { + throw new Error("Game not loaded"); +}; +GameState.prototype.evaluatePosition = function() { + // determine who's winning, even mid-round + // return { TEAM_RED: number, ... } + throw new Error("Game not loaded"); +}; +GameState.prototype.gameHasEnded = function() { + throw new Error("Game not loaded"); +}; +GameState.prototype.evaluatePositionOnBehalfOf = function(team_id) { + var team_scores = this.evaluatePosition(); + + // Turn the team scores into a single +/- number. + var our_score = team_scores[team_id]; + + var other_scores = []; + + for (var tid in team_scores) { + if (tid != team_id) { + other_scores.push( team_scores[tid] ); + } + } + + var other_players_max = Math.max.apply(null, other_scores); + var other_players_sum = other_scores.reduce(function(a, b) { return a + b; }, 0); + var other_players_mean = other_players_sum / other_scores.length; + + // Positive if we're winning, negative if we're losing. + // This is accurate but naive, it'd be better to return how many stddev's we're ahead/behind + + return (our_score - other_players_max); +}; + +// + +var Move = function(player_id) { + this.player_id = player_id; + // Anything else goes - 500/hearts have only a single card, but KNA could have multiple, shouldn't matter in general +}; + +// + +var Player = function() { + this.cards = []; // private + this.player_id = 0; // public + this.team_id = TEAM_RED; // public +}; + +Player.prototype.enumerateLegalMoves = function(gamestate) { + // use this.cards; + throw new Error("Game not loaded"); +}; +Player.prototype.buildMentalModelOfOtherPlayers = function(gamestate) { + throw new Error("Game not loaded"); + // use gamestate.moveHistory; + // return [null (self), Player, Player, Player] with probably-wrong card information +}; +Player.prototype.determineMove = function(gamestate, recurse_limit, score_on_behalf_of_team_id) { + var avail_moves = this.enumerateLegalMoves(gamestate); + if (! avail_moves.length) { + return null; + } + + var nextPlayer = null; + if (recurse_limit > 0) { + nextPlayer = this.buildMentalModelOfOtherPlayers(gamestate)[ gamestate.getNextPlayerID() ]; + // n.b. that took into account recursive calls should have removed cards from players' hands + } + + var best_move = null; + var best_score = -Infinity; + for(var i = 0, e = avail_moves.length; i < e; ++i) { + + var proposed_gamestate = gamestate.clone(); + proposed_gamestate.applyMove( avail_moves[i] ); + + var move_score = proposed_gamestate.evaluatePositionOnBehalfOf(this.team_id); + + + if (recurse_limit > 0 && ! proposed_gamestate.gameHasEnded()) { + + // depth-first search + var recurse_move_arr = nextPlayer.determineMove(proposed_gamestate, recurse_limit - 1, score_on_behalf_of_team_id); + + // It's possible the game hasn't ended yet, but there would be no more moves from this player + if (recurse_move_arr != null) { + // use the score from recursion, but, we need to return our own move to get to this score + move_score = recurse_move_arr[1]; + } + + } + + if (move_score > best_score) { + best_score = move_score; + best_move = avail_moves[i]; + } + + } + + // The best_score determined what move *this player* would have made. But we were asked to determine + // the score on behalf of someone else + if (this.team_id != score_on_behalf_of_team_id) { + var temp_gs = gamestate.clone(); + temp_gs.applyMove(best_move); + best_score = temp_gs.evaluatePositionOnBehalfOf( score_on_behalf_of_team_id ); // overwrite + } + + return [best_move, best_score]; +}; + + +// HEARTS +// `````` + +Player.prototype.enumerateLegalMoves = function(gamestate) { + // use this.cards; + + // HEARTS {{{ + + // We could play any card, with some exceptions + var play_options = this.cards.slice(0); // clone + var leading = (gamestate.moveHistory.length % 4) == 0; + + // If hearts aren't broken, we can't lead them + if (leading) { + var hearts_broken = false; + for(var i = 0; i < gamestate.moveHistory.length; i += 4) { // leading moves only + if ( card_suit(gamestate.moveHistory[i].card_id) == SUIT_HEARTS ) { + hearts_broken = true; + } + } + if (! hearts_broken) { + play_options = play_options.filter(function(card_id) { + return card_suit(card_id) != SUIT_HEARTS; + }); + } + } + + // No point cards on the first round + if (gamestate.moveHistory.length < 4) { + play_options = play_options.filter(function(card_id) { + return ( + card_suit(card_id) != SUIT_HEARTS && + !card_is(card_id, SUIT_SPADES, FACE_QUEEN) + ); + }); + } + + // Follow suit if we can + if (! leading) { + var lead_move = gamestate.moveHistory[ Math.floor(gamestate.moveHistory.length / 4)*4 ]; + + var follow_suit_options = play_options.filter(function(card_id) { + return card_suit(card_id) == card_suit(lead_move.card_id) + }); + if (follow_suit_options.length) { + play_options = follow_suit_options; + } + } + + // If there were no legal moves, gotta break a rule, any card goes i guess + if (! play_options.length) { + play_options = this.cards.slice(0); // clone + } + + // Build \Move objects for return + var ret = []; + for(var i = 0; i < play_options.length; ++i) { + var move = new Move(); + move.player_id = this.player_id; + move.card_id = play_options[i]; + ret.push(move); + } + return ret; + // }}} +}; + +Player.prototype.buildMentalModelOfOtherPlayers = function(gamestate) { + var retn = hearts_create_players(); + + // Start out by assuming anyone could have any card + for (var i = 0; i < 4; ++i) { + retn[i].cards = deck_create(); + } + + // Go through the move history... + for (var i = 0; i < gamestate.moveHistory.length; ++i) { + var played_card_id = gamestate.moveHistory[i].card_id; + var played_player_id = gamestate.moveHistory[i].player_id; + + var is_lead_card = (i % 4) == 0; + if (is_lead_card) { + var last_lead_card = played_card_id; + } + + // Once a move has been made, nobody has that card any more + for (var j = 0; j < 4; ++j) { + retn[j].cards = retn[j].cards.filter(function(card_id) { + return card_id != played_card_id; + }); + } + + // If this player didn't follow suit, then they were out of the lead suit + if (! is_lead_card) { + if (card_suit(played_card_id) != card_suit(last_lead_card)) { + retn[ played_player_id ].cards = retn[ played_player_id ].cards.filter(function(card_id) { + return card_suit(card_id) != card_suit(last_lead_card); + }); + } + } + } + + // ...and of course, we know more about our own situation + retn[ this.player_id ] = this; + + // That's all we can infer + return retn; +}; + +GameState.prototype.evaluatePosition = function() { + // determine who's winning, even mid-round. + + var retn = {}; + for (var i = 0; i < 4; ++i) { + retn[i] = 0; + } + + // We can get an explicit score for all the completed rounds.. + var completedRounds = Math.floor(this.moveHistory.length/4); + + var last_winner = 0; + + for (var i = 0; i < completedRounds; ++i) { + + // Determine trick value + + var trick_value = 0; + for (var j = 0; j < 4; ++j) { + var move = this.moveHistory[i*4 + j]; + if (card_suit(move.card_id) == SUIT_HEARTS) { + trick_value += 1; + } else if (card_is(move.card_id, SUIT_SPADES, FACE_QUEEN)) { + trick_value += 13; + } + } + + // Determine trick winner + + var lead_move = this.moveHistory[i*4 + 0]; + var winning_player = lead_move.player_id; + for (var j = 1; j < 4; ++j) { + var contender_move = this.moveHistory[i*4 + j]; + if ( + card_suit(contender_move.card_id) == card_suit(lead_move.card_id) && + card_face(contender_move.card_id) > card_face(lead_move.card_id) + ) { + winning_player = contender_move.player_id; + } + } + + // That player loses the points + + retn[winning_player /* should be team id, but they're equal */] -= (trick_value * 100); + last_winner = winning_player; + } + + // If there's an incomplete round, + // then we can see who's in the best position for the round so far + + + + // If this was the trick-ending play, apply a penalty for whoever's in the lead - unless it's the last trick + var is_time_for_new_trick = ( (this.moveHistory.length % 4) == 0 && ! this.gameHasEnded() ); + if (is_time_for_new_trick) { + retn[last_winner] -= 50; + } + + return retn; +}; + +GameState.prototype.gameHasEnded = function() { + return this.moveHistory.length >= (4*13); +}; + +GameState.prototype.getNextPlayerID = function() { + if (this.moveHistory.length) { + + if (this.moveHistory.length % 4 == 0) { + + // It's a new round. Who won the last round? + var last_round_starting_move = this.moveHistory[this.moveHistory.length-4]; + var winning_player_id = last_round_starting_move.player_id; + var highest_face_in_lead_suit = card_face(last_round_starting_move.card_id); + + for (var i = 0; i < 3; ++i) { + var round_move = this.moveHistory[this.moveHistory.length-3 + i]; + if ( + card_suit(round_move.card_id) == card_suit(last_round_starting_move.card_id) && + card_face(round_move.card_id) > highest_face_in_lead_suit + ) { + + winning_player_id = round_move.player_id; + highest_face_in_lead_suit = card_face(round_move.card_id); + } + } + + return winning_player_id; // Math.floor(Math.random() * 4); // FIXME: who won the last round? + + } else { + + // We're mid-round, pick the next player + return (this.moveHistory[this.moveHistory.length - 1].player_id + 1) % 4; // num_players + } + + } else { + + // No moves have bene played yet, youngest player starts + return 0; + } +}; + +var hearts_create_players = function() { + var deck = deck_create(); + var retn = [new Player(), new Player(), new Player(), new Player()]; + for (var i = 0; i < 4; ++i) { + retn[i].cards = deck.slice( 13*i, 13*(i+1) ); + retn[i].player_id = i; + retn[i].team_id = i; + } + return retn; +} + +var hearts_mainloop = function() { + + var players = hearts_create_players(); + + var gs = new GameState(); + while (! gs.gameHasEnded()) { + + var player_id = gs.getNextPlayerID(); + + var move = players[player_id].determineMove(gs, 3, players[player_id].team_id)[0]; + + gs.applyMove(move); + players[player_id].cards = players[player_id].cards.filter(function(card_id) { + return card_id != move.card_id; + }); + } + + return gs; +} + +// SAMPLE GAME +// ``````````` + +var main = function() { + + var heartsgame = hearts_mainloop(); + + for(var i = 0; i < heartsgame.moveHistory.length; ++i) { + + var move = heartsgame.moveHistory[i]; + + console.log("Player "+move.player_id + ": " + card_name(move.card_id)); + + if (i % 4 == 3) { + var temp_gs = heartsgame.clone(); + temp_gs.moveHistory = temp_gs.moveHistory.slice(0, i+1); + console.log("Scores: " + JSON.stringify(temp_gs.evaluatePosition()) + "\n"); + + } + + } + + console.log("The game ended."); +} + +main(); diff --git a/dist-archive/sample_game.log b/dist-archive/sample_game.log new file mode 100644 index 0000000..a91e8b1 --- /dev/null +++ b/dist-archive/sample_game.log @@ -0,0 +1,79 @@ +Player 0: King of Diamonds +Player 1: Five of Diamonds +Player 2: Eight of Diamonds +Player 3: Six of Diamonds +Scores: {"0":-50,"1":0,"2":0,"3":0} + +Player 0: Ace of Clubs +Player 1: Ten of Clubs +Player 2: Nine of Spades +Player 3: Three of Clubs +Scores: {"0":-50,"1":0,"2":0,"3":0} + +Player 0: Queen of Clubs +Player 1: Nine of Clubs +Player 2: Six of Hearts +Player 3: Six of Clubs +Scores: {"0":-150,"1":0,"2":0,"3":0} + +Player 0: Four of Clubs +Player 1: Eight of Clubs +Player 2: Jack of Hearts +Player 3: Five of Clubs +Scores: {"0":-100,"1":0,"2":0,"3":-150} + +Player 1: Seven of Spades +Player 2: Two of Spades +Player 3: Five of Spades +Player 0: King of Spades +Scores: {"0":-150,"1":0,"2":0,"3":-100} + +Player 0: Jack of Clubs +Player 1: Five of Hearts +Player 2: King of Hearts +Player 3: Seven of Clubs +Scores: {"0":-350,"1":0,"2":0,"3":-100} + +Player 0: Two of Clubs +Player 1: Six of Spades +Player 2: Jack of Diamonds +Player 3: King of Clubs +Scores: {"0":-300,"1":0,"2":0,"3":-150} + +Player 3: Four of Spades +Player 0: Jack of Spades +Player 1: Ace of Spades +Player 2: Ten of Spades +Scores: {"0":-300,"1":0,"2":-50,"3":-100} + +Player 1: Ace of Diamonds +Player 2: Ten of Diamonds +Player 3: Four of Diamonds +Player 0: Two of Diamonds +Scores: {"0":-300,"1":-50,"2":0,"3":-100} + +Player 1: ** THE QUEEN OF SPADES ** +Player 2: Three of Diamonds +Player 3: Three of Spades +Player 0: Eight of Spades +Scores: {"0":-300,"1":-1350,"2":0,"3":-100} + +Player 1: Queen of Diamonds +Player 2: Nine of Diamonds +Player 3: Ace of Hearts +Player 0: Seven of Hearts +Scores: {"0":-300,"1":-1550,"2":0,"3":-100} + +Player 1: Seven of Diamonds +Player 2: Eight of Hearts +Player 3: Ten of Hearts +Player 0: Queen of Hearts +Scores: {"0":-300,"1":-1850,"2":0,"3":-100} + +Player 1: Four of Hearts +Player 2: Nine of Hearts +Player 3: Three of Hearts +Player 0: Two of Hearts +Scores: {"0":-300,"1":-1800,"2":-400,"3":-100} + +The game ended. diff --git a/doc/screenshot.png b/doc/screenshot.png new file mode 100644 index 0000000..fb77bd7 Binary files /dev/null and b/doc/screenshot.png differ