diff --git a/schafkopf-logic/.gitignore b/schafkopf-logic/.gitignore index 5bdfff6..6813bcc 100644 --- a/schafkopf-logic/.gitignore +++ b/schafkopf-logic/.gitignore @@ -1,3 +1,4 @@ target Cargo.lock -shell.nix \ No newline at end of file +shell.nix +dist \ No newline at end of file diff --git a/schafkopf-logic/Cargo.toml b/schafkopf-logic/Cargo.toml index 30f55f3..2abd6cf 100644 --- a/schafkopf-logic/Cargo.toml +++ b/schafkopf-logic/Cargo.toml @@ -6,4 +6,9 @@ edition = "2024" [dependencies] strum = "0.27" strum_macros = "0.27" -rand = "0.9" \ No newline at end of file +rand = "0.9" + +bevy = { version = "0.17", features = ["jpeg"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.3", features = ["wasm_js"] } \ No newline at end of file diff --git a/schafkopf-logic/Trunk.toml b/schafkopf-logic/Trunk.toml new file mode 100644 index 0000000..eaf2280 --- /dev/null +++ b/schafkopf-logic/Trunk.toml @@ -0,0 +1,7 @@ +[build] +dist = "dist" +release = true + +[serve] +open = false +port = 8080 diff --git a/schafkopf-logic/assets/symbole.png b/schafkopf-logic/assets/symbole.png new file mode 100644 index 0000000..dc90e81 Binary files /dev/null and b/schafkopf-logic/assets/symbole.png differ diff --git a/schafkopf-logic/index.html b/schafkopf-logic/index.html new file mode 100644 index 0000000..c22e723 --- /dev/null +++ b/schafkopf-logic/index.html @@ -0,0 +1,11 @@ + + + + + Schafkopf Logic + + + + + + diff --git a/schafkopf-logic/src/gamemode.rs b/schafkopf-logic/src/gamemode.rs deleted file mode 100644 index 48c91ac..0000000 --- a/schafkopf-logic/src/gamemode.rs +++ /dev/null @@ -1,135 +0,0 @@ -use crate::deck::{Card, Suit, Rank}; - -pub enum Gamemode { - Sauspiel(Suit), - Solo(Suit), - Wenz(Suit), - Geier(Suit), - Bettel, - Ramsch -} - -impl Gamemode { - pub fn winning_card<'a>(&self, cards: [&'a Card; 4]) -> &'a Card { - match self { - Gamemode::Sauspiel(_) | Gamemode::Ramsch | Gamemode::Bettel => - winner_for_trump(Suit::Herz, cards), - Gamemode::Solo(solo_suit) => winner_for_trump(*solo_suit, cards), - _ => cards[0], - } - } -} - -fn winner_for_trump<'a>(trump_suit: Suit, cards: [&'a Card; 4]) -> &'a Card { - if cards.iter().any(|&c| is_trump(c, trump_suit)) { - // Highest trump wins - let winner_idx = cards - .iter() - .enumerate() - .filter(|&(_, &c)| is_trump(c, trump_suit)) - .max_by_key(|&(_, &c)| trump_strength(c, trump_suit)) - .map(|(i, _)| i) - .unwrap_or(0); - cards[winner_idx] - } else { - // No trump: highest of the led suit wins - let first_suit = cards[0].suit; - let winner_idx = cards - .iter() - .enumerate() - .filter(|&(_, &c)| c.suit == first_suit) - .max_by_key(|&(_, &c)| non_trump_strength(c.rank)) - .map(|(i, _)| i) - .unwrap_or(0); - cards[winner_idx] - } -} - -// A card is trump in Sauspiel if it's an Ober, an Unter, or of the trump suit. -fn is_trump(card: &Card, trump_suit: Suit) -> bool { - card.rank == Rank::Ober || card.rank == Rank::Unter || card.suit == trump_suit -} - -// Trump strength according to Schafkopf: -// Obers: Eichel > Gras > Herz > Schell -// Unters: Eichel > Gras > Herz > Schell -// Then trump suit cards: A > 10 > K > 9 > 8 > 7 -fn trump_strength(card: &Card, trump_suit: Suit) -> u16 { - match card.rank { - Rank::Ober => 300 + ober_unter_suit_strength(card.suit), - Rank::Unter => 200 + ober_unter_suit_strength(card.suit), - _ if card.suit == trump_suit => 100 + non_trump_strength(card.rank) as u16, - _ => 0, - } -} - -// Suit precedence for Obers/Unters: Eichel > Gras > Herz > Schell -fn ober_unter_suit_strength(s: Suit) -> u16 { - match s { - Suit::Eichel => 4, - Suit::Gras => 3, - Suit::Herz => 2, - Suit::Schell => 1, - } -} - -// Non-trump (following suit) strength: A > 10 > K > 9 > 8 > 7 -fn non_trump_strength(rank: Rank) -> u8 { - match rank { - Rank::Ass => 6, - Rank::Zehn => 5, - Rank::Koenig => 4, - Rank::Neun => 3, - Rank::Acht => 2, - Rank::Sieben => 1, - _ => 0, // Ober/Unter are trumps, handled elsewhere - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::deck::{Card, Suit, Rank}; - - fn card(suit: Suit, rank: Rank) -> Card { - Card { suit, rank } - } - - #[test] - fn sauspiel_trump_beats_non_trump() { - // In Sauspiel, trumps are all Obers, all Unters, and Herz. - let c0 = card(Suit::Eichel, Rank::Ass); // lead (non-trump) - let c1 = card(Suit::Gras, Rank::Ober); // trump (Ober) - let c2 = card(Suit::Herz, Rank::Zehn); // trump (Herz suit) - let c3 = card(Suit::Gras, Rank::Ass); // non-trump - - let winner = Gamemode::Sauspiel(Suit::Eichel).winning_card([&c0, &c1, &c2, &c3]); - // Ober beats Herz trumps due to higher trump tier - assert_eq!(winner, &c1); - } - - #[test] - fn sauspiel_follow_suit_highest_non_trump_when_no_trumps() { - // No Obers/Unters and no Herz => follow led suit, highest wins (A > 10 > K > 9 > 8 > 7) - let c0 = card(Suit::Eichel, Rank::Koenig); // lead - let c1 = card(Suit::Gras, Rank::Ass); - let c2 = card(Suit::Eichel, Rank::Ass); // same suit as lead, highest - let c3 = card(Suit::Schell, Rank::Zehn); - - let winner = Gamemode::Sauspiel(Suit::Gras).winning_card([&c0, &c1, &c2, &c3]); - assert_eq!(winner, &c2); - } - - #[test] - fn solo_uses_declared_suit_as_trump() { - // In Solo(Gras), trumps are all Obers, all Unters, and all Gras. - // Avoid Obers/Unters to focus on the solo suit behaving as trump. - let c0 = card(Suit::Herz, Rank::Koenig); // lead - let c1 = card(Suit::Gras, Rank::Zehn); // trump (solo suit) - let c2 = card(Suit::Eichel, Rank::Ass); - let c3 = card(Suit::Schell, Rank::Ass); - - let winner = Gamemode::Solo(Suit::Gras).winning_card([&c0, &c1, &c2, &c3]); - assert_eq!(winner, &c1); - } -} \ No newline at end of file diff --git a/schafkopf-logic/src/gamemode/mod.rs b/schafkopf-logic/src/gamemode/mod.rs new file mode 100644 index 0000000..bfa1f2a --- /dev/null +++ b/schafkopf-logic/src/gamemode/mod.rs @@ -0,0 +1,125 @@ +use crate::deck::{Card, Suit, Rank}; + +pub enum Gamemode { + Sauspiel(Suit), + Solo(Suit), + Wenz(Option), + Geier(Option), + Bettel, + Ramsch +} + +impl Gamemode { + pub fn winning_card<'a>(&self, cards: [&'a Card; 4]) -> &'a Card { + match self { + Gamemode::Sauspiel(_) | Gamemode::Ramsch | Gamemode::Bettel => + winner_for_trump(Suit::Herz, cards), + Gamemode::Solo(solo_suit) => winner_for_trump(*solo_suit, cards), + Gamemode::Wenz(wenz_suit) => winner_for_wenz(Rank::Unter, *wenz_suit, cards), + Gamemode::Geier(geier_suit) => winner_for_wenz(Rank::Ober, *geier_suit, cards), + } + } +} + +fn winner_for_wenz<'a>(rank: Rank, trump_suit: Option, cards: [&'a Card; 4]) -> &'a Card { + let ranks = [rank]; + + if cards.iter().any(|&c| is_trump(c, &ranks, trump_suit)) { + let winner_idx = cards + .iter() + .enumerate() + .filter(|&(_, &c)| is_trump(c, &ranks, trump_suit)) + .max_by_key(|&(_, &c)| trump_strength_wenz(c, rank, trump_suit)) + .map(|(i, _)| i) + .unwrap_or(0); + cards[winner_idx] + } else { + let first_suit = cards[0].suit; + let winner_idx = cards + .iter() + .enumerate() + .filter(|&(_, &c)| c.suit == first_suit) + .max_by_key(|&(_, &c)| non_trump_strength(c.rank)) + .map(|(i, _)| i) + .unwrap_or(0); + cards[winner_idx] + } +} + +fn winner_for_trump<'a>(trump_suit: Suit, cards: [&'a Card; 4]) -> &'a Card { + let ranks = [Rank::Ober, Rank::Unter]; + if cards.iter().any(|&c| is_trump(c, &ranks, Some(trump_suit))) { + // Highest trump wins + let winner_idx = cards + .iter() + .enumerate() + .filter(|&(_, &c)| is_trump(c, &ranks, Some(trump_suit))) + .max_by_key(|&(_, &c)| trump_strength(c, trump_suit)) + .map(|(i, _)| i) + .unwrap_or(0); + cards[winner_idx] + } else { + // No trump: highest of the led suit wins + let first_suit = cards[0].suit; + let winner_idx = cards + .iter() + .enumerate() + .filter(|&(_, &c)| c.suit == first_suit) + .max_by_key(|&(_, &c)| non_trump_strength(c.rank)) + .map(|(i, _)| i) + .unwrap_or(0); + cards[winner_idx] + } +} + +fn is_trump(card: &Card, trump_ranks: &[Rank], trump_suit: Option) -> bool { + trump_ranks.iter().any(|&r| card.rank == r) || trump_suit.map_or(false, |s| card.suit == s) +} + +// Trump strength according to Schafkopf: +// Obers: Eichel > Gras > Herz > Schell +// Unters: Eichel > Gras > Herz > Schell +// Then trump suit cards: A > 10 > K > 9 > 8 > 7 +fn trump_strength(card: &Card, trump_suit: Suit) -> u16 { + match card.rank { + Rank::Ober => 300 + ober_unter_suit_strength(card.suit), + Rank::Unter => 200 + ober_unter_suit_strength(card.suit), + _ if card.suit == trump_suit => 100 + non_trump_strength(card.rank) as u16, + _ => 0, + } +} + +fn trump_strength_wenz(card: &Card, rank: Rank, trump_suit: Option) -> u16 { + if card.rank == rank { + 200 + ober_unter_suit_strength(card.suit) + } else if trump_suit.map_or(false, |s| card.suit == s) { + 100 + non_trump_strength(card.rank) as u16 + } else { + 0 + } +} + +fn ober_unter_suit_strength(suit: Suit) -> u16 { + match suit { + Suit::Eichel => 4, + Suit::Gras => 3, + Suit::Herz => 2, + Suit::Schell => 1, + } +} + +fn non_trump_strength(rank: Rank) -> u8 { + match rank { + Rank::Ass => 8, + Rank::Zehn => 7, + Rank::Koenig => 6, + Rank::Ober => 5, + Rank::Unter => 4, + Rank::Neun => 3, + Rank::Acht => 2, + Rank::Sieben => 1, + } +} + +#[cfg(test)] +mod tests; diff --git a/schafkopf-logic/src/gamemode/tests.rs b/schafkopf-logic/src/gamemode/tests.rs new file mode 100644 index 0000000..ba66b86 --- /dev/null +++ b/schafkopf-logic/src/gamemode/tests.rs @@ -0,0 +1,313 @@ +use super::*; +use crate::deck::{Card, Suit, Rank}; + +fn card(suit: Suit, rank: Rank) -> Card { + Card { suit, rank } +} + +#[test] +fn winner_test_1() { + let c1 = card(Suit::Herz, Rank::Ober); + let c2 = card(Suit::Gras, Rank::Ober); + let c3 = card(Suit::Schell, Rank::Ass); + let c4 = card(Suit::Gras, Rank::Koenig); + + let winner = Gamemode::Sauspiel(Suit::Eichel).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c2); + + let winner = Gamemode::Wenz(None).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c1); + + let winner = Gamemode::Wenz(Some(Suit::Herz)).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c1); + + let winner = Gamemode::Wenz(Some(Suit::Gras)).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c4); + + let winner = Gamemode::Wenz(Some(Suit::Schell)).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c3); + + let winner = Gamemode::Geier(Some(Suit::Schell)).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c2); + + // Extra: Solo and Herz-trump modes behave consistently with O/U > suit trumps + let winner = Gamemode::Solo(Suit::Gras).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c2); + + let winner = Gamemode::Bettel.winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c2); + + let winner = Gamemode::Ramsch.winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c2); +} + +#[test] +fn sauspiel_trump_hierarchy() { + // In Sauspiel, trump is always Herz; Obers > Unters > Herz-suit trumps + let c1 = card(Suit::Eichel, Rank::Neun); // led suit, non-trump + let c2 = card(Suit::Gras, Rank::Ober); // trump (Ober) + let c3 = card(Suit::Herz, Rank::Ass); // trump (trump suit) + let c4 = card(Suit::Schell, Rank::Unter); // trump (Unter) + + let winner = Gamemode::Sauspiel(Suit::Eichel).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c2); + + // More checks on the same trick: + // Wenz: only Unters are trump -> Unter wins + let winner = Gamemode::Wenz(None).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c4); + + // Geier: only Obers are trump -> Ober wins + let winner = Gamemode::Geier(None).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c2); + + // Solo (any suit): O/U outrank suit trumps -> Ober wins + let winner = Gamemode::Solo(Suit::Schell).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c2); + + // Herz-trump modes equivalent + let winner = Gamemode::Bettel.winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c2); + let winner = Gamemode::Ramsch.winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c2); +} + +#[test] +fn sauspiel_ober_suit_precedence() { + // Among Obers: Eichel > Gras > Herz > Schell + let c1 = card(Suit::Gras, Rank::Koenig); // led + let c2 = card(Suit::Eichel, Rank::Ober); // highest Ober + let c3 = card(Suit::Herz, Rank::Ober); // lower Ober + let c4 = card(Suit::Schell, Rank::Unter); // trump but below any Ober + + let winner = Gamemode::Sauspiel(Suit::Gras).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c2); + + // More checks: + let winner = Gamemode::Solo(Suit::Schell).winning_card([&c1, &c2, &c3, &c4]); // O/U trump + assert_eq!(winner, &c2); + + let winner = Gamemode::Geier(None).winning_card([&c1, &c2, &c3, &c4]); // Obers trump + assert_eq!(winner, &c2); + + let winner = Gamemode::Wenz(None).winning_card([&c1, &c2, &c3, &c4]); // only Unter trump + assert_eq!(winner, &c4); + + let winner = Gamemode::Bettel.winning_card([&c1, &c2, &c3, &c4]); // Herz-trump + assert_eq!(winner, &c2); +} + +#[test] +fn sauspiel_no_trump_led_suit_highest() { + // No Obers/Unters and no Herz cards: highest of led suit wins (A > 10 > K > 9 > 8 > 7) + let c1 = card(Suit::Eichel, Rank::Koenig); // led suit + let c2 = card(Suit::Gras, Rank::Ass); + let c3 = card(Suit::Eichel, Rank::Zehn); // higher than König + let c4 = card(Suit::Schell, Rank::Neun); + + let winner = Gamemode::Sauspiel(Suit::Schell).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c3); + + // More checks: + let winner = Gamemode::Wenz(None).winning_card([&c1, &c2, &c3, &c4]); // no Unters + assert_eq!(winner, &c3); + + let winner = Gamemode::Solo(Suit::Gras).winning_card([&c1, &c2, &c3, &c4]); // Gras suit trump + assert_eq!(winner, &c2); + + let winner = Gamemode::Solo(Suit::Schell).winning_card([&c1, &c2, &c3, &c4]); // Schell suit trump + assert_eq!(winner, &c4); + + let winner = Gamemode::Solo(Suit::Eichel).winning_card([&c1, &c2, &c3, &c4]); // both Eichel trumps; A>10>K... + assert_eq!(winner, &c3); + + let winner = Gamemode::Geier(Some(Suit::Schell)).winning_card([&c1, &c2, &c3, &c4]); // suit trump Schell + assert_eq!(winner, &c4); +} + +#[test] +fn solo_suit_trumps_only_internal_order() { + // In Solo, chosen suit is trump plus all Obers/Unters; with only suit trumps present, A > 10 > K > 9 > 8 > 7 + let c1 = card(Suit::Schell, Rank::Zehn); // trump suit + let c2 = card(Suit::Gras, Rank::Koenig); + let c3 = card(Suit::Schell, Rank::Ass); // highest among suit trumps + let c4 = card(Suit::Eichel, Rank::Neun); + + let winner = Gamemode::Solo(Suit::Schell).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c3); + + // More checks: + let winner = Gamemode::Sauspiel(Suit::Eichel).winning_card([&c1, &c2, &c3, &c4]); // no O/U, Herz not present -> follow suit + assert_eq!(winner, &c3); + + let winner = Gamemode::Solo(Suit::Gras).winning_card([&c1, &c2, &c3, &c4]); // only Gras becomes trump + assert_eq!(winner, &c2); + + let winner = Gamemode::Wenz(None).winning_card([&c1, &c2, &c3, &c4]); // no Unters -> follow suit + assert_eq!(winner, &c3); + + let winner = Gamemode::Wenz(Some(Suit::Schell)).winning_card([&c1, &c2, &c3, &c4]); // suit trump Schell + assert_eq!(winner, &c3); +} + +#[test] +fn wenz_unter_trumps_over_optional_suit_trump() { + // In Wenz with extra suit trump, Unters outrank any suit trumps + let c1 = card(Suit::Eichel, Rank::Ass); // led + let c2 = card(Suit::Gras, Rank::Koenig); // trump by suit (Gras) if chosen + let c3 = card(Suit::Schell, Rank::Unter); // trump by Unter (beats suit trumps) + let c4 = card(Suit::Gras, Rank::Ass); // trump by suit (Gras) if chosen + + let winner = Gamemode::Wenz(Some(Suit::Gras)).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c3); + + // More checks: + let winner = Gamemode::Wenz(None).winning_card([&c1, &c2, &c3, &c4]); // only Unter trump + assert_eq!(winner, &c3); + + let winner = Gamemode::Geier(None).winning_card([&c1, &c2, &c3, &c4]); // no Obers -> follow suit + assert_eq!(winner, &c1); + + let winner = Gamemode::Geier(Some(Suit::Gras)).winning_card([&c1, &c2, &c3, &c4]); // suit trump Gras + assert_eq!(winner, &c4); + + let winner = Gamemode::Sauspiel(Suit::Eichel).winning_card([&c1, &c2, &c3, &c4]); // O/U trump -> Unter wins + assert_eq!(winner, &c3); +} + +#[test] +fn wenz_unter_precedence_between_suits() { + // Unter precedence: Eichel > Gras > Herz > Schell + let c1 = card(Suit::Herz, Rank::Neun); // led + let c2 = card(Suit::Gras, Rank::Unter); + let c3 = card(Suit::Eichel, Rank::Unter); // highest Unter + let c4 = card(Suit::Schell, Rank::Unter); + + let winner = Gamemode::Wenz(None).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c3); + + // More checks: + let winner = Gamemode::Wenz(Some(Suit::Gras)).winning_card([&c1, &c2, &c3, &c4]); // Unters still outrank suit trumps + assert_eq!(winner, &c3); + + let winner = Gamemode::Geier(None).winning_card([&c1, &c2, &c3, &c4]); // no Obers -> follow suit + assert_eq!(winner, &c1); + + let winner = Gamemode::Geier(Some(Suit::Gras)).winning_card([&c1, &c2, &c3, &c4]); // suit trump Gras + assert_eq!(winner, &c2); + + let winner = Gamemode::Sauspiel(Suit::Schell).winning_card([&c1, &c2, &c3, &c4]); // O/U trump -> highest Unter by suit + assert_eq!(winner, &c3); +} + +#[test] +fn wenz_no_trump_led_suit_highest() { + // No Unters and no optional suit trumps: highest of led suit wins + let c1 = card(Suit::Eichel, Rank::Koenig); // led suit + let c2 = card(Suit::Gras, Rank::Ass); + let c3 = card(Suit::Eichel, Rank::Zehn); // higher than König + let c4 = card(Suit::Schell, Rank::Neun); + + let winner = Gamemode::Wenz(None).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c3); + + // More checks: + let winner = Gamemode::Wenz(Some(Suit::Gras)).winning_card([&c1, &c2, &c3, &c4]); // suit trump Gras + assert_eq!(winner, &c2); + + let winner = Gamemode::Geier(None).winning_card([&c1, &c2, &c3, &c4]); // no Obers -> follow suit + assert_eq!(winner, &c3); + + let winner = Gamemode::Geier(Some(Suit::Schell)).winning_card([&c1, &c2, &c3, &c4]); // suit trump Schell + assert_eq!(winner, &c4); + + let winner = Gamemode::Solo(Suit::Eichel).winning_card([&c1, &c2, &c3, &c4]); // Eichel suit trump + assert_eq!(winner, &c3); +} + +#[test] +fn geier_ober_trumps_over_optional_suit_trump() { + // In Geier with extra suit trump, Obers outrank any suit trumps + let c1 = card(Suit::Gras, Rank::Ass); // led + let c2 = card(Suit::Schell, Rank::Koenig); // trump by suit (optional) + let c3 = card(Suit::Eichel, Rank::Ober); // trump by Ober (beats suit trumps) + let c4 = card(Suit::Schell, Rank::Ass); // trump by suit (optional) + + let winner = Gamemode::Geier(Some(Suit::Schell)).winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c3); + + // More checks: + let winner = Gamemode::Geier(None).winning_card([&c1, &c2, &c3, &c4]); // Obers trump + assert_eq!(winner, &c3); + + let winner = Gamemode::Wenz(None).winning_card([&c1, &c2, &c3, &c4]); // no Unters -> follow suit + assert_eq!(winner, &c1); + + let winner = Gamemode::Wenz(Some(Suit::Schell)).winning_card([&c1, &c2, &c3, &c4]); // suit trump Schell + assert_eq!(winner, &c4); + + let winner = Gamemode::Sauspiel(Suit::Eichel).winning_card([&c1, &c2, &c3, &c4]); // O/U trump -> Ober wins + assert_eq!(winner, &c3); +} + +#[test] +fn bettel_behaves_like_herz_trump() { + // Current implementation treats Bettel like Herz-trump + let c1 = card(Suit::Gras, Rank::Ass); // led + let c2 = card(Suit::Herz, Rank::Neun); // trump by Herz suit + let c3 = card(Suit::Schell, Rank::Ass); + let c4 = card(Suit::Eichel, Rank::Koenig); + + let winner = Gamemode::Bettel.winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c2); + + // More checks: + let winner = Gamemode::Ramsch.winning_card([&c1, &c2, &c3, &c4]); // same as Bettel currently + assert_eq!(winner, &c2); + + let winner = Gamemode::Sauspiel(Suit::Eichel).winning_card([&c1, &c2, &c3, &c4]); // Herz trump + assert_eq!(winner, &c2); + + let winner = Gamemode::Solo(Suit::Herz).winning_card([&c1, &c2, &c3, &c4]); // Herz trump in Solo + assert_eq!(winner, &c2); + + let winner = Gamemode::Solo(Suit::Gras).winning_card([&c1, &c2, &c3, &c4]); // Gras trump; no O/U + assert_eq!(winner, &c1); + + let winner = Gamemode::Wenz(Some(Suit::Herz)).winning_card([&c1, &c2, &c3, &c4]); // suit trump Herz + assert_eq!(winner, &c2); + + let winner = Gamemode::Geier(Some(Suit::Herz)).winning_card([&c1, &c2, &c3, &c4]); // suit trump Herz + assert_eq!(winner, &c2); +} + +#[test] +fn ramsch_behaves_like_herz_trump() { + // Current implementation treats Ramsch like Herz-trump + let c1 = card(Suit::Eichel, Rank::Ass); // led + let c2 = card(Suit::Schell, Rank::Koenig); + let c3 = card(Suit::Herz, Rank::Zehn); // trump by Herz suit + let c4 = card(Suit::Gras, Rank::Neun); + + let winner = Gamemode::Ramsch.winning_card([&c1, &c2, &c3, &c4]); + assert_eq!(winner, &c3); + + // More checks: + let winner = Gamemode::Bettel.winning_card([&c1, &c2, &c3, &c4]); // same as Ramsch currently + assert_eq!(winner, &c3); + + let winner = Gamemode::Sauspiel(Suit::Eichel).winning_card([&c1, &c2, &c3, &c4]); // Herz trump + assert_eq!(winner, &c3); + + let winner = Gamemode::Solo(Suit::Herz).winning_card([&c1, &c2, &c3, &c4]); // Herz trump in Solo + assert_eq!(winner, &c3); + + let winner = Gamemode::Solo(Suit::Eichel).winning_card([&c1, &c2, &c3, &c4]); // Eichel trump; no O/U + assert_eq!(winner, &c1); + + let winner = Gamemode::Wenz(Some(Suit::Herz)).winning_card([&c1, &c2, &c3, &c4]); // suit trump Herz + assert_eq!(winner, &c3); + + let winner = Gamemode::Geier(Some(Suit::Herz)).winning_card([&c1, &c2, &c3, &c4]); // suit trump Herz + assert_eq!(winner, &c3); +} \ No newline at end of file diff --git a/schafkopf-logic/src/lib.rs b/schafkopf-logic/src/lib.rs new file mode 100644 index 0000000..c06dff1 --- /dev/null +++ b/schafkopf-logic/src/lib.rs @@ -0,0 +1,3 @@ +pub mod deck; +pub mod gamemode; +pub mod player; diff --git a/schafkopf-logic/src/main.rs b/schafkopf-logic/src/main.rs index ea1edcb..c538aa3 100644 --- a/schafkopf-logic/src/main.rs +++ b/schafkopf-logic/src/main.rs @@ -1,51 +1,335 @@ -mod deck; -mod gamemode; +use bevy::{ + asset::{AssetMetaCheck, AssetPlugin, RenderAssetUsages}, + prelude::*, + sprite::Anchor, + render::render_resource::{Extent3d, TextureDimension, TextureFormat}, +}; +use schafkopf_logic::{ + deck::{Card, Deck, Rank, Suit}, + gamemode::Gamemode, + player::{HumanPlayer, InternalPlayer}, +}; -use deck::{Deck, Suit, Card, Rank}; -use gamemode::Gamemode; +use std::fmt::Debug; + + +const CARD_TEXTURE_WIDTH: usize = 96; +const CARD_TEXTURE_HEIGHT: usize = 135; +const CARD_WORLD_SIZE: Vec2 = Vec2::new(96.0, 135.0); +const ICON_OFFSET_TL: Vec2 = Vec2::new(-CARD_WORLD_SIZE.x * 0.5 + 16.0, CARD_WORLD_SIZE.y * 0.5 - 20.0); +const ICON_OFFSET_BR: Vec2 = Vec2::new( CARD_WORLD_SIZE.x * 0.5 - 16.0, -CARD_WORLD_SIZE.y * 0.5 + 20.0); +const GLYPH_WIDTH: usize = 5; +const GLYPH_HEIGHT: usize = 7; +const GLYPH_STRIDE: usize = 6; +const SUIT_ICON_PX: usize = 32; +const LABEL_MARGIN_X: usize = 14; +const LABEL_MARGIN_Y: usize = 8; +const LABEL_TEXT_GAP: usize = 4; + +#[derive(Resource)] +struct CurrentGamemode(Gamemode); + +#[derive(Resource)] +struct SuitAtlas { + texture: Handle, + layout: Handle, +} + +impl SuitAtlas { + fn load( + asset_server: &AssetServer, + layouts: &mut Assets, + ) -> Self { + let texture: Handle = asset_server.load("symbole.png"); + let layout = TextureAtlasLayout::from_grid(UVec2::splat(32), 2, 2, None, None); + let layout_handle = layouts.add(layout); + + Self { texture, layout: layout_handle } + } + + fn index_for(&self, suit: Suit) -> usize { + match suit { + Suit::Eichel => 0, + Suit::Gras => 1, + Suit::Herz => 2, + Suit::Schell => 3, + } + } +} + +#[derive(Resource)] +struct PlayerHandResource { + cards: Vec, +} + +#[derive(Component)] +struct PlayerCardVisual { + card: Card, + index: usize, +} fn main() { + App::new() + .add_plugins( + DefaultPlugins.set( + AssetPlugin { + meta_check: AssetMetaCheck::Never, + ..default() + } + ).set(ImagePlugin::default_nearest())) + .add_systems(Startup, setup_game) + .add_systems(PostStartup, spawn_player_hand) + .run(); +} + +fn setup_game( + mut commands: Commands, + asset_server: Res, + mut texture_layouts: ResMut>, +) { + commands.spawn(Camera2d); + + let atlas = SuitAtlas::load(&asset_server, &mut texture_layouts); + commands.insert_resource(atlas); + let mut deck = Deck::new(); deck.shuffle(); + let [mut hand1, mut hand2, mut hand3, mut hand4] = + deck.deal_4x8().expect("expected a full deck to deal four hands"); - let [mut hand1, mut hand2, mut hand3, mut hand4] = deck.deal_4x8().unwrap(); + sort_cards(&mut hand1); - println!("Player 1 has:"); - for card in hand1.iter() { - println!("{}", card) - } + let mut p1 = HumanPlayer::new(1, "Alice"); + let mut p2 = HumanPlayer::new(2, "Bob"); + let mut p3 = HumanPlayer::new(3, "Clara"); + let mut p4 = HumanPlayer::new(4, "Max"); - println!("\nPlayer 2 has:"); - for card in hand2.iter() { - println!("{}", card) - } + p1.set_hand(hand1); + p2.set_hand(hand2); + p3.set_hand(hand3); + p4.set_hand(hand4); - println!("\nPlayer 3 has:"); - for card in hand3.iter() { - println!("{}", card) - } + let mode = Gamemode::Wenz(None); + commands.insert_resource(CurrentGamemode(mode)); - println!("\nPlayer 4 has:"); - for card in hand4.iter() { - println!("{}", card) - } - - let mode = Gamemode::Solo(Suit::Gras); - - //let trick: [&Card; 4] = [&hand1[0], &hand2[0], &hand3[0], &hand4[0]]; - let c1 = Card { suit: Suit::Schell, rank: Rank::Zehn }; - let c2 = Card { suit: Suit::Schell, rank: Rank::Ass }; - let c3 = Card { suit: Suit::Schell, rank: Rank::Unter }; - let c4 = Card { suit: Suit::Gras, rank: Rank::Sieben }; - - let trick: [&Card; 4] = [&c1, &c2, &c3, &c4]; - - - for card in trick.iter() { - println!("{}", card) - } - println!("\n"); - - let winner = mode.winning_card(trick); - println!("\nWinning card: {}", winner); + commands.insert_resource(PlayerHandResource { + cards: p1.hand().clone(), + }); } + +fn spawn_player_hand( + mut commands: Commands, + mut images: ResMut>, + atlas: Res, + hand: Res, +) { + let spacing = CARD_WORLD_SIZE.x + 5.0; + let start_x = -(spacing * (hand.cards.len() as f32 - 1.0) / 2.0); + let y = -200.0; + + for (i, card) in hand.cards.iter().enumerate() { + let base_handle = create_card_texture(&mut images, card); + + let parent = commands + .spawn((Transform::from_xyz(start_x + i as f32 * spacing, y, 0.0))) + .id(); + + commands.entity(parent).with_children(|c| { + c.spawn(( + Sprite { + image: base_handle, + custom_size: Some(CARD_WORLD_SIZE), + ..default() + }, + Transform::from_xyz(0.0, 0.0, 0.0), + Pickable::default(), + )) + .observe(on_hover()) + .observe(on_unhover()) + .observe(on_click(card.clone())); + + c.spawn(( + Sprite::from_atlas_image( + atlas.texture.clone(), + TextureAtlas { + layout: atlas.layout.clone(), + index: atlas.index_for(card.suit), + }, + ), + Transform::from_xyz(ICON_OFFSET_TL.x, ICON_OFFSET_TL.y, 0.1), // on top + )); + + c.spawn(( + Sprite::from_atlas_image( + atlas.texture.clone(), + TextureAtlas { + layout: atlas.layout.clone(), + index: atlas.index_for(card.suit), + }, + ), + Transform::from_xyz(ICON_OFFSET_BR.x, ICON_OFFSET_BR.y, 0.1), + )); + }); + } +} +fn sort_cards(cards: &mut Vec) { + cards.sort_by(|a, b| a.suit.cmp(&b.suit).then(a.rank.cmp(&b.rank))); +} + + +fn create_card_texture(images: &mut Assets, card: &Card) -> Handle { + let mut pixels = vec![0u8; CARD_TEXTURE_WIDTH * CARD_TEXTURE_HEIGHT * 4]; + + let background = suit_background(card.suit); + for chunk in pixels.chunks_exact_mut(4) { + chunk.copy_from_slice(&background); + } + + draw_border(&mut pixels, [45, 45, 45, 255]); + + let rank_text = rank_label(card.rank); + + let ink = [15, 15, 15, 255]; + let rank_text_len = rank_text.chars().count(); + let rank_text_width = if rank_text_len == 0 { + 0 + } else { + (rank_text_len - 1) * GLYPH_STRIDE + GLYPH_WIDTH + }; + + let top_label_x = LABEL_MARGIN_X; + let top_label_y = LABEL_MARGIN_Y + SUIT_ICON_PX + LABEL_TEXT_GAP; + let bottom_label_x = CARD_TEXTURE_WIDTH.saturating_sub(LABEL_MARGIN_X + rank_text_width); + let bottom_label_y = CARD_TEXTURE_HEIGHT + .saturating_sub(LABEL_MARGIN_Y + SUIT_ICON_PX + LABEL_TEXT_GAP + GLYPH_HEIGHT); + + draw_text(&mut pixels, top_label_x, top_label_y, rank_text, ink); + draw_text(&mut pixels, bottom_label_x, bottom_label_y, rank_text, ink); + + let extent = Extent3d { + width: CARD_TEXTURE_WIDTH as u32, + height: CARD_TEXTURE_HEIGHT as u32, + depth_or_array_layers: 1, + }; + + let image = Image::new_fill( + extent, + TextureDimension::D2, + &pixels, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::default(), + ); + + images.add(image) +} + +fn draw_border(pixels: &mut [u8], color: [u8; 4]) { + for x in 0..CARD_TEXTURE_WIDTH { + set_pixel(pixels, x, 0, color); + set_pixel(pixels, x, CARD_TEXTURE_HEIGHT - 1, color); + } + + for y in 0..CARD_TEXTURE_HEIGHT { + set_pixel(pixels, 0, y, color); + set_pixel(pixels, CARD_TEXTURE_WIDTH - 1, y, color); + } +} + +fn draw_text(pixels: &mut [u8], start_x: usize, start_y: usize, text: &str, color: [u8; 4]) { + let mut x = start_x; + for ch in text.chars() { + if let Some(bitmap) = glyph_bitmap(ch) { + draw_glyph(pixels, x, start_y, bitmap, color); + } + x += GLYPH_STRIDE; + } +} + +fn draw_glyph(pixels: &mut [u8], start_x: usize, start_y: usize, glyph: [u8; 7], color: [u8; 4]) { + for (row, pattern) in glyph.iter().enumerate() { + for col in 0..5 { + if (pattern >> (4 - col)) & 1 == 1 { + set_pixel(pixels, start_x + col, start_y + row, color); + } + } + } +} + +fn set_pixel(pixels: &mut [u8], x: usize, y: usize, color: [u8; 4]) { + if x >= CARD_TEXTURE_WIDTH || y >= CARD_TEXTURE_HEIGHT { + return; + } + let index = (y * CARD_TEXTURE_WIDTH + x) * 4; + pixels[index..index + 4].copy_from_slice(&color); +} + +fn glyph_bitmap(ch: char) -> Option<[u8; 7]> { + match ch { + '0' => Some([0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110]), + '1' => Some([0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110]), + '7' => Some([0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000]), + '8' => Some([0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110]), + '9' => Some([0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b11100]), + 'A' => Some([0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001]), + 'K' => Some([0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001]), + 'O' => Some([0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110]), + 'U' => Some([0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110]), + _ => None, + } +} + +fn rank_label(rank: Rank) -> &'static str { + match rank { + Rank::Ass => "A", + Rank::Zehn => "10", + Rank::Koenig => "K", + Rank::Ober => "O", + Rank::Unter => "U", + Rank::Neun => "9", + Rank::Acht => "8", + Rank::Sieben => "7", + } +} + +fn suit_background(suit: Suit) -> [u8; 4] { + match suit { + Suit::Eichel => [245, 235, 220, 255], + Suit::Gras => [225, 245, 225, 255], + Suit::Herz => [245, 225, 225, 255], + Suit::Schell => [245, 240, 210, 255], + } +} + +fn suit_color(suit: Suit) -> [u8; 4] { + match suit { + Suit::Eichel => [131, 100, 56, 255], + Suit::Gras => [62, 120, 54, 255], + Suit::Herz => [170, 40, 60, 255], + Suit::Schell => [204, 142, 30, 255], + } +} + +fn on_hover() -> impl Fn(On>, Query<(&mut Sprite, &mut Transform)>) { + move |ev, mut q| { + if let Ok((mut sprite, mut transform)) = q.get_mut(ev.event_target()) { + sprite.color = Color::srgb(0.6, 0.6, 0.6); + transform.scale = Vec3::splat(1.1); + } + } +} + +fn on_unhover() -> impl Fn(On>, Query<(&mut Sprite, &mut Transform)>) { + move |ev, mut q| { + if let Ok((mut sprite, mut transform)) = q.get_mut(ev.event_target()) { + sprite.color = Color::WHITE; + transform.scale = Vec3::ONE; + } + } +} + + +fn on_click(card: Card) -> impl Fn(On>, Query<(&mut Sprite, &mut Transform)>) { + move |ev, cards| { + println!("Clicked on card: {:?}", card); + } +} \ No newline at end of file diff --git a/schafkopf-logic/src/player/mod.rs b/schafkopf-logic/src/player/mod.rs new file mode 100644 index 0000000..91026c5 --- /dev/null +++ b/schafkopf-logic/src/player/mod.rs @@ -0,0 +1,175 @@ +use std::io::{self, Write}; +use crate::deck::Card; +use std::fmt; + +#[derive(Debug)] +pub enum PlayerError { + NoCards, +} + +impl fmt::Display for PlayerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PlayerError::NoCards => write!(f, "no cards available to play"), + } + } +} + +impl std::error::Error for PlayerError {} + +pub struct PlayerBase { + pub id: u32, + pub name: String, +} + +impl PlayerBase { + pub fn new(id: u32, name: impl Into) -> Self { + Self { id, name: name.into() } + } + + pub fn id(&self) -> u32 { + self.id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn set_name(&mut self, name: impl Into) { + self.name = name.into(); + } + + pub fn play_card(&mut self, hand: &mut Vec) -> Result { + hand.pop().ok_or(PlayerError::NoCards) + } +} + +pub trait PlayerBaseAccess { + fn base(&self) -> &PlayerBase; + fn base_mut(&mut self) -> &mut PlayerBase; + + fn id(&self) -> u32 { + self.base().id() + } + + fn name(&self) -> &str { + self.base().name() + } + + fn set_name(&mut self, name: impl Into) { + self.base_mut().set_name(name); + } +} + +pub trait ExternalPlayer: PlayerBaseAccess { + fn play_card(&mut self, hand: &mut Vec) -> Result { + self.base_mut().play_card(hand) + } +} + +pub trait InternalPlayer: PlayerBaseAccess { + fn play_card_from_hand(&mut self) -> Result; + fn receive_card(&mut self, card: Card); + fn set_hand(&mut self, hand: Vec); + fn hand(&self) -> &Vec; +} + +pub struct HumanPlayer { + pub base: PlayerBase, + pub hand: Vec, +} + +impl HumanPlayer { + pub fn new(id: u32, name: impl Into) -> Self { + Self { + base: PlayerBase::new(id, name), + hand: Vec::with_capacity(8), + } + } +} + +impl PlayerBaseAccess for HumanPlayer { + fn base(&self) -> &PlayerBase { + &self.base + } + fn base_mut(&mut self) -> &mut PlayerBase { + &mut self.base + } +} + +impl InternalPlayer for HumanPlayer { + fn play_card_from_hand(&mut self) -> Result { + if self.hand.is_empty() { + return Err(PlayerError::NoCards); + } + + println!("{}'s hand:", self.name()); + for (i, c) in self.hand.iter().enumerate() { + println!(" {}: {}", i, c); + } + print!("Select card index to play: "); + let _ = io::stdout().flush(); + + let mut input = String::new(); + if io::stdin().read_line(&mut input).is_ok() { + if let Ok(idx) = input.trim().parse::() { + if idx < self.hand.len() { + return Ok(self.hand.remove(idx)); + } + } + } + + // fallback: pop last + self.hand.pop().ok_or(PlayerError::NoCards) + } + + fn receive_card(&mut self, card: Card) { + self.hand.push(card); + } + + fn set_hand(&mut self, hand: Vec) { + self.hand = hand; + } + + fn hand(&self) -> &Vec { + &self.hand + } +} + +pub struct NpcPlayer { + pub base: PlayerBase, + pub hand: Vec, +} + +impl NpcPlayer { + pub fn new(id: u32, name: impl Into) -> Self { + Self { base: PlayerBase::new(id, name), hand: Vec::with_capacity(8) } + } +} + +impl PlayerBaseAccess for NpcPlayer { + fn base(&self) -> &PlayerBase { &self.base } + fn base_mut(&mut self) -> &mut PlayerBase { &mut self.base } +} + +impl InternalPlayer for NpcPlayer { + fn play_card_from_hand(&mut self) -> Result { + if self.hand.is_empty() { + Err(PlayerError::NoCards) + } else { + Ok(self.hand.remove(0)) + } + } + + fn receive_card(&mut self, card: Card) { + self.hand.push(card); + } + + fn set_hand(&mut self, hand: Vec) { + self.hand = hand; + } + + fn hand(&self) -> &Vec { + &self.hand + } +} \ No newline at end of file