commit all archived files
This commit is contained in:
parent
57d1256a05
commit
24e1bced35
14
README.md
Normal file
14
README.md
Normal file
@ -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)*
|
491
dist-archive/cgi.js
Normal file
491
dist-archive/cgi.js
Normal file
@ -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();
|
79
dist-archive/sample_game.log
Normal file
79
dist-archive/sample_game.log
Normal file
@ -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.
|
BIN
doc/screenshot.png
Normal file
BIN
doc/screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
Reference in New Issue
Block a user