From 24e1bced352adae06f5f34767dcbb3b225e700ab Mon Sep 17 00:00:00 2001 From: mappu Date: Fri, 13 Nov 2015 05:00:14 +0000 Subject: [PATCH] commit all archived files --- README.md | 14 + dist-archive/cgi.js | 491 +++++++++++++++++++++++++++++++++++ dist-archive/sample_game.log | 79 ++++++ doc/screenshot.png | Bin 0 -> 13548 bytes 4 files changed, 584 insertions(+) create mode 100644 README.md create mode 100644 dist-archive/cgi.js create mode 100644 dist-archive/sample_game.log create mode 100644 doc/screenshot.png 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 0000000000000000000000000000000000000000..fb77bd702d7943faa1cf0072a6a447168a96d25d GIT binary patch literal 13548 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 uVX6!3r>$K7-BNe-!34*Zwo<343lak