deadwood 0.1.8

Gin Rummy TUI featuring full knock, gin, and undercut rules with an algorithmic bot opponent.
use std::collections::{BTreeSet, HashSet};

use itertools::Itertools;

use crate::cards::{Card, Rank};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MeldKind {
    Set,
    Run,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Meld {
    pub kind: MeldKind,
    pub cards: Vec<Card>,
}

impl Meld {
    pub fn new(kind: MeldKind, cards: Vec<Card>) -> Self {
        let mut cards = cards;
        cards.sort();
        Self { kind, cards }
    }

    pub fn contains(&self, card: Card) -> bool {
        self.cards.contains(&card)
    }

    pub fn can_layoff(&self, card: Card) -> bool {
        match self.kind {
            MeldKind::Set => self.cards.first().map(|c| c.rank) == Some(card.rank),
            MeldKind::Run => {
                if self.cards.is_empty() {
                    return false;
                }
                if self.cards[0].suit != card.suit {
                    return false;
                }
                let mut sorted = self.cards.clone();
                sorted.sort();
                let min_rank = sorted.first().unwrap().rank as i32;
                let max_rank = sorted.last().unwrap().rank as i32;
                let card_rank = card.rank as i32;
                card_rank == min_rank - 1 || card_rank == max_rank + 1
            }
        }
    }
}

#[derive(Debug, Clone)]
pub struct MeldAnalysis {
    pub melds: Vec<Meld>,
    pub deadwood: Vec<Card>,
    pub deadwood_value: u32,
}

impl MeldAnalysis {
    fn new(melds: Vec<Meld>, deadwood: Vec<Card>) -> Self {
        let deadwood_value = deadwood.iter().map(|c| c.rank.value() as u32).sum();
        Self {
            melds,
            deadwood,
            deadwood_value,
        }
    }
}

pub fn analyze_hand(cards: &[Card]) -> MeldAnalysis {
    let mut sorted = cards.to_vec();
    sorted.sort();
    let candidates = generate_candidates(&sorted);
    let mut best = MeldAnalysis::new(vec![], sorted.clone());
    search_candidates(&sorted, &candidates, &mut vec![], &mut vec![], &mut best);
    best
}

fn generate_candidates(cards: &[Card]) -> Vec<Meld> {
    let mut candidates = Vec::new();
    candidates.extend(generate_sets(cards));
    candidates.extend(generate_runs(cards));

    dedup_melds(candidates)
}

fn generate_sets(cards: &[Card]) -> Vec<Meld> {
    cards
        .iter()
        .cloned()
        .into_group_map_by(|card| card.rank)
        .into_iter()
        .flat_map(|(_, group)| {
            (3..=group.len()).flat_map(move |size| {
                group
                    .iter()
                    .cloned()
                    .combinations(size)
                    .map(|combo| Meld::new(MeldKind::Set, combo))
                    .collect::<Vec<_>>()
            })
        })
        .collect()
}

fn generate_runs(cards: &[Card]) -> Vec<Meld> {
    let mut runs = Vec::new();
    let by_suit = cards.iter().cloned().into_group_map_by(|card| card.suit);

    for (_suit, mut suited_cards) in by_suit {
        suited_cards.sort();
        let unique_cards: Vec<Card> = suited_cards
            .into_iter()
            .collect::<BTreeSet<_>>()
            .into_iter()
            .collect();

        let mut start = 0;
        while start < unique_cards.len() {
            let mut end = start + 1;
            while end < unique_cards.len()
                && ranks_are_consecutive(unique_cards[end - 1].rank, unique_cards[end].rank)
            {
                end += 1;
            }

            for len in 3..=(end - start) {
                for window_start in start..=(end - len) {
                    let window = unique_cards[window_start..window_start + len].to_vec();
                    runs.push(Meld::new(MeldKind::Run, window));
                }
            }

            start = end;
        }
    }

    runs
}

fn ranks_are_consecutive(prev: Rank, next: Rank) -> bool {
    (prev as i32) + 1 == (next as i32)
}

fn dedup_melds(melds: Vec<Meld>) -> Vec<Meld> {
    let mut seen = HashSet::new();
    let mut result = Vec::new();
    for meld in melds {
        if seen.insert(meld.cards.clone()) {
            result.push(meld);
        }
    }
    result
}

fn search_candidates(
    remaining: &[Card],
    candidates: &[Meld],
    current_melds: &mut Vec<Meld>,
    deadwood: &mut Vec<Card>,
    best: &mut MeldAnalysis,
) {
    if remaining.is_empty() {
        let analysis = MeldAnalysis::new(current_melds.clone(), deadwood.clone());
        if analysis.deadwood_value < best.deadwood_value
            || (analysis.deadwood_value == best.deadwood_value
                && analysis.melds.len() > best.melds.len())
        {
            *best = analysis;
        }
        return;
    }

    let card = remaining[0];
    let rest = &remaining[1..];

    deadwood.push(card);
    search_candidates(rest, candidates, current_melds, deadwood, best);
    deadwood.pop();

    for meld in candidates.iter().filter(|m| m.contains(card)) {
        if meld.cards.iter().all(|c| remaining.contains(c))
            && meld.cards.iter().all(|c| !deadwood.contains(c))
            && meld
                .cards
                .iter()
                .all(|c| current_melds.iter().all(|m| !m.contains(*c)))
        {
            current_melds.push(meld.clone());
            let mut reduced: Vec<Card> = remaining
                .iter()
                .filter(|c| !meld.cards.contains(c))
                .cloned()
                .collect();
            reduced.sort();
            search_candidates(&reduced, candidates, current_melds, deadwood, best);
            current_melds.pop();
        }
    }
}

pub fn layoff_cards(deadwood: &[Card], knocker_melds: &[Meld]) -> (Vec<Card>, Vec<Card>) {
    let mut remaining = Vec::new();
    let mut laid_off = Vec::new();
    let mut expanded_melds = knocker_melds.to_vec();

    'outer: for card in deadwood {
        for meld in expanded_melds.iter_mut() {
            if meld.can_layoff(*card) {
                laid_off.push(*card);
                meld.cards.push(*card);
                meld.cards.sort();
                continue 'outer;
            }
        }
        remaining.push(*card);
    }

    (remaining, laid_off)
}