Initial commit: 6-Card Golf with AI opponents
Features: - Multiplayer WebSocket game server (FastAPI) - 8 AI personalities with distinct play styles - 15+ house rule variants - SQLite game logging for AI analysis - Comprehensive test suite (80+ tests) AI improvements: - Fixed Maya bug (taking bad cards, discarding good ones) - Personality traits influence style without overriding competence - Zero blunders detected in 1000+ game simulations Testing infrastructure: - Game rules verification (test_game.py) - AI decision analysis (game_analyzer.py) - Score distribution analysis (score_analysis.py) - House rules testing (test_house_rules.py) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
609
server/game.py
Normal file
609
server/game.py
Normal file
@@ -0,0 +1,609 @@
|
||||
"""Game logic for 6-Card Golf."""
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Suit(Enum):
|
||||
HEARTS = "hearts"
|
||||
DIAMONDS = "diamonds"
|
||||
CLUBS = "clubs"
|
||||
SPADES = "spades"
|
||||
|
||||
|
||||
class Rank(Enum):
|
||||
ACE = "A"
|
||||
TWO = "2"
|
||||
THREE = "3"
|
||||
FOUR = "4"
|
||||
FIVE = "5"
|
||||
SIX = "6"
|
||||
SEVEN = "7"
|
||||
EIGHT = "8"
|
||||
NINE = "9"
|
||||
TEN = "10"
|
||||
JACK = "J"
|
||||
QUEEN = "Q"
|
||||
KING = "K"
|
||||
JOKER = "★"
|
||||
|
||||
|
||||
RANK_VALUES = {
|
||||
Rank.ACE: 1,
|
||||
Rank.TWO: -2,
|
||||
Rank.THREE: 3,
|
||||
Rank.FOUR: 4,
|
||||
Rank.FIVE: 5,
|
||||
Rank.SIX: 6,
|
||||
Rank.SEVEN: 7,
|
||||
Rank.EIGHT: 8,
|
||||
Rank.NINE: 9,
|
||||
Rank.TEN: 10,
|
||||
Rank.JACK: 10,
|
||||
Rank.QUEEN: 10,
|
||||
Rank.KING: 0,
|
||||
Rank.JOKER: -2,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Card:
|
||||
suit: Suit
|
||||
rank: Rank
|
||||
face_up: bool = False
|
||||
|
||||
def to_dict(self, reveal: bool = False) -> dict:
|
||||
if self.face_up or reveal:
|
||||
return {
|
||||
"suit": self.suit.value,
|
||||
"rank": self.rank.value,
|
||||
"face_up": self.face_up,
|
||||
}
|
||||
return {"face_up": False}
|
||||
|
||||
def value(self) -> int:
|
||||
return RANK_VALUES[self.rank]
|
||||
|
||||
|
||||
class Deck:
|
||||
def __init__(self, num_decks: int = 1, use_jokers: bool = False, lucky_swing: bool = False):
|
||||
self.cards: list[Card] = []
|
||||
for _ in range(num_decks):
|
||||
for suit in Suit:
|
||||
for rank in Rank:
|
||||
if rank != Rank.JOKER:
|
||||
self.cards.append(Card(suit, rank))
|
||||
if use_jokers and not lucky_swing:
|
||||
# Standard: Add 2 jokers worth -2 each per deck
|
||||
self.cards.append(Card(Suit.HEARTS, Rank.JOKER))
|
||||
self.cards.append(Card(Suit.SPADES, Rank.JOKER))
|
||||
# Lucky Swing: Add just 1 joker total (worth -5)
|
||||
if use_jokers and lucky_swing:
|
||||
self.cards.append(Card(Suit.HEARTS, Rank.JOKER))
|
||||
self.shuffle()
|
||||
|
||||
def shuffle(self):
|
||||
random.shuffle(self.cards)
|
||||
|
||||
def draw(self) -> Optional[Card]:
|
||||
if self.cards:
|
||||
return self.cards.pop()
|
||||
return None
|
||||
|
||||
def cards_remaining(self) -> int:
|
||||
return len(self.cards)
|
||||
|
||||
def add_cards(self, cards: list[Card]):
|
||||
"""Add cards to the deck and shuffle."""
|
||||
self.cards.extend(cards)
|
||||
self.shuffle()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Player:
|
||||
id: str
|
||||
name: str
|
||||
cards: list[Card] = field(default_factory=list)
|
||||
score: int = 0
|
||||
total_score: int = 0
|
||||
rounds_won: int = 0
|
||||
|
||||
def all_face_up(self) -> bool:
|
||||
return all(card.face_up for card in self.cards)
|
||||
|
||||
def flip_card(self, position: int):
|
||||
if 0 <= position < len(self.cards):
|
||||
self.cards[position].face_up = True
|
||||
|
||||
def swap_card(self, position: int, new_card: Card) -> Card:
|
||||
old_card = self.cards[position]
|
||||
new_card.face_up = True
|
||||
self.cards[position] = new_card
|
||||
return old_card
|
||||
|
||||
def calculate_score(self, options: Optional["GameOptions"] = None) -> int:
|
||||
"""Calculate score with column pair matching and house rules."""
|
||||
if len(self.cards) != 6:
|
||||
return 0
|
||||
|
||||
def get_card_value(card: Card) -> int:
|
||||
"""Get card value with house rules applied."""
|
||||
if options:
|
||||
if card.rank == Rank.JOKER:
|
||||
return -5 if options.lucky_swing else -2
|
||||
if card.rank == Rank.KING and options.super_kings:
|
||||
return -2
|
||||
if card.rank == Rank.SEVEN and options.lucky_sevens:
|
||||
return 0
|
||||
if card.rank == Rank.TEN and options.ten_penny:
|
||||
return 1
|
||||
return card.value()
|
||||
|
||||
def cards_match(card1: Card, card2: Card) -> bool:
|
||||
"""Check if two cards match for pairing (with Queens Wild support)."""
|
||||
if card1.rank == card2.rank:
|
||||
return True
|
||||
if options and options.queens_wild:
|
||||
if card1.rank == Rank.QUEEN or card2.rank == Rank.QUEEN:
|
||||
return True
|
||||
return False
|
||||
|
||||
total = 0
|
||||
# Cards are arranged in 2 rows x 3 columns
|
||||
# Position mapping: [0, 1, 2] (top row)
|
||||
# [3, 4, 5] (bottom row)
|
||||
# Columns: (0,3), (1,4), (2,5)
|
||||
|
||||
# Check for Four of a Kind first (4 cards same rank = all score 0)
|
||||
four_of_kind_positions: set[int] = set()
|
||||
if options and options.four_of_a_kind:
|
||||
from collections import Counter
|
||||
rank_positions: dict[Rank, list[int]] = {}
|
||||
for i, card in enumerate(self.cards):
|
||||
if card.rank not in rank_positions:
|
||||
rank_positions[card.rank] = []
|
||||
rank_positions[card.rank].append(i)
|
||||
for rank, positions in rank_positions.items():
|
||||
if len(positions) >= 4:
|
||||
four_of_kind_positions.update(positions)
|
||||
|
||||
for col in range(3):
|
||||
top_idx = col
|
||||
bottom_idx = col + 3
|
||||
top_card = self.cards[top_idx]
|
||||
bottom_card = self.cards[bottom_idx]
|
||||
|
||||
# Skip if part of four of a kind
|
||||
if top_idx in four_of_kind_positions and bottom_idx in four_of_kind_positions:
|
||||
continue
|
||||
|
||||
# Check if column pair matches (same rank or Queens Wild)
|
||||
if cards_match(top_card, bottom_card):
|
||||
# Eagle Eye: paired jokers score -8 (2³) instead of canceling
|
||||
if (options and options.eagle_eye and
|
||||
top_card.rank == Rank.JOKER and bottom_card.rank == Rank.JOKER):
|
||||
total -= 8
|
||||
continue
|
||||
# Normal matching pair scores 0
|
||||
continue
|
||||
else:
|
||||
if top_idx not in four_of_kind_positions:
|
||||
total += get_card_value(top_card)
|
||||
if bottom_idx not in four_of_kind_positions:
|
||||
total += get_card_value(bottom_card)
|
||||
|
||||
self.score = total
|
||||
return total
|
||||
|
||||
def cards_to_dict(self, reveal: bool = False) -> list[dict]:
|
||||
return [card.to_dict(reveal) for card in self.cards]
|
||||
|
||||
|
||||
class GamePhase(Enum):
|
||||
WAITING = "waiting"
|
||||
INITIAL_FLIP = "initial_flip"
|
||||
PLAYING = "playing"
|
||||
FINAL_TURN = "final_turn"
|
||||
ROUND_OVER = "round_over"
|
||||
GAME_OVER = "game_over"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameOptions:
|
||||
# Standard options
|
||||
flip_on_discard: bool = False # Flip a card when discarding from deck
|
||||
initial_flips: int = 2 # Cards to flip at start (0, 1, or 2)
|
||||
knock_penalty: bool = False # +10 if you go out but don't have lowest
|
||||
use_jokers: bool = False # Add jokers worth -2 points
|
||||
|
||||
# House Rules - Point Modifiers
|
||||
lucky_swing: bool = False # Single joker worth -5 instead of two -2 jokers
|
||||
super_kings: bool = False # Kings worth -2 instead of 0
|
||||
lucky_sevens: bool = False # 7s worth 0 instead of 7
|
||||
ten_penny: bool = False # 10s worth 1 (like Ace) instead of 10
|
||||
|
||||
# House Rules - Bonuses/Penalties
|
||||
knock_bonus: bool = False # First to reveal all cards gets -5 bonus
|
||||
underdog_bonus: bool = False # Lowest score player gets -3 each hole
|
||||
tied_shame: bool = False # Tie with someone's score = +5 penalty to both
|
||||
blackjack: bool = False # Hole score of exactly 21 becomes 0
|
||||
|
||||
# House Rules - Gameplay Twists
|
||||
queens_wild: bool = False # Queens count as any rank for pairing
|
||||
four_of_a_kind: bool = False # 4 cards of same rank in grid = all 4 score 0
|
||||
eagle_eye: bool = False # Paired jokers double instead of cancel (-4 or -10)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Game:
|
||||
players: list[Player] = field(default_factory=list)
|
||||
deck: Optional[Deck] = None
|
||||
discard_pile: list[Card] = field(default_factory=list)
|
||||
current_player_index: int = 0
|
||||
phase: GamePhase = GamePhase.WAITING
|
||||
num_decks: int = 1
|
||||
num_rounds: int = 1
|
||||
current_round: int = 1
|
||||
drawn_card: Optional[Card] = None
|
||||
drawn_from_discard: bool = False # Track if current draw was from discard
|
||||
finisher_id: Optional[str] = None
|
||||
players_with_final_turn: set = field(default_factory=set)
|
||||
initial_flips_done: set = field(default_factory=set)
|
||||
options: GameOptions = field(default_factory=GameOptions)
|
||||
|
||||
@property
|
||||
def flip_on_discard(self) -> bool:
|
||||
return self.options.flip_on_discard
|
||||
|
||||
def add_player(self, player: Player) -> bool:
|
||||
if len(self.players) >= 6:
|
||||
return False
|
||||
self.players.append(player)
|
||||
return True
|
||||
|
||||
def remove_player(self, player_id: str) -> Optional[Player]:
|
||||
for i, player in enumerate(self.players):
|
||||
if player.id == player_id:
|
||||
return self.players.pop(i)
|
||||
return None
|
||||
|
||||
def get_player(self, player_id: str) -> Optional[Player]:
|
||||
for player in self.players:
|
||||
if player.id == player_id:
|
||||
return player
|
||||
return None
|
||||
|
||||
def current_player(self) -> Optional[Player]:
|
||||
if self.players:
|
||||
return self.players[self.current_player_index]
|
||||
return None
|
||||
|
||||
def start_game(self, num_decks: int = 1, num_rounds: int = 1, options: Optional[GameOptions] = None):
|
||||
self.num_decks = num_decks
|
||||
self.num_rounds = num_rounds
|
||||
self.options = options or GameOptions()
|
||||
self.current_round = 1
|
||||
self.start_round()
|
||||
|
||||
def start_round(self):
|
||||
self.deck = Deck(
|
||||
self.num_decks,
|
||||
use_jokers=self.options.use_jokers,
|
||||
lucky_swing=self.options.lucky_swing
|
||||
)
|
||||
self.discard_pile = []
|
||||
self.drawn_card = None
|
||||
self.drawn_from_discard = False
|
||||
self.finisher_id = None
|
||||
self.players_with_final_turn = set()
|
||||
self.initial_flips_done = set()
|
||||
|
||||
# Deal 6 cards to each player
|
||||
for player in self.players:
|
||||
player.cards = []
|
||||
player.score = 0
|
||||
for _ in range(6):
|
||||
card = self.deck.draw()
|
||||
if card:
|
||||
player.cards.append(card)
|
||||
|
||||
# Start discard pile with one card
|
||||
first_discard = self.deck.draw()
|
||||
if first_discard:
|
||||
first_discard.face_up = True
|
||||
self.discard_pile.append(first_discard)
|
||||
|
||||
self.current_player_index = 0
|
||||
# Skip initial flip phase if 0 flips required
|
||||
if self.options.initial_flips == 0:
|
||||
self.phase = GamePhase.PLAYING
|
||||
else:
|
||||
self.phase = GamePhase.INITIAL_FLIP
|
||||
|
||||
def flip_initial_cards(self, player_id: str, positions: list[int]) -> bool:
|
||||
if self.phase != GamePhase.INITIAL_FLIP:
|
||||
return False
|
||||
|
||||
if player_id in self.initial_flips_done:
|
||||
return False
|
||||
|
||||
required_flips = self.options.initial_flips
|
||||
if len(positions) != required_flips:
|
||||
return False
|
||||
|
||||
player = self.get_player(player_id)
|
||||
if not player:
|
||||
return False
|
||||
|
||||
for pos in positions:
|
||||
if not (0 <= pos < 6):
|
||||
return False
|
||||
player.flip_card(pos)
|
||||
|
||||
self.initial_flips_done.add(player_id)
|
||||
|
||||
# Check if all players have flipped
|
||||
if len(self.initial_flips_done) == len(self.players):
|
||||
self.phase = GamePhase.PLAYING
|
||||
|
||||
return True
|
||||
|
||||
def draw_card(self, player_id: str, source: str) -> Optional[Card]:
|
||||
player = self.current_player()
|
||||
if not player or player.id != player_id:
|
||||
return None
|
||||
|
||||
if self.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
|
||||
return None
|
||||
|
||||
if self.drawn_card is not None:
|
||||
return None
|
||||
|
||||
if source == "deck":
|
||||
card = self.deck.draw()
|
||||
if not card:
|
||||
# Deck empty - try to reshuffle discard pile
|
||||
card = self._reshuffle_discard_pile()
|
||||
if card:
|
||||
self.drawn_card = card
|
||||
self.drawn_from_discard = False
|
||||
return card
|
||||
else:
|
||||
# No cards available anywhere - end round gracefully
|
||||
self._end_round()
|
||||
return None
|
||||
elif source == "discard" and self.discard_pile:
|
||||
card = self.discard_pile.pop()
|
||||
self.drawn_card = card
|
||||
self.drawn_from_discard = True
|
||||
return card
|
||||
|
||||
return None
|
||||
|
||||
def _reshuffle_discard_pile(self) -> Optional[Card]:
|
||||
"""Reshuffle discard pile into deck, keeping top card. Returns drawn card or None."""
|
||||
if len(self.discard_pile) <= 1:
|
||||
# No cards to reshuffle (only top card or empty)
|
||||
return None
|
||||
|
||||
# Keep the top card, take the rest
|
||||
top_card = self.discard_pile[-1]
|
||||
cards_to_reshuffle = self.discard_pile[:-1]
|
||||
|
||||
# Reset face_up for reshuffled cards
|
||||
for card in cards_to_reshuffle:
|
||||
card.face_up = False
|
||||
|
||||
# Add to deck and shuffle
|
||||
self.deck.add_cards(cards_to_reshuffle)
|
||||
|
||||
# Keep only top card in discard pile
|
||||
self.discard_pile = [top_card]
|
||||
|
||||
# Draw from the newly shuffled deck
|
||||
return self.deck.draw()
|
||||
|
||||
def swap_card(self, player_id: str, position: int) -> Optional[Card]:
|
||||
player = self.current_player()
|
||||
if not player or player.id != player_id:
|
||||
return None
|
||||
|
||||
if self.drawn_card is None:
|
||||
return None
|
||||
|
||||
if not (0 <= position < 6):
|
||||
return None
|
||||
|
||||
old_card = player.swap_card(position, self.drawn_card)
|
||||
old_card.face_up = True
|
||||
self.discard_pile.append(old_card)
|
||||
self.drawn_card = None
|
||||
|
||||
self._check_end_turn(player)
|
||||
return old_card
|
||||
|
||||
def can_discard_drawn(self) -> bool:
|
||||
"""Check if player can discard the drawn card."""
|
||||
# Must swap if taking from discard pile (always enforced)
|
||||
if self.drawn_from_discard:
|
||||
return False
|
||||
return True
|
||||
|
||||
def discard_drawn(self, player_id: str) -> bool:
|
||||
player = self.current_player()
|
||||
if not player or player.id != player_id:
|
||||
return False
|
||||
|
||||
if self.drawn_card is None:
|
||||
return False
|
||||
|
||||
# Cannot discard if drawn from discard pile (must swap)
|
||||
if not self.can_discard_drawn():
|
||||
return False
|
||||
|
||||
self.drawn_card.face_up = True
|
||||
self.discard_pile.append(self.drawn_card)
|
||||
self.drawn_card = None
|
||||
|
||||
if self.flip_on_discard:
|
||||
# Version 1: Must flip a card after discarding
|
||||
has_face_down = any(not card.face_up for card in player.cards)
|
||||
if not has_face_down:
|
||||
self._check_end_turn(player)
|
||||
# Otherwise, wait for flip_and_end_turn to be called
|
||||
else:
|
||||
# Version 2 (default): Just end the turn
|
||||
self._check_end_turn(player)
|
||||
return True
|
||||
|
||||
def flip_and_end_turn(self, player_id: str, position: int) -> bool:
|
||||
"""Flip a face-down card after discarding from deck draw."""
|
||||
player = self.current_player()
|
||||
if not player or player.id != player_id:
|
||||
return False
|
||||
|
||||
if not (0 <= position < 6):
|
||||
return False
|
||||
|
||||
if player.cards[position].face_up:
|
||||
return False
|
||||
|
||||
player.flip_card(position)
|
||||
self._check_end_turn(player)
|
||||
return True
|
||||
|
||||
def _check_end_turn(self, player: Player):
|
||||
# Check if player finished (all cards face up)
|
||||
if player.all_face_up() and self.finisher_id is None:
|
||||
self.finisher_id = player.id
|
||||
self.phase = GamePhase.FINAL_TURN
|
||||
self.players_with_final_turn.add(player.id)
|
||||
|
||||
# Move to next player
|
||||
self._next_turn()
|
||||
|
||||
def _next_turn(self):
|
||||
if self.phase == GamePhase.FINAL_TURN:
|
||||
# In final turn phase, track who has had their turn
|
||||
next_index = (self.current_player_index + 1) % len(self.players)
|
||||
next_player = self.players[next_index]
|
||||
|
||||
if next_player.id in self.players_with_final_turn:
|
||||
# Everyone has had their final turn
|
||||
self._end_round()
|
||||
return
|
||||
|
||||
self.current_player_index = next_index
|
||||
self.players_with_final_turn.add(next_player.id)
|
||||
else:
|
||||
self.current_player_index = (self.current_player_index + 1) % len(self.players)
|
||||
|
||||
def _end_round(self):
|
||||
self.phase = GamePhase.ROUND_OVER
|
||||
|
||||
# Reveal all cards and calculate scores
|
||||
for player in self.players:
|
||||
for card in player.cards:
|
||||
card.face_up = True
|
||||
player.calculate_score(self.options)
|
||||
|
||||
# Apply Blackjack rule: score of exactly 21 becomes 0
|
||||
if self.options.blackjack:
|
||||
for player in self.players:
|
||||
if player.score == 21:
|
||||
player.score = 0
|
||||
|
||||
# Apply knock penalty if enabled (+10 if you go out but don't have lowest)
|
||||
if self.options.knock_penalty and self.finisher_id:
|
||||
finisher = self.get_player(self.finisher_id)
|
||||
if finisher:
|
||||
min_score = min(p.score for p in self.players)
|
||||
if finisher.score > min_score:
|
||||
finisher.score += 10
|
||||
|
||||
# Apply knock bonus if enabled (-5 to first player who reveals all)
|
||||
if self.options.knock_bonus and self.finisher_id:
|
||||
finisher = self.get_player(self.finisher_id)
|
||||
if finisher:
|
||||
finisher.score -= 5
|
||||
|
||||
# Apply underdog bonus (-3 to lowest scorer)
|
||||
if self.options.underdog_bonus:
|
||||
min_score = min(p.score for p in self.players)
|
||||
for player in self.players:
|
||||
if player.score == min_score:
|
||||
player.score -= 3
|
||||
|
||||
# Apply tied shame (+5 to players who tie with someone else)
|
||||
if self.options.tied_shame:
|
||||
from collections import Counter
|
||||
score_counts = Counter(p.score for p in self.players)
|
||||
for player in self.players:
|
||||
if score_counts[player.score] > 1:
|
||||
player.score += 5
|
||||
|
||||
for player in self.players:
|
||||
player.total_score += player.score
|
||||
|
||||
# Award round win to lowest scorer(s)
|
||||
min_score = min(p.score for p in self.players)
|
||||
for player in self.players:
|
||||
if player.score == min_score:
|
||||
player.rounds_won += 1
|
||||
|
||||
def start_next_round(self) -> bool:
|
||||
if self.phase != GamePhase.ROUND_OVER:
|
||||
return False
|
||||
|
||||
if self.current_round >= self.num_rounds:
|
||||
self.phase = GamePhase.GAME_OVER
|
||||
return False
|
||||
|
||||
self.current_round += 1
|
||||
self.start_round()
|
||||
return True
|
||||
|
||||
def discard_top(self) -> Optional[Card]:
|
||||
if self.discard_pile:
|
||||
return self.discard_pile[-1]
|
||||
return None
|
||||
|
||||
def get_state(self, for_player_id: str) -> dict:
|
||||
current = self.current_player()
|
||||
|
||||
players_data = []
|
||||
for player in self.players:
|
||||
reveal = self.phase in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER)
|
||||
is_self = player.id == for_player_id
|
||||
|
||||
players_data.append({
|
||||
"id": player.id,
|
||||
"name": player.name,
|
||||
"cards": player.cards_to_dict(reveal=reveal or is_self),
|
||||
"score": player.score if reveal else None,
|
||||
"total_score": player.total_score,
|
||||
"rounds_won": player.rounds_won,
|
||||
"all_face_up": player.all_face_up(),
|
||||
})
|
||||
|
||||
discard_top = self.discard_top()
|
||||
|
||||
return {
|
||||
"phase": self.phase.value,
|
||||
"players": players_data,
|
||||
"current_player_id": current.id if current else None,
|
||||
"discard_top": discard_top.to_dict(reveal=True) if discard_top else None,
|
||||
"deck_remaining": self.deck.cards_remaining() if self.deck else 0,
|
||||
"current_round": self.current_round,
|
||||
"total_rounds": self.num_rounds,
|
||||
"has_drawn_card": self.drawn_card is not None,
|
||||
"can_discard": self.can_discard_drawn() if self.drawn_card else True,
|
||||
"waiting_for_initial_flip": (
|
||||
self.phase == GamePhase.INITIAL_FLIP and
|
||||
for_player_id not in self.initial_flips_done
|
||||
),
|
||||
"initial_flips": self.options.initial_flips,
|
||||
"flip_on_discard": self.flip_on_discard,
|
||||
}
|
||||
Reference in New Issue
Block a user