diff --git a/core/examples/simulate_play.rs b/core/examples/simulate_play.rs index 0e70885a..256fbca4 100644 --- a/core/examples/simulate_play.rs +++ b/core/examples/simulate_play.rs @@ -94,6 +94,9 @@ fn main() { game_state = GameState::Draw(s.start(landlord).unwrap()); } }, + GameState::Draw(ref mut s) if s.deck_cut_player().is_some() => { + s.cut_deck(s.next_player().unwrap(), 0).unwrap(); + } GameState::Draw(ref mut s) if !s.done_drawing() => { s.draw_card(s.next_player().unwrap()).unwrap(); } diff --git a/core/src/game_state/draw_phase.rs b/core/src/game_state/draw_phase.rs index 3352e9e6..3f9acb51 100644 --- a/core/src/game_state/draw_phase.rs +++ b/core/src/game_state/draw_phase.rs @@ -24,6 +24,8 @@ pub struct DrawPhase { #[serde(default)] autobid: Option, position: usize, + #[serde(default)] + deck_cut_player: Option, kitty: Vec, #[serde(default)] revealed_cards: usize, @@ -48,12 +50,22 @@ impl DrawPhase { decks: Vec, removed_cards: Vec, ) -> Self { + let deck_cut_player = if propagated.players.is_empty() { + None + } else { + Some( + propagated.players + [(position + propagated.players.len() - 1) % propagated.players.len()] + .id, + ) + }; DrawPhase { hands: Hands::new(propagated.players.iter().map(|p| p.id)), deck, kitty, propagated, position, + deck_cut_player, num_decks, decks, game_mode, @@ -86,6 +98,10 @@ impl DrawPhase { &self.kitty } + pub fn deck_cut_player(&self) -> Option { + self.deck_cut_player + } + #[cfg(test)] pub fn deck_mut(&mut self) -> &mut Vec { &mut self.deck @@ -110,6 +126,9 @@ impl DrawPhase { } pub fn next_player(&self) -> Result { + if let Some(player) = self.deck_cut_player { + return Ok(player); + } if self.deck.is_empty() { let (first_bid, winning_bid) = Bid::first_and_winner(&self.bids, self.autobid)?; let landlord = self.propagated.landlord.unwrap_or( @@ -126,6 +145,9 @@ impl DrawPhase { } pub fn draw_card(&mut self, id: PlayerID) -> Result<(), Error> { + if self.deck_cut_player.is_some() { + bail!("the deck must be cut before cards can be drawn"); + } if id != self.propagated.players[self.position].id { bail!("not your turn!"); } @@ -138,6 +160,39 @@ impl DrawPhase { } } + pub fn cut_deck(&mut self, id: PlayerID, count: usize) -> Result<(), Error> { + if self.deck_cut_player != Some(id) { + bail!("not your turn to cut the deck"); + } + + let total_cards = self.deck.len() + self.kitty.len(); + if count > total_cards { + bail!("cut must be between 0 and {total_cards}"); + } + + // `deck.pop()` draws from the top, so the drawable portion is stored + // bottom-to-top. Reconstruct the physical top-to-bottom order including + // the kitty, rotate the requested top cards to the bottom, then split it + // back without changing the kitty size. + let num_drawable_cards = self.deck.len(); + let mut top_to_bottom = self + .deck + .iter() + .rev() + .chain(self.kitty.iter()) + .copied() + .collect::>(); + top_to_bottom.rotate_left(count); + self.deck = top_to_bottom[..num_drawable_cards] + .iter() + .rev() + .copied() + .collect(); + self.kitty = top_to_bottom[num_drawable_cards..].to_vec(); + self.deck_cut_player = None; + Ok(()) + } + pub fn reveal_card(&mut self) -> Result { if !self.deck.is_empty() { bail!("can't reveal card until deck is fully drawn") @@ -361,8 +416,41 @@ impl DrawPhase { for card in &mut self.kitty[self.revealed_cards..] { *card = Card::Unknown; } - for card in &mut self.deck { - *card = Card::Unknown; - } + self.deck.fill(Card::Unknown); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use shengji_mechanics::types::cards; + + #[test] + fn player_before_first_drawer_cuts_entire_deck() { + let mut init = InitializePhase::new(); + let p1 = init.add_player("p1".into()).unwrap().0; + init.add_player("p2".into()).unwrap(); + init.add_player("p3".into()).unwrap(); + let p4 = init.add_player("p4".into()).unwrap().0; + let mut draw = init.start(p1).unwrap(); + + draw.position = 0; + draw.deck_cut_player = Some(p4); + draw.deck = vec![cards::S_6, cards::S_5, cards::S_4]; + draw.kitty = vec![cards::S_7, cards::S_8]; + + assert_eq!(draw.next_player().unwrap(), p4); + assert!(draw.draw_card(p1).is_err()); + assert!(draw.cut_deck(p1, 2).is_err()); + assert!(draw.cut_deck(p4, 6).is_err()); + + draw.cut_deck(p4, 2).unwrap(); + assert_eq!(draw.deck, vec![cards::S_8, cards::S_7, cards::S_6]); + assert_eq!(draw.kitty, vec![cards::S_4, cards::S_5]); + assert_eq!(draw.next_player().unwrap(), p1); + + draw.draw_card(p1).unwrap(); + assert_eq!(draw.hands.get(p1).unwrap().get(&cards::S_6), Some(&1)); + assert!(draw.cut_deck(p4, 0).is_err()); } } diff --git a/core/src/game_state/exchange_phase.rs b/core/src/game_state/exchange_phase.rs index fb447636..caeb9373 100644 --- a/core/src/game_state/exchange_phase.rs +++ b/core/src/game_state/exchange_phase.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use shengji_mechanics::bidding::Bid; use shengji_mechanics::deck::Deck; use shengji_mechanics::hands::Hands; -use shengji_mechanics::types::{Card, Number, PlayerID, Rank, Trump}; +use shengji_mechanics::types::{Card, EffectiveSuit, Number, PlayerID, Rank, Trump}; use crate::message::MessageVariant; use crate::settings::{ @@ -25,6 +25,50 @@ macro_rules! bail_unwrap { }; } +const RIVER_CROSSING_CARD_COUNT: usize = 5; + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub enum RiverCrossingStage { + Deciding, + SelectingCrossingCards, + SelectingReturnCards, + Complete, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RiverCrossingPlayerState { + player_id: PlayerID, + eligible: bool, + decision: Option, + crossing_cards: Vec, + crossing_cards_submitted: bool, + received_crossing_cards: Vec, + return_cards: Vec, + return_cards_submitted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RiverCrossingState { + stage: RiverCrossingStage, + players: Vec, +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema, Default)] +pub enum KittyTheftStage { + #[default] + Disabled, + Exchanging, + Waiting, + Complete, +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema, Default)] +pub struct KittyTheftState { + stage: KittyTheftStage, + current_player: Option, + remaining_players: usize, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ExchangePhase { propagated: PropagatedState, @@ -48,6 +92,10 @@ pub struct ExchangePhase { removed_cards: Vec, #[serde(default)] decks: Vec, + #[serde(default)] + river_crossing: Option, + #[serde(default)] + kitty_theft: KittyTheftState, player_requested_reset: Option, } @@ -66,6 +114,16 @@ impl ExchangePhase { removed_cards: Vec, decks: Vec, ) -> Self { + let kitty_theft = if propagated.kitty_theft_policy == KittyTheftPolicy::AllowKittyTheft + && autobid.is_none() + { + KittyTheftState { + stage: KittyTheftStage::Exchanging, + ..KittyTheftState::default() + } + } else { + KittyTheftState::default() + }; ExchangePhase { kitty_size: kitty.len(), num_decks, @@ -80,6 +138,8 @@ impl ExchangePhase { autobid, removed_cards, decks, + river_crossing: None, + kitty_theft, finalized: false, epoch: 1, player_requested_reset: None, @@ -95,6 +155,9 @@ impl ExchangePhase { } pub fn move_card_to_kitty(&mut self, id: PlayerID, card: Card) -> Result<(), Error> { + if self.river_crossing.is_some() { + bail!("bottom-card exchange has already ended") + } if self.exchanger != id { bail!("not the exchanger") } @@ -107,6 +170,9 @@ impl ExchangePhase { } pub fn move_card_to_hand(&mut self, id: PlayerID, card: Card) -> Result<(), Error> { + if self.river_crossing.is_some() { + bail!("bottom-card exchange has already ended") + } if self.exchanger != id { bail!("not the exchanger") } @@ -134,6 +200,9 @@ impl ExchangePhase { id: PlayerID, iter: impl IntoIterator, ) -> Result<(), Error> { + if self.river_crossing.is_some() { + bail!("friend selection has already ended") + } if self.landlord != id { bail!("not the landlord") } @@ -214,6 +283,9 @@ impl ExchangePhase { } pub fn finalize(&mut self, id: PlayerID) -> Result<(), Error> { + if self.river_crossing.is_some() { + bail!("bottom-card exchange has already ended") + } if id != self.exchanger { bail!("only the exchanger can finalize their cards") } @@ -224,16 +296,73 @@ impl ExchangePhase { bail!("incorrect number of cards in the bottom") } self.finalized = true; + if self.kitty_theft.stage == KittyTheftStage::Exchanging { + self.start_kitty_theft_round()?; + } + Ok(()) + } + + fn player_after(&self, id: PlayerID) -> Result { + let position = self + .propagated + .players + .iter() + .position(|player| player.id == id) + .ok_or_else(|| anyhow!("player not found"))?; + let next = (position + 1) % self.propagated.players.len(); + Ok(self.propagated.players[next].id) + } + + fn start_kitty_theft_round(&mut self) -> Result<(), Error> { + let remaining_players = self.propagated.players.len().saturating_sub(1); + if remaining_players == 0 { + self.kitty_theft = KittyTheftState { + stage: KittyTheftStage::Complete, + ..KittyTheftState::default() + }; + } else { + self.kitty_theft = KittyTheftState { + stage: KittyTheftStage::Waiting, + current_player: Some(self.player_after(self.exchanger)?), + remaining_players, + }; + } + Ok(()) + } + + pub fn decline_kitty_theft(&mut self, id: PlayerID) -> Result<(), Error> { + if self.kitty_theft.stage != KittyTheftStage::Waiting + || self.kitty_theft.current_player != Some(id) + { + bail!("not this player's turn to decide whether to steal the bottom") + } + if self.kitty_theft.remaining_players <= 1 { + self.kitty_theft = KittyTheftState { + stage: KittyTheftStage::Complete, + ..KittyTheftState::default() + }; + } else { + self.kitty_theft.current_player = Some(self.player_after(id)?); + self.kitty_theft.remaining_players -= 1; + } Ok(()) } pub fn pick_up_cards(&mut self, id: PlayerID) -> Result<(), Error> { + if self.river_crossing.is_some() { + bail!("bottom-card exchange has already ended") + } if !self.finalized { bail!("Current exchanger is still exchanging cards!") } if self.autobid.is_some() { bail!("Bid was automatically determined; no overbidding allowed") } + if self.kitty_theft.stage != KittyTheftStage::Waiting + || self.kitty_theft.current_player != Some(id) + { + bail!("not this player's turn to steal the bottom") + } if self.bids.last().map(|b| b.epoch) != Some(self.epoch) { bail!("No bids have been made since the last player finished exchanging cards") } @@ -257,15 +386,24 @@ impl ExchangePhase { self.finalized = false; self.epoch += 1; self.exchanger = winning_bid.id; + self.kitty_theft = KittyTheftState { + stage: KittyTheftStage::Exchanging, + ..KittyTheftState::default() + }; Ok(()) } - pub fn bid(&mut self, id: PlayerID, card: Card, count: usize) -> bool { - if !self.finalized || self.autobid.is_some() { - return false; + pub fn bid(&mut self, id: PlayerID, card: Card, count: usize) -> Result { + if self.river_crossing.is_some() + || !self.finalized + || self.autobid.is_some() + || self.kitty_theft.stage != KittyTheftStage::Waiting + || self.kitty_theft.current_player != Some(id) + { + return Ok(false); } - Bid::bid( + if !Bid::bid( id, card, count, @@ -279,10 +417,17 @@ impl ExchangePhase { self.propagated.joker_bid_policy, self.num_decks, self.epoch, - ) + ) { + return Ok(false); + } + self.pick_up_cards(id)?; + Ok(true) } pub fn take_back_bid(&mut self, id: PlayerID) -> Result<(), Error> { + if self.river_crossing.is_some() { + bail!("bidding has already ended") + } if !self.finalized { bail!("Can't take back bid until exchanger is done swapping cards") } @@ -318,16 +463,29 @@ impl ExchangePhase { } pub fn next_player(&self) -> Result { - if self.propagated.kitty_theft_policy == KittyTheftPolicy::AllowKittyTheft - && self.autobid.is_none() - && !self.finalized - { - Ok(self.exchanger) - } else { - Ok(self.landlord) + if self.river_crossing.is_some() { + // River-crossing choices are simultaneous, so there is deliberately + // no public "next" player. Returning the landlord also avoids + // revealing which players are eligible or have already submitted. + return Ok(self.landlord); + } + match self.kitty_theft.stage { + KittyTheftStage::Exchanging => Ok(self.exchanger), + KittyTheftStage::Waiting => self + .kitty_theft + .current_player + .ok_or_else(|| anyhow!("missing current player for kitty-theft decision")), + KittyTheftStage::Disabled | KittyTheftStage::Complete => Ok(self.landlord), } } + fn kitty_theft_complete(&self) -> bool { + matches!( + self.kitty_theft.stage, + KittyTheftStage::Disabled | KittyTheftStage::Complete + ) + } + pub fn advance(&self, id: PlayerID) -> Result { if id != self.landlord { bail!("only the leader can advance the game") @@ -345,11 +503,15 @@ impl ExchangePhase { } } - if self.propagated.kitty_theft_policy == KittyTheftPolicy::AllowKittyTheft - && self.autobid.is_none() - && !self.finalized - { - bail!("must give other players a chance to over-bid and swap cards") + if !self.kitty_theft_complete() { + bail!("must finish sequential kitty-theft decisions first") + } + + if self.river_crossing_is_applicable() { + match self.river_crossing.as_ref().map(|r| r.stage) { + Some(RiverCrossingStage::Complete) => (), + _ => bail!("must complete five-trump river crossing first"), + } } let landlord_position = bail_unwrap!(self @@ -386,9 +548,328 @@ impl ExchangePhase { landlords_team, self.removed_cards.clone(), self.decks.clone(), + self.river_crossing_initiators(), ) } + fn river_crossing_initiators(&self) -> Vec { + self.river_crossing + .as_ref() + .filter(|r| r.stage != RiverCrossingStage::Deciding) + .map(|r| { + r.players + .iter() + .filter(|p| p.decision == Some(true)) + .map(|p| p.player_id) + .collect() + }) + .unwrap_or_default() + } + + fn river_crossing_is_applicable(&self) -> bool { + self.propagated.five_trump_river_crossing_enabled + && self.propagated.players.len() == 4 + && matches!(self.game_mode, GameMode::Tractor) + && matches!(self.trump, Trump::Standard { .. }) + } + + fn partner_of(&self, player_id: PlayerID) -> Result { + let position = self + .propagated + .players + .iter() + .position(|p| p.id == player_id) + .ok_or_else(|| anyhow!("player not found"))?; + if self.propagated.players.len() != 4 { + bail!("five-trump river crossing requires exactly four players") + } + Ok(self.propagated.players[(position + 2) % 4].id) + } + + fn partner_decision(&self, player_id: PlayerID) -> Option { + let partner = self.partner_of(player_id).ok()?; + self.river_crossing + .as_ref()? + .players + .iter() + .find(|p| p.player_id == partner)? + .decision + } + + fn trump_count(&self, player_id: PlayerID) -> Result { + Ok(self + .hands + .get(player_id)? + .iter() + .filter(|(card, _)| self.trump.effective_suit(**card) == EffectiveSuit::Trump) + .map(|(_, count)| *count) + .sum()) + } + + pub fn start_river_crossing(&mut self, id: PlayerID) -> Result { + if id != self.landlord { + bail!("only the leader can advance the game") + } + if !self.river_crossing_is_applicable() { + return Ok(false); + } + if self.river_crossing.is_some() { + bail!("five-trump river crossing has already started") + } + + // Reuse all pre-play checks, except the crossing-completion check. + if self.kitty.len() != self.kitty_size { + bail!("incorrect number of cards in the bottom") + } + if !self.kitty_theft_complete() { + bail!("must finish sequential kitty-theft decisions first") + } + + let mut players = Vec::with_capacity(4); + for player in &self.propagated.players { + let eligible = self.trump_count(player.id)? <= RIVER_CROSSING_CARD_COUNT; + players.push(RiverCrossingPlayerState { + player_id: player.id, + eligible, + decision: if eligible { None } else { Some(false) }, + crossing_cards: vec![], + crossing_cards_submitted: false, + received_crossing_cards: vec![], + return_cards: vec![], + return_cards_submitted: false, + }); + } + let stage = if players.iter().any(|p| p.eligible) { + RiverCrossingStage::Deciding + } else { + RiverCrossingStage::Complete + }; + self.river_crossing = Some(RiverCrossingState { stage, players }); + Ok(true) + } + + pub fn decide_river_crossing( + &mut self, + id: PlayerID, + cross: bool, + ) -> Result, Error> { + let crossing = self + .river_crossing + .as_mut() + .ok_or_else(|| anyhow!("five-trump river crossing has not started"))?; + if crossing.stage != RiverCrossingStage::Deciding { + bail!("river-crossing decisions are already complete") + } + let player = crossing + .players + .iter_mut() + .find(|p| p.player_id == id) + .ok_or_else(|| anyhow!("player not found"))?; + if !player.eligible { + bail!("player has more than five trump cards") + } + if player.decision.is_some() { + bail!("player has already decided") + } + player.decision = Some(cross); + + if crossing.players.iter().all(|p| p.decision.is_some()) { + crossing.stage = if crossing.players.iter().any(|p| p.decision == Some(true)) { + RiverCrossingStage::SelectingCrossingCards + } else { + RiverCrossingStage::Complete + }; + } + // Announce only after all simultaneous decisions are locked in. The + // last responder may have declined, so name each initiator explicitly. + Ok(self + .river_crossing_initiators() + .into_iter() + .map(|player| MessageVariant::RiverCrossingInitiated { player }) + .collect()) + } + + fn validate_crossing_cards(&self, id: PlayerID, cards: &[Card]) -> Result<(), Error> { + if cards.len() != RIVER_CROSSING_CARD_COUNT { + bail!("must select exactly five cards") + } + self.hands.contains(id, cards.iter().copied())?; + let selected_trumps = cards + .iter() + .filter(|card| self.trump.effective_suit(**card) == EffectiveSuit::Trump) + .count(); + if selected_trumps != self.trump_count(id)? { + bail!("the five crossing cards must include every trump card in the hand") + } + Ok(()) + } + + pub fn submit_river_crossing_cards( + &mut self, + id: PlayerID, + cards: Vec, + ) -> Result<(), Error> { + let stage = self + .river_crossing + .as_ref() + .map(|r| r.stage) + .ok_or_else(|| anyhow!("five-trump river crossing has not started"))?; + if stage != RiverCrossingStage::SelectingCrossingCards { + bail!("not selecting river-crossing cards") + } + self.validate_crossing_cards(id, &cards)?; + { + let player = self + .river_crossing + .as_mut() + .unwrap() + .players + .iter_mut() + .find(|p| p.player_id == id) + .ok_or_else(|| anyhow!("player not found"))?; + if player.decision != Some(true) { + bail!("player did not choose to cross the river") + } + if player.crossing_cards_submitted { + bail!("crossing cards already submitted") + } + player.crossing_cards = cards; + player.crossing_cards_submitted = true; + } + + let all_submitted = self + .river_crossing + .as_ref() + .unwrap() + .players + .iter() + .all(|p| p.decision != Some(true) || p.crossing_cards_submitted); + if all_submitted { + self.apply_crossing_cards()?; + } + Ok(()) + } + + fn apply_crossing_cards(&mut self) -> Result<(), Error> { + let transfers = self + .river_crossing + .as_ref() + .unwrap() + .players + .iter() + .filter(|p| p.decision == Some(true)) + .map(|p| { + Ok(( + p.player_id, + self.partner_of(p.player_id)?, + p.crossing_cards.clone(), + )) + }) + .collect::, Error>>()?; + + for (from, _, cards) in &transfers { + self.hands.remove(*from, cards.iter().copied())?; + } + for (_, to, cards) in &transfers { + self.hands.add(*to, cards.iter().copied())?; + let recipient = self + .river_crossing + .as_mut() + .unwrap() + .players + .iter_mut() + .find(|p| p.player_id == *to) + .unwrap(); + recipient.received_crossing_cards = cards.clone(); + } + self.river_crossing.as_mut().unwrap().stage = RiverCrossingStage::SelectingReturnCards; + Ok(()) + } + + pub fn submit_river_return_cards( + &mut self, + id: PlayerID, + cards: Vec, + ) -> Result<(), Error> { + let crossing = self + .river_crossing + .as_ref() + .ok_or_else(|| anyhow!("five-trump river crossing has not started"))?; + if crossing.stage != RiverCrossingStage::SelectingReturnCards { + bail!("not selecting return cards") + } + if self.partner_decision(id) != Some(true) { + bail!("this player has no cards to return") + } + if cards.len() != RIVER_CROSSING_CARD_COUNT { + bail!("must return exactly five cards") + } + self.hands.contains(id, cards.iter().copied())?; + { + let player = self + .river_crossing + .as_mut() + .unwrap() + .players + .iter_mut() + .find(|p| p.player_id == id) + .ok_or_else(|| anyhow!("player not found"))?; + if player.return_cards_submitted { + bail!("return cards already submitted") + } + player.return_cards = cards; + player.return_cards_submitted = true; + } + + let all_submitted = self + .river_crossing + .as_ref() + .unwrap() + .players + .iter() + .all(|p| self.partner_decision(p.player_id) != Some(true) || p.return_cards_submitted); + if all_submitted { + self.apply_return_cards()?; + } + Ok(()) + } + + fn apply_return_cards(&mut self) -> Result<(), Error> { + let transfers = self + .river_crossing + .as_ref() + .unwrap() + .players + .iter() + .filter(|p| p.return_cards_submitted) + .map(|p| { + Ok(( + p.player_id, + self.partner_of(p.player_id)?, + p.return_cards.clone(), + )) + }) + .collect::, Error>>()?; + + // Remove every selection before adding any returned cards. This makes a + // two-way return genuinely simultaneous even when identical cards occur. + for (from, _, cards) in &transfers { + self.hands.remove(*from, cards.iter().copied())?; + } + for (_, to, cards) in &transfers { + self.hands.add(*to, cards.iter().copied())?; + } + self.river_crossing.as_mut().unwrap().stage = RiverCrossingStage::Complete; + Ok(()) + } + + pub fn river_crossing_complete(&self) -> bool { + self.river_crossing + .as_ref() + .map(|r| r.stage == RiverCrossingStage::Complete) + .unwrap_or(false) + } + pub fn request_reset( &mut self, player: PlayerID, @@ -428,11 +909,10 @@ impl ExchangePhase { } pub fn destructively_redact_for_player(&mut self, player: PlayerID) { + let players_partner = self.partner_of(player).ok(); self.hands.destructively_redact_except_for_player(player); if player != self.exchanger || self.finalized { - for card in &mut self.kitty { - *card = Card::Unknown; - } + self.kitty.fill(Card::Unknown); } if player != self.landlord { if let GameMode::FindingFriends { @@ -442,5 +922,448 @@ impl ExchangePhase { friends.clear(); } } + if let Some(ref mut crossing) = self.river_crossing { + let stage = crossing.stage; + for state in &mut crossing.players { + if state.player_id != player { + if stage == RiverCrossingStage::Deciding { + state.eligible = false; + state.decision = None; + } + state.crossing_cards.clear(); + state.return_cards.clear(); + if players_partner != Some(state.player_id) { + state.received_crossing_cards.clear(); + } + } + } + } + } +} + +#[cfg(test)] +mod river_crossing_tests { + use super::*; + use shengji_mechanics::player::Player; + use shengji_mechanics::types::{ + cards::{C_4, C_8, D_2, D_5, D_9, H_3, H_4, H_5, S_10, S_2, S_3, S_6, S_7}, + Rank, Suit, + }; + + const P1: PlayerID = PlayerID(1); + const P2: PlayerID = PlayerID(2); + const P3: PlayerID = PlayerID(3); + const P4: PlayerID = PlayerID(4); + + fn player(id: PlayerID, name: &str) -> Player { + Player { + id, + name: name.to_string(), + level: Rank::Number(Number::Two), + metalevel: 0, + } + } + + fn phase(p1_cards: Vec, p3_cards: Vec) -> ExchangePhase { + let mut propagated = PropagatedState::default(); + propagated.players = vec![ + player(P1, "p1"), + player(P2, "p2"), + player(P3, "p3"), + player(P4, "p4"), + ]; + propagated.five_trump_river_crossing_enabled = true; + propagated.game_mode = crate::settings::GameModeSettings::Tractor; + + let trump = Trump::Standard { + suit: Suit::Hearts, + number: Number::Two, + }; + let mut hands = Hands::new([P1, P2, P3, P4]); + hands.set_trump(trump); + hands.add(P1, p1_cards).unwrap(); + hands.add(P2, vec![H_5; 6]).unwrap(); + hands.add(P3, p3_cards).unwrap(); + hands.add(P4, vec![H_5; 6]).unwrap(); + + ExchangePhase::new( + propagated, + 2, + GameMode::Tractor, + vec![], + P1, + hands, + trump, + vec![], + None, + vec![], + vec![], + ) + } + + fn enable_sequential_kitty_theft(phase: &mut ExchangePhase) { + phase.propagated.kitty_theft_policy = KittyTheftPolicy::AllowKittyTheft; + phase.kitty_theft = KittyTheftState { + stage: KittyTheftStage::Exchanging, + ..KittyTheftState::default() + }; + } + + #[test] + fn sequential_kitty_theft_must_complete_before_river_crossing() { + let mut phase = phase( + vec![S_3, C_4, D_5, S_6, S_7], + vec![H_4, S_7, C_8, D_9, S_10], + ); + enable_sequential_kitty_theft(&mut phase); + + phase.finalize(P1).unwrap(); + assert_eq!(phase.next_player().unwrap(), P2); + assert!(phase.start_river_crossing(P1).is_err()); + assert!(phase.advance(P1).is_err()); + + phase.decline_kitty_theft(P2).unwrap(); + assert_eq!(phase.next_player().unwrap(), P3); + phase.decline_kitty_theft(P3).unwrap(); + assert_eq!(phase.next_player().unwrap(), P4); + phase.decline_kitty_theft(P4).unwrap(); + + assert_eq!(phase.next_player().unwrap(), P1); + assert!(phase.start_river_crossing(P1).unwrap()); + } + + #[test] + fn sequential_kitty_theft_turn_is_public_and_survives_serialization() { + let mut phase = phase( + vec![S_3, C_4, D_5, S_6, S_7], + vec![H_4, S_7, C_8, D_9, S_10], + ); + enable_sequential_kitty_theft(&mut phase); + phase.finalize(P1).unwrap(); + + let serialized = serde_json::to_value(&phase).unwrap(); + assert_eq!(serialized["kitty_theft"]["stage"], "Waiting"); + assert_eq!(serialized["kitty_theft"]["current_player"], 2); + assert_eq!(serialized["kitty_theft"]["remaining_players"], 3); + + let restored: ExchangePhase = serde_json::from_value(serialized).unwrap(); + assert_eq!(restored.next_player().unwrap(), P2); + for viewer in [P1, P2, P3, P4, PlayerID(99)] { + let mut visible = restored.clone(); + visible.destructively_redact_for_player(viewer); + let visible = serde_json::to_value(visible).unwrap(); + assert_eq!(visible["kitty_theft"]["current_player"], 2); + assert_eq!(visible["kitty_theft"]["remaining_players"], 3); + } + } + + #[test] + fn announcements_wait_for_all_decisions_and_name_the_initiator() { + use crate::game_state::GameState; + use crate::interactive::{Action, InteractiveGame}; + use slog::{o, Logger}; + + let phase = phase( + vec![S_3, C_4, D_5, S_6, S_7], + vec![H_4, S_7, C_8, D_9, S_10], + ); + let mut game = InteractiveGame::new_from_state(GameState::Exchange(phase)); + let logger = Logger::root(slog::Discard, o!()); + game.interact(Action::BeginPlay, P1, &logger).unwrap(); + assert!(game + .interact(Action::DecideRiverCrossing(true), P1, &logger) + .unwrap() + .is_empty()); + let msgs = game + .interact(Action::DecideRiverCrossing(false), P3, &logger) + .unwrap(); + assert_eq!(msgs.len(), 1); + let broadcast = serde_json::to_value(&msgs[0].0).unwrap(); + assert_eq!( + broadcast["variant"], + serde_json::json!({ + "type": "RiverCrossingInitiated", "player": 1 + }) + ); + assert!(msgs[0].1.starts_with("p1 发起了五主过河")); + // The final responder is p3; the public message must still name p1. + assert_eq!(broadcast["actor"], serde_json::json!(3)); + assert!(game + .interact(Action::DecideRiverCrossing(true), P1, &logger) + .is_err()); + } + + #[test] + fn multiple_initiators_are_announced_once_each() { + let mut phase = phase( + vec![S_3, C_4, D_5, S_6, S_7], + vec![H_4, S_7, C_8, D_9, S_10], + ); + phase.start_river_crossing(P1).unwrap(); + assert!(phase.decide_river_crossing(P1, true).unwrap().is_empty()); + let msgs = phase.decide_river_crossing(P3, true).unwrap(); + assert_eq!(msgs.len(), 2); + assert!(matches!( + msgs[0], + MessageVariant::RiverCrossingInitiated { player: P1 } + )); + assert!(matches!( + msgs[1], + MessageVariant::RiverCrossingInitiated { player: P3 } + )); + assert!(phase.decide_river_crossing(P3, true).is_err()); + } + + #[test] + fn declined_and_disabled_crossings_have_no_markers() { + let mut phase = phase( + vec![S_3, C_4, D_5, S_6, S_7], + vec![H_4, S_7, C_8, D_9, S_10], + ); + let mut disabled = phase.clone(); + disabled.propagated.five_trump_river_crossing_enabled = false; + assert!(!disabled.start_river_crossing(P1).unwrap()); + let play = serde_json::to_value(disabled.advance(P1).unwrap()).unwrap(); + assert_eq!(play["river_crossing_initiators"], serde_json::json!([])); + + phase.start_river_crossing(P1).unwrap(); + assert!(phase.decide_river_crossing(P1, false).unwrap().is_empty()); + assert!(phase.decide_river_crossing(P3, false).unwrap().is_empty()); + let play = serde_json::to_value(phase.advance(P1).unwrap()).unwrap(); + assert_eq!(play["river_crossing_initiators"], serde_json::json!([])); + } + + #[test] + fn initiators_survive_play_serialization_and_clear_on_reset() { + let cards = vec![S_3, C_4, D_5, S_6, S_7]; + let mut phase = phase(cards.clone(), vec![H_4, S_7, C_8, D_9, S_10]); + phase.start_river_crossing(P1).unwrap(); + phase.decide_river_crossing(P1, true).unwrap(); + phase.decide_river_crossing(P3, false).unwrap(); + phase + .submit_river_crossing_cards(P1, cards.clone()) + .unwrap(); + phase.submit_river_return_cards(P3, cards).unwrap(); + let play = phase.advance(P1).unwrap(); + let serialized = serde_json::to_value(&play).unwrap(); + assert_eq!( + serialized["river_crossing_initiators"], + serde_json::json!([1]) + ); + let mut restored: PlayPhase = serde_json::from_value(serialized.clone()).unwrap(); + // Finishing the deal also drops its marker instead of propagating it. + let mut finished = serialized.clone(); + finished["game_ended_early"] = serde_json::json!(true); + finished["decks"] = serde_json::json!(vec![Deck::default(); 2]); + let finished: PlayPhase = serde_json::from_value(finished).unwrap(); + let (next, _, _) = finished.finish_game().unwrap(); + assert!(!serde_json::to_string(&next) + .unwrap() + .contains("river_crossing_initiators")); + // Every player and a late spectator receive the same public marker. + for viewer in [P1, P2, P3, P4, PlayerID(99)] { + let mut visible = restored.clone(); + visible.destructively_redact_for_player(viewer); + assert_eq!( + serde_json::to_value(visible).unwrap()["river_crossing_initiators"], + serde_json::json!([1]) + ); + } + // A state saved before this feature remains readable. + let mut legacy = serialized; + legacy + .as_object_mut() + .unwrap() + .remove("river_crossing_initiators"); + let legacy: PlayPhase = serde_json::from_value(legacy).unwrap(); + assert_eq!( + serde_json::to_value(legacy).unwrap()["river_crossing_initiators"], + serde_json::json!([]) + ); + + assert!(restored.request_reset(P1).unwrap().0.is_none()); + let init = restored.request_reset(P2).unwrap().0.unwrap(); + assert!(!serde_json::to_string(&init) + .unwrap() + .contains("river_crossing_initiators")); + let draw = init.start(P1).unwrap(); + assert!(!serde_json::to_string(&draw) + .unwrap() + .contains("river_crossing_initiators")); + } + + #[test] + fn zero_trumps_is_eligible() { + let p1_cards = vec![S_3, C_4, D_5, S_6, S_7]; + let mut phase = phase(p1_cards, vec![H_4, S_7, C_8, D_9, S_10]); + assert!(phase.start_river_crossing(P1).unwrap()); + let p1 = phase + .river_crossing + .as_ref() + .unwrap() + .players + .iter() + .find(|p| p.player_id == P1) + .unwrap(); + assert!(p1.eligible); + } + + #[test] + fn jokers_and_off_suit_level_cards_count_as_trumps() { + let mut phase = phase( + vec![Card::SmallJoker, Card::BigJoker, S_2, D_2, H_3, H_4, C_4], + vec![H_4, S_7, C_8, D_9, S_10], + ); + phase.start_river_crossing(P1).unwrap(); + let p1 = phase + .river_crossing + .as_ref() + .unwrap() + .players + .iter() + .find(|p| p.player_id == P1) + .unwrap(); + assert!(!p1.eligible); + assert_eq!(p1.decision, Some(false)); + } + + #[test] + fn crossing_cards_must_include_every_trump() { + let mut phase = phase( + vec![H_3, S_3, C_4, D_5, S_6, S_7], + vec![H_4, S_7, C_8, D_9, S_10], + ); + phase.start_river_crossing(P1).unwrap(); + phase.decide_river_crossing(P1, true).unwrap(); + phase.decide_river_crossing(P3, false).unwrap(); + assert!(phase + .submit_river_crossing_cards(P1, vec![S_3, C_4, D_5, S_6, S_7]) + .is_err()); + } + + #[test] + fn two_way_crossing_has_a_simultaneous_return_round() { + let p1_cards = vec![H_3, S_3, C_4, D_5, S_6]; + let p3_cards = vec![H_4, S_7, C_8, D_9, S_10]; + let mut phase = phase(p1_cards.clone(), p3_cards.clone()); + phase.start_river_crossing(P1).unwrap(); + phase.decide_river_crossing(P1, true).unwrap(); + phase.decide_river_crossing(P3, true).unwrap(); + + phase + .submit_river_crossing_cards(P1, p1_cards.clone()) + .unwrap(); + assert_eq!( + phase.river_crossing.as_ref().unwrap().stage, + RiverCrossingStage::SelectingCrossingCards + ); + phase + .submit_river_crossing_cards(P3, p3_cards.clone()) + .unwrap(); + assert_eq!( + phase.river_crossing.as_ref().unwrap().stage, + RiverCrossingStage::SelectingReturnCards + ); + assert!(phase.hands.contains(P1, p3_cards.clone()).is_ok()); + assert!(phase.hands.contains(P3, p1_cards.clone()).is_ok()); + + phase + .submit_river_return_cards(P1, p3_cards.clone()) + .unwrap(); + assert!(!phase.river_crossing_complete()); + // P3's selection remains secret and neither return is applied early. + assert!(phase.hands.contains(P1, p3_cards.clone()).is_ok()); + phase + .submit_river_return_cards(P3, p1_cards.clone()) + .unwrap(); + assert!(phase.river_crossing_complete()); + assert!(phase.hands.contains(P1, p1_cards).is_ok()); + assert!(phase.hands.contains(P3, p3_cards).is_ok()); + } + + #[test] + fn one_way_recipient_can_return_received_cards() { + let p1_cards = vec![H_3, S_3, C_4, D_5, S_6]; + let p3_cards = vec![H_4, S_7, C_8, D_9, S_10]; + let mut phase = phase(p1_cards.clone(), p3_cards); + phase.start_river_crossing(P1).unwrap(); + phase.decide_river_crossing(P1, true).unwrap(); + phase.decide_river_crossing(P3, false).unwrap(); + phase + .submit_river_crossing_cards(P1, p1_cards.clone()) + .unwrap(); + phase + .submit_river_return_cards(P3, p1_cards.clone()) + .unwrap(); + assert!(phase.river_crossing_complete()); + assert!(phase.hands.contains(P1, p1_cards).is_ok()); + } + + #[test] + fn private_decisions_and_card_selections_are_redacted() { + let p1_cards = vec![H_3, S_3, C_4, D_5, S_6]; + let p3_cards = vec![H_4, S_7, C_8, D_9, S_10]; + let mut phase = phase(p1_cards.clone(), p3_cards.clone()); + phase.start_river_crossing(P1).unwrap(); + phase.decide_river_crossing(P1, true).unwrap(); + + let mut p3_view = phase.clone(); + p3_view.destructively_redact_for_player(P3); + let hidden_p1 = p3_view + .river_crossing + .as_ref() + .unwrap() + .players + .iter() + .find(|p| p.player_id == P1) + .unwrap(); + assert_eq!(hidden_p1.decision, None); + + phase.decide_river_crossing(P3, true).unwrap(); + phase + .submit_river_crossing_cards(P1, p1_cards.clone()) + .unwrap(); + let mut p3_view = phase.clone(); + p3_view.destructively_redact_for_player(P3); + let hidden_p1 = p3_view + .river_crossing + .as_ref() + .unwrap() + .players + .iter() + .find(|p| p.player_id == P1) + .unwrap(); + assert!(hidden_p1.crossing_cards.is_empty()); + + phase + .submit_river_crossing_cards(P3, p3_cards.clone()) + .unwrap(); + phase + .submit_river_return_cards(P1, p3_cards.clone()) + .unwrap(); + let mut p3_view = phase.clone(); + p3_view.destructively_redact_for_player(P3); + let hidden_p1 = p3_view + .river_crossing + .as_ref() + .unwrap() + .players + .iter() + .find(|p| p.player_id == P1) + .unwrap(); + assert!(hidden_p1.return_cards.is_empty()); + } + + #[test] + fn no_trump_skips_river_crossing() { + let mut phase = phase( + vec![H_3, S_3, C_4, D_5, S_6], + vec![H_4, S_7, C_8, D_9, S_10], + ); + phase.trump = Trump::NoTrump { + number: Some(Number::Two), + }; + assert!(!phase.start_river_crossing(P1).unwrap()); + assert!(phase.river_crossing.is_none()); } } diff --git a/core/src/game_state/mod.rs b/core/src/game_state/mod.rs index 9989fba9..188cea76 100644 --- a/core/src/game_state/mod.rs +++ b/core/src/game_state/mod.rs @@ -808,6 +808,7 @@ mod tests { let p3 = init.add_player("p3".into()).unwrap().0; let p4 = init.add_player("p4".into()).unwrap().0; let mut draw = init.start(PlayerID(0)).unwrap(); + draw.cut_deck(draw.next_player().unwrap(), 0).unwrap(); // Hackily ensure that everyone can bid. *draw.deck_mut() = vec![ cards::S_2, @@ -847,6 +848,7 @@ mod tests { init.set_kitty_theft_policy(KittyTheftPolicy::AllowKittyTheft) .unwrap(); let mut draw = init.start(PlayerID(0)).unwrap(); + draw.cut_deck(draw.next_player().unwrap(), 0).unwrap(); // Hackily ensure that everyone can bid. *draw.deck_mut() = vec![ cards::S_2, @@ -871,16 +873,41 @@ mod tests { assert!(draw.bid(p1, cards::H_2, 1)); let mut exchange = draw.advance(p1).unwrap(); + // The original landlord must bury first. The first decision then goes + // to the landlord's next seated player, not the fastest bidder. + assert_eq!(exchange.next_player().unwrap(), p1); exchange.finalize(p1).unwrap(); - assert!(exchange.bid(p1, cards::H_2, 2)); - assert!(exchange.bid(p3, Card::SmallJoker, 2)); - exchange.pick_up_cards(p3).unwrap(); + assert_eq!(exchange.next_player().unwrap(), p2); + assert!(!exchange.bid(p3, Card::SmallJoker, 2).unwrap()); + assert!(exchange.decline_kitty_theft(p3).is_err()); exchange.advance(p1).unwrap_err(); + + // Passing proceeds in seat order. A successful bid immediately picks + // up the bottom and makes the bidder the new exchanger. + exchange.decline_kitty_theft(p2).unwrap(); + assert_eq!(exchange.next_player().unwrap(), p3); + assert!(exchange.bid(p3, Card::SmallJoker, 2).unwrap()); + assert_eq!(exchange.next_player().unwrap(), p3); exchange.finalize(p3).unwrap(); - assert!(exchange.bid(p2, Card::BigJoker, 2)); - exchange.pick_up_cards(p2).unwrap(); + assert_eq!(exchange.next_player().unwrap(), p4); + + // Every new burial starts a fresh circuit, including players who + // passed in the previous circuit and excluding the current exchanger. + exchange.decline_kitty_theft(p4).unwrap(); + assert_eq!(exchange.next_player().unwrap(), p1); + exchange.decline_kitty_theft(p1).unwrap(); + assert_eq!(exchange.next_player().unwrap(), p2); + assert!(exchange.bid(p2, Card::BigJoker, 2).unwrap()); + assert_eq!(exchange.next_player().unwrap(), p2); exchange.finalize(p2).unwrap(); - assert!(!exchange.bid(p1, cards::H_2, 2)); + assert_eq!(exchange.next_player().unwrap(), p3); + assert!(!exchange.bid(p1, cards::H_2, 2).unwrap()); + + // Once every other player passes, the landlord may begin play. + exchange.decline_kitty_theft(p3).unwrap(); + exchange.decline_kitty_theft(p4).unwrap(); + exchange.decline_kitty_theft(p1).unwrap(); + assert_eq!(exchange.next_player().unwrap(), p1); exchange.advance(p1).unwrap(); } @@ -898,6 +925,7 @@ mod tests { let p3 = init.add_player("p3".into()).unwrap().0; let p4 = init.add_player("p4".into()).unwrap().0; let mut draw = init.start(PlayerID(0)).unwrap(); + draw.cut_deck(draw.next_player().unwrap(), 0).unwrap(); let p1_hand = [S_9, S_9, S_10, S_10, S_K, S_3, S_4, S_5, S_7, S_7, H_2]; let p2_hand = [S_3, S_3, S_5, S_5, S_7, S_8, S_J, S_Q, C_3, C_4, C_5]; @@ -951,6 +979,7 @@ mod tests { .unwrap(); let mut draw = init.start(PlayerID(1)).unwrap(); + draw.cut_deck(draw.next_player().unwrap(), 0).unwrap(); *draw.deck_mut() = vec![bid, bid, bid, bid]; draw.draw_card(p2).unwrap(); draw.draw_card(p3).unwrap(); @@ -1033,6 +1062,7 @@ mod tests { init.set_rank(p2, Rank::Number(Number::Seven)).unwrap(); let mut draw = init.start(PlayerID(1)).unwrap(); + draw.cut_deck(draw.next_player().unwrap(), 0).unwrap(); let p1_hand = [ Card::SmallJoker, @@ -1404,6 +1434,7 @@ mod tests { init.set_rank(p1, Rank::Number(Number::Seven)).unwrap(); let mut draw = init.start(PlayerID(0)).unwrap(); + draw.cut_deck(draw.next_player().unwrap(), 0).unwrap(); let mut deck = vec![]; // We need at least two cards per person, since the landlord needs to diff --git a/core/src/game_state/play_phase.rs b/core/src/game_state/play_phase.rs index 567ac41d..9cb68e6a 100644 --- a/core/src/game_state/play_phase.rs +++ b/core/src/game_state/play_phase.rs @@ -55,11 +55,18 @@ pub struct PlayPhase { trump: Trump, trick: Trick, last_trick: Option, + /// Completed plays for each player in this deal. This is redacted down to + /// the requesting player's own history before game state is sent. + #[serde(default)] + played_card_history: HashMap>>, game_ended_early: bool, #[serde(default)] removed_cards: Vec, #[serde(default)] decks: Vec, + /// Public initiator IDs for this deal only; never propagated to the next deal. + #[serde(default)] + river_crossing_initiators: Vec, player_requested_reset: Option, } @@ -77,8 +84,14 @@ impl PlayPhase { landlords_team: Vec, removed_cards: Vec, decks: Vec, + river_crossing_initiators: Vec, ) -> Result { let landlord_idx = bail_unwrap!(propagated.players.iter().position(|p| p.id == landlord)); + let played_card_history = propagated + .players + .iter() + .map(|player| (player.id, Vec::new())) + .collect(); Ok(PlayPhase { trick: Trick::new( trump, @@ -105,8 +118,10 @@ impl PlayPhase { propagated, removed_cards, decks, + river_crossing_initiators, game_ended_early: false, last_trick: None, + played_card_history, player_requested_reset: None, }) } @@ -194,17 +209,13 @@ impl PlayPhase { for msg in &mut msgs { match msg { PlayCardsMessage::PlayedCards { ref mut cards, .. } => { - for card in cards { - *card = Card::Unknown; - } + cards.fill(Card::Unknown); } PlayCardsMessage::ThrowFailed { ref mut original_cards, .. } => { - for card in original_cards { - *card = Card::Unknown; - } + original_cards.fill(Card::Unknown); } } } @@ -355,6 +366,12 @@ impl PlayPhase { }), self.propagated.bomb_policy, ); + for played in self.trick.played_cards() { + self.played_card_history + .entry(played.id) + .or_default() + .push(played.cards.clone()); + } self.last_trick = Some(std::mem::replace(&mut self.trick, new_trick)); Ok(msgs) @@ -673,6 +690,8 @@ impl PlayPhase { } pub fn destructively_redact_for_player(&mut self, player: PlayerID) { + self.played_card_history + .retain(|history_player, _| *history_player == player); if self.propagated.hide_landlord_points { for (k, v) in self.points.iter_mut() { if self.landlords_team.contains(k) { @@ -687,9 +706,84 @@ impl PlayPhase { self.hands.destructively_redact_except_for_player(player); } if game_ongoing && player != self.exchanger { - for card in &mut self.kitty { - *card = Card::Unknown; - } + self.kitty.fill(Card::Unknown); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game_state::initialize_phase::InitializePhase; + use shengji_mechanics::types::cards::{H_2, S_2}; + + #[test] + fn completed_play_history_is_private_and_survives_serialization() { + let mut init = InitializePhase::new(); + let p1 = init.add_player("p1".into()).unwrap().0; + let p2 = init.add_player("p2".into()).unwrap().0; + let p3 = init.add_player("p3".into()).unwrap().0; + let p4 = init.add_player("p4".into()).unwrap().0; + let mut draw = init.start(p1).unwrap(); + draw.cut_deck(draw.next_player().unwrap(), 0).unwrap(); + + *draw.deck_mut() = vec![ + S_2, + Card::SmallJoker, + Card::BigJoker, + H_2, + S_2, + Card::SmallJoker, + Card::BigJoker, + H_2, + ]; + *draw.position_mut() = 0; + for _ in 0..2 { + draw.draw_card(p1).unwrap(); + draw.draw_card(p2).unwrap(); + draw.draw_card(p3).unwrap(); + draw.draw_card(p4).unwrap(); } + assert!(draw.bid(p1, H_2, 1)); + + let exchange = draw.advance(p1).unwrap(); + let mut play = exchange.advance(p1).unwrap(); + play.play_cards(p1, &[H_2]).unwrap(); + play.play_cards(p2, &[Card::BigJoker]).unwrap(); + play.play_cards(p3, &[Card::SmallJoker]).unwrap(); + play.play_cards(p4, &[S_2]).unwrap(); + play.finish_trick().unwrap(); + + assert_eq!(play.played_card_history[&p1], vec![vec![H_2]]); + assert_eq!(play.played_card_history[&p2], vec![vec![Card::BigJoker]]); + assert_eq!(play.last_trick.as_ref().unwrap().played_cards().len(), 4); + + // An uncompleted or withdrawn play must not become history. + play.play_cards(p2, &[Card::BigJoker]).unwrap(); + play.take_back_cards(p2).unwrap(); + assert_eq!(play.played_card_history[&p2].len(), 1); + + let serialized = serde_json::to_value(&play).unwrap(); + let restored: PlayPhase = serde_json::from_value(serialized.clone()).unwrap(); + assert_eq!(restored.played_card_history, play.played_card_history); + + let mut p2_view = play.clone(); + p2_view.destructively_redact_for_player(p2); + assert_eq!(p2_view.played_card_history.len(), 1); + assert_eq!(p2_view.played_card_history[&p2], vec![vec![Card::BigJoker]]); + assert_eq!(p2_view.last_trick.as_ref().unwrap().played_cards().len(), 4); + + let mut observer_view = play.clone(); + observer_view.destructively_redact_for_player(PlayerID(99)); + assert!(observer_view.played_card_history.is_empty()); + + // State created before this field existed remains loadable. + let mut legacy = serialized; + legacy + .as_object_mut() + .unwrap() + .remove("played_card_history"); + let legacy: PlayPhase = serde_json::from_value(legacy).unwrap(); + assert!(legacy.played_card_history.is_empty()); } } diff --git a/core/src/interactive.rs b/core/src/interactive.rs index 246b7407..43074bf9 100644 --- a/core/src/interactive.rs +++ b/core/src/interactive.rs @@ -276,6 +276,13 @@ impl InteractiveGame { info!(logger, "Setting kitty theft policy"; "policy" => policy); state.set_kitty_theft_policy(policy)? } + ( + Action::SetFiveTrumpRiverCrossingEnabled(enabled), + GameState::Initialize(ref mut state), + ) => { + info!(logger, "Setting five-trump river crossing"; "enabled" => enabled); + state.set_five_trump_river_crossing_enabled(enabled)? + } (Action::SetGameShadowingPolicy(policy), GameState::Initialize(ref mut state)) => { info!(logger, "Setting user multiple game session policy"; "policy" => policy); state.set_user_multiple_game_session_policy(policy)? @@ -304,6 +311,11 @@ impl InteractiveGame { state.draw_card(id)?; vec![] } + (Action::CutDeck(count), GameState::Draw(ref mut state)) => { + info!(logger, "Cutting the deck"; "count" => count); + state.cut_deck(id, count)?; + vec![MessageVariant::DeckCut { count }] + } (Action::RevealCard, GameState::Draw(ref mut state)) => { info!(logger, "Revealing card"); vec![state.reveal_card()?] @@ -328,8 +340,11 @@ impl InteractiveGame { } (Action::Bid(card, count), GameState::Exchange(ref mut state)) => { info!(logger, "Making exchange bid"); - if state.bid(id, card, count) { - vec![MessageVariant::MadeBid { card, count }] + if state.bid(id, card, count)? { + vec![ + MessageVariant::MadeBid { card, count }, + MessageVariant::PickedUpCards, + ] } else { bail!("bid was invalid") } @@ -339,10 +354,13 @@ impl InteractiveGame { state.take_back_bid(id)?; vec![MessageVariant::TookBackBid] } - (Action::PickUpKitty, GameState::Exchange(ref mut state)) => { - info!(logger, "Picking up cards after over-bid"); - state.pick_up_cards(id)?; - vec![MessageVariant::PickedUpCards] + (Action::PickUpKitty, GameState::Exchange(_)) => { + bail!("a successful sequential kitty-theft bid picks up the bottom immediately") + } + (Action::DeclineKittyTheft, GameState::Exchange(ref mut state)) => { + info!(logger, "Declining kitty theft"); + state.decline_kitty_theft(id)?; + vec![MessageVariant::KittyTheftDeclined] } (Action::PutDownKitty, GameState::Exchange(ref mut state)) => { info!(logger, "Putting down cards after over-bid"); @@ -366,7 +384,33 @@ impl InteractiveGame { } (Action::BeginPlay, GameState::Exchange(ref mut state)) => { info!(logger, "Entering play phase"); - self.state = GameState::Play(state.advance(id)?); + let started_crossing = state.start_river_crossing(id)?; + if !started_crossing || state.river_crossing_complete() { + self.state = GameState::Play(state.advance(id)?); + } + vec![] + } + (Action::DecideRiverCrossing(cross), GameState::Exchange(ref mut state)) => { + info!(logger, "Deciding five-trump river crossing"; "cross" => cross); + let msgs = state.decide_river_crossing(id, cross)?; + if state.river_crossing_complete() { + let landlord = state.landlord(); + self.state = GameState::Play(state.advance(landlord)?); + } + msgs + } + (Action::SubmitRiverCrossingCards(ref cards), GameState::Exchange(ref mut state)) => { + info!(logger, "Submitting five-trump river crossing cards"); + state.submit_river_crossing_cards(id, cards.clone())?; + vec![] + } + (Action::SubmitRiverReturnCards(ref cards), GameState::Exchange(ref mut state)) => { + info!(logger, "Submitting five-trump river return cards"); + state.submit_river_return_cards(id, cards.clone())?; + if state.river_crossing_complete() { + let landlord = state.landlord(); + self.state = GameState::Play(state.advance(landlord)?); + } vec![] } (Action::PlayCards(ref cards), GameState::Play(ref mut state)) => { @@ -463,6 +507,7 @@ pub enum Action { SetPlayTakebackPolicy(PlayTakebackPolicy), SetBidTakebackPolicy(BidTakebackPolicy), SetKittyTheftPolicy(KittyTheftPolicy), + SetFiveTrumpRiverCrossingEnabled(bool), SetGameShadowingPolicy(GameShadowingPolicy), SetGameStartPolicy(GameStartPolicy), SetShouldRevealKittyAtEndOfGame(bool), @@ -473,15 +518,20 @@ pub enum Action { SetCompoundFormats(CompoundFormats), SetGameVisibility(GameVisibility), StartGame, + CutDeck(usize), DrawCard, RevealCard, Bid(Card, usize), + DeclineKittyTheft, PickUpKitty, PutDownKitty, MoveCardToKitty(Card), MoveCardToHand(Card), SetFriends(Vec), BeginPlay, + DecideRiverCrossing(bool), + SubmitRiverCrossingCards(Vec), + SubmitRiverReturnCards(Vec), PlayCards(Vec), PlayCardsWithHint(Vec, Vec), EndTrick, diff --git a/core/src/message.rs b/core/src/message.rs index af955c32..feb92d5e 100644 --- a/core/src/message.rs +++ b/core/src/message.rs @@ -27,6 +27,9 @@ pub enum MessageVariant { ResetCanceled, ResettingGame, StartingGame, + DeckCut { + count: usize, + }, TrickWon { winner: PlayerID, points: usize, @@ -105,11 +108,18 @@ pub enum MessageVariant { KittyTheftPolicySet { policy: KittyTheftPolicy, }, + FiveTrumpRiverCrossingEnabledSet { + enabled: bool, + }, + RiverCrossingInitiated { + player: PlayerID, + }, GameVisibilitySet { visibility: GameVisibility, }, TookBackPlay, TookBackBid, + KittyTheftDeclined, PlayedCards { cards: Vec, }, @@ -216,6 +226,9 @@ impl MessageVariant { ResetCanceled => format!("{} canceled game reset", n?), ResettingGame => format!("{} reset the game", n?), StartingGame => format!("{} started the game", n?), + DeckCut { count } => format!("{} cut {} cards / 切了 {} 张牌", n?, count, count), + RiverCrossingInitiated { player } => + format!("{} 发起了五主过河 / initiated five-trump river crossing", player_name(*player)?), TrickWon { winner, points: 0 } => format!("{} wins the trick, but gets no points :(", player_name(*winner)?), TrickWon { winner, points } => @@ -308,6 +321,8 @@ impl MessageVariant { format!("{} set the game mode to Finding Friends with {} friends", n?, friends), TookBackBid => format!("{} took back their last bid", n?), TookBackPlay => format!("{} took back their last play", n?), + KittyTheftDeclined => + format!("{} 放弃炒底 / declined to steal the bottom", n?), PlayedCards { ref cards } => format!("{} played {}", n?, cards.iter().map(|c| c.as_char()).collect::()), EndOfGameKittyReveal { ref cards } => @@ -367,6 +382,10 @@ impl MessageVariant { format!("{} allowed stealing the bottom cards after the leader", n?), KittyTheftPolicySet { policy: KittyTheftPolicy::NoKittyTheft } => format!("{} disabled stealing the bottom cards after the leader", n?), + FiveTrumpRiverCrossingEnabledSet { enabled: true } => + format!("{} enabled five-trump river crossing (五主过河)", n?), + FiveTrumpRiverCrossingEnabledSet { enabled: false } => + format!("{} disabled five-trump river crossing (五主过河)", n?), GameShadowingPolicySet { policy: GameShadowingPolicy::AllowMultipleSessions } => format!("{} allowed players to be shadowed by joining with the same name", n?), GameShadowingPolicySet { policy: GameShadowingPolicy::SingleSessionOnly } => diff --git a/core/src/settings.rs b/core/src/settings.rs index a9b1d9cb..48e8c744 100644 --- a/core/src/settings.rs +++ b/core/src/settings.rs @@ -285,6 +285,8 @@ pub struct PropagatedState { #[serde(default)] pub(crate) kitty_theft_policy: KittyTheftPolicy, #[serde(default)] + pub(crate) five_trump_river_crossing_enabled: bool, + #[serde(default)] pub(crate) trick_draw_policy: TrickDrawPolicy, #[serde(default)] pub(crate) throw_evaluation_policy: ThrowEvaluationPolicy, @@ -782,6 +784,20 @@ impl PropagatedState { } } + pub fn set_five_trump_river_crossing_enabled( + &mut self, + enabled: bool, + ) -> Result, Error> { + if enabled != self.five_trump_river_crossing_enabled { + self.five_trump_river_crossing_enabled = enabled; + Ok(vec![MessageVariant::FiveTrumpRiverCrossingEnabledSet { + enabled, + }]) + } else { + Ok(vec![]) + } + } + pub fn set_game_visibility( &mut self, game_visibility: GameVisibility, diff --git a/frontend/src/Draw.tsx b/frontend/src/Draw.tsx index c8450b22..6594c0e1 100644 --- a/frontend/src/Draw.tsx +++ b/frontend/src/Draw.tsx @@ -20,6 +20,7 @@ interface IDrawProps { } interface IDrawState { autodraw: boolean; + cutCount: string; } class Draw extends React.Component { private could_draw: boolean = false; @@ -30,8 +31,10 @@ class Draw extends React.Component { super(props); this.state = { autodraw: true, + cutCount: "0", }; this.drawCard = this.drawCard.bind(this); + this.cutDeck = this.cutDeck.bind(this); this.pickUpKitty = this.pickUpKitty.bind(this); this.revealCard = this.revealCard.bind(this); this.onAutodrawClicked = this.onAutodrawClicked.bind(this); @@ -59,6 +62,15 @@ class Draw extends React.Component { } } + cutDeck(evt: React.SyntheticEvent): void { + evt.preventDefault(); + const count = Number(this.state.cutCount); + const max = this.props.state.deck.length + this.props.state.kitty.length; + if (Number.isInteger(count) && count >= 0 && count <= max) { + (window as any).send({ Action: { CutDeck: count } }); + } + } + pickUpKitty(evt: React.SyntheticEvent): void { evt.preventDefault(); (window as any).send({ Action: "PickUpKitty" }); @@ -84,9 +96,13 @@ class Draw extends React.Component { } render(): JSX.Element { + const deckCutPlayer = this.props.state.deck_cut_player ?? null; + const waitingForCut = deckCutPlayer !== null; const canDraw = + !waitingForCut && this.props.state.propagated.players[this.props.state.position].name === - this.props.name && this.props.state.deck.length > 0; + this.props.name && + this.props.state.deck.length > 0; if ( canDraw && !this.could_draw && @@ -103,6 +119,7 @@ class Draw extends React.Component { this.could_draw = canDraw; let next = + deckCutPlayer ?? this.props.state.propagated.players[this.props.state.position].id; if ( this.props.state.deck.length === 0 && @@ -121,6 +138,8 @@ class Draw extends React.Component { }); const landlord = this.props.state.propagated.landlord; + const canCut = deckCutPlayer === playerId; + const maxCut = this.props.state.deck.length + this.props.state.kitty.length; let trump: Trump | undefined; if ( landlord !== null && @@ -184,33 +203,62 @@ class Draw extends React.Component { } prefixButtons={ - <> - - - + waitingForCut ? ( + canCut ? ( +
+ + +
+ ) : ( +

+ Waiting for the player before the first drawer to cut the + deck... / 正在等待摸牌首位的上家切牌…… +

+ ) + ) : ( + <> + + + + ) } suffixButtons={ <> + + + ); + } else if ( + riverCrossing.stage === "Deciding" && + playerState !== undefined && + !playerState.eligible + ) { + actionUI = ( +

+ You have more than five trump cards and cannot cross. Waiting for + other players... / 你的主牌多于五张,不能过河。正在等待其他玩家…… +

+ ); + } else if ( + riverCrossing.stage === "SelectingCrossingCards" && + playerState?.decision === true && + !playerState.crossing_cards_submitted + ) { + actionUI = ( + <> +

+ Select exactly five cards, including every trump card in your + hand. They will be revealed to your partner only after all + crossing selections are submitted. / + 请选择正好五张牌,其中必须包含手中全部主牌;所有需要过河的玩家提交后才会同时交换。 +

+ + this.setState({ selectedRiverCards }) + } + trump={this.props.state.trump} + /> + + + ); + } else if ( + riverCrossing.stage === "SelectingReturnCards" && + playerState !== undefined && + partnerState?.decision === true && + !playerState.return_cards_submitted + ) { + actionUI = ( + <> +

+ Your partner sent you these cards. Select exactly five cards to + return. Return selections are exchanged simultaneously. / + 这是对家给你的牌。请从现在的手牌中选择正好五张归还;双方都提交后才会同时还牌。 +

+ {playerState.received_crossing_cards.length > 0 ? ( + + ) : null} + + this.setState({ selectedRiverCards }) + } + trump={this.props.state.trump} + /> + + + ); + } + + return ( +
+
+ + + +

Five-trump river crossing / 五主过河

+ {actionUI} +
+ ); + } + + const kittyTheft = this.props.state.kitty_theft; + const kittyTheftStage = kittyTheft?.stage ?? "Disabled"; + const currentKittyTheftPlayer = kittyTheft?.current_player ?? null; + const activeKittyTheftPlayer = + kittyTheftStage === "Waiting" + ? currentKittyTheftPlayer + : this.props.state.exchanger; + const currentKittyTheftPlayerName = + this.props.state.propagated.players.find( + (player) => player.id === activeKittyTheftPlayer, + )?.name ?? "another player"; const nextPlayer = - kittyTheftEnabled && - !this.props.state.finalized && - this.props.state.exchanger !== null - ? this.props.state.exchanger - : this.props.state.landlord; + kittyTheftStage === "Waiting" && currentKittyTheftPlayer !== null + ? currentKittyTheftPlayer + : kittyTheftStage === "Exchanging" && + this.props.state.exchanger !== null + ? this.props.state.exchanger + : this.props.state.landlord; + const kittyTheftComplete = + !kittyTheftEnabled || + this.props.state.autobid !== null || + kittyTheftStage === "Disabled" || + kittyTheftStage === "Complete"; const exchangeUI = isExchanger && !this.props.state.finalized ? ( @@ -209,15 +409,12 @@ class Exchange extends React.Component { ) : null; - const lastBid = this.props.state.bids![this.props.state.bids!.length - 1]; const startGame = ( - {isLandlord ? startGame : null} - - } - bidTakeBacksEnabled={ - this.props.state.propagated.bid_takeback_policy === - "AllowBidTakeback" + } + bidTakeBacksEnabled={false} /> { players={this.props.state.propagated.players} observers={this.props.state.propagated.observers} landlord={this.props.state.landlord} - next={this.props.state.landlord} + next={nextPlayer} name={this.props.name} /> @@ -337,11 +525,17 @@ class Exchange extends React.Component { playerId={playerId} trump={this.props.state.trump} /> -

Waiting...

+

+ {kittyTheftStage === "Waiting" + ? `Waiting for ${currentKittyTheftPlayerName} to decide whether to steal the bottom... / 正在等待 ${currentKittyTheftPlayerName} 决定是否炒底……` + : kittyTheftStage === "Exchanging" + ? `Waiting for ${currentKittyTheftPlayerName} to finish exchanging cards... / 正在等待埋底完成……` + : "Waiting..."} +

) : null} {playerId !== nextPlayer && } - {isLandlord && bidUI === null ? startGame : null} + {isLandlord && bidUI === null && kittyTheftComplete ? startGame : null} {bidUI} ); diff --git a/frontend/src/Initialize.tsx b/frontend/src/Initialize.tsx index c432750f..906bbe10 100644 --- a/frontend/src/Initialize.tsx +++ b/frontend/src/Initialize.tsx @@ -1052,6 +1052,13 @@ const Initialize = (props: IProps): JSX.Element => { }, }); break; + case "five_trump_river_crossing_enabled": + send({ + Action: { + SetFiveTrumpRiverCrossingEnabled: value, + }, + }); + break; case "throw_penalty": send({ Action: { @@ -1336,6 +1343,37 @@ const Initialize = (props: IProps): JSX.Element => { +
+ + {props.state.propagated.game_mode !== "Tractor" || + props.state.propagated.players.length !== 4 ? ( + (available only in four-player Tractor games) + ) : null} +
} + lastTrick={null} + ownHistory={[]} + ownHistoryAvailable={true} + players={players} + landlord={1} + landlordSuffix="(当庄)" + landlordsTeam={[1, 3]} + name="Alice" + trump={trump} + />, + ); + + expect(html).toContain("Current trick"); + expect(html).toContain("Previous trick unavailable / 上一轮暂无"); + expect(html).toContain("My play history (0) ▼ / 我的出牌历史(0墩)"); + expect(html.match(/aria-expanded="false"/g)).toHaveLength(2); + expect(html).not.toContain('id="previous-trick-panel"'); + expect(html).not.toContain('id="own-play-history-panel"'); + }); + + it("shows all four players in seat order in the previous-trick panel", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html.indexOf("Alice")).toBeLessThan(html.indexOf("Bob")); + expect(html.indexOf("Bob")).toBeLessThan(html.indexOf("Carol")); + expect(html.indexOf("Carol")).toBeLessThan(html.indexOf("Dave")); + expect(html).toContain('data-cards="♤A"'); + expect(html).toContain('data-cards="♤K"'); + expect(html).toContain('class="winning"'); + }); + + it("renders personal history newest first while preserving trick groups", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html.indexOf("第3墩")).toBeLessThan(html.indexOf("第2墩")); + expect(html.indexOf("第2墩")).toBeLessThan(html.indexOf("第1墩")); + expect(html).toContain('data-cards="♡4,♡4"'); + }); + + it("keeps the personal-history button visible but disabled for observers", () => { + const html = renderToStaticMarkup( + Current trick} + lastTrick={lastTrick} + ownHistory={[]} + ownHistoryAvailable={false} + players={players} + landlord={1} + landlordSuffix="(当庄)" + landlordsTeam={[1, 3]} + name="Observer" + trump={trump} + />, + ); + + expect(html).toContain("My play history unavailable / 观察者无个人记录"); + expect(html.match(/ disabled=""/g)).toHaveLength(1); + }); +}); diff --git a/frontend/src/PlayHistory.tsx b/frontend/src/PlayHistory.tsx new file mode 100644 index 00000000..af7284f4 --- /dev/null +++ b/frontend/src/PlayHistory.tsx @@ -0,0 +1,135 @@ +import * as React from "react"; + +import LabeledPlay from "./LabeledPlay"; +import TrickDisplay from "./Trick"; +import { Player, Trick, Trump } from "./gen-types"; + +import type { JSX } from "react"; + +interface CommonProps { + players: Player[]; + landlord: number; + landlordSuffix: string; + landlordsTeam: number[]; + name: string; + trump: Trump; +} + +interface IProps extends CommonProps { + currentTrick: JSX.Element; + lastTrick?: Trick | null; + ownHistory: string[][]; + ownHistoryAvailable: boolean; +} + +export const PreviousTrickPanel = ( + props: CommonProps & { trick: Trick }, +): JSX.Element => ( +
+

Previous trick / 上一轮出牌

+ +
+); + +export const OwnPlayHistoryPanel = (props: { + history: string[][]; + trump: Trump; +}): JSX.Element => ( +
+

My play history / 我的出牌历史

+ {props.history.length === 0 ? ( +

No completed plays yet / 暂无已完成的出牌

+ ) : ( +
+ {props.history + .map((cards, index) => ({ cards, trickNumber: index + 1 })) + .reverse() + .map(({ cards, trickNumber }) => ( + + ))} +
+ )} +
+); + +const PlayHistory = (props: IProps): JSX.Element => { + const [showPreviousTrick, setShowPreviousTrick] = React.useState(false); + const [showOwnHistory, setShowOwnHistory] = React.useState(false); + const hasPreviousTrick = + props.lastTrick !== undefined && props.lastTrick !== null; + + return ( +
+
+
{props.currentTrick}
+
+ + +
+
+ {showPreviousTrick && props.lastTrick ? ( + + ) : null} + {showOwnHistory && props.ownHistoryAvailable ? ( + + ) : null} +
+ ); +}; + +export default PlayHistory; diff --git a/frontend/src/Players.tsx b/frontend/src/Players.tsx index 5dd5369b..cc00263a 100644 --- a/frontend/src/Players.tsx +++ b/frontend/src/Players.tsx @@ -12,6 +12,7 @@ interface IProps { observers: Player[]; landlord?: number | null; landlords_team?: number[]; + riverCrossingInitiators?: number[]; movable?: boolean; next?: number | null; name: string; @@ -31,15 +32,28 @@ const Players = (props: IProps): JSX.Element => { const { send } = React.useContext(WebsocketContext); const makeDescriptor = (p: Player): Array => { + const descriptor: Array = [p.name]; + if (props.riverCrossingInitiators?.includes(p.id)) { + descriptor.push( + + 已过河 + , + ); + } if (p.metalevel <= 1) { - return [`${p.name} (rank ${p.level})`]; + descriptor.push(` (rank ${p.level})`); } else { - return [ - `${p.name} (rank ${p.level}`, + descriptor.push( + ` (rank ${p.level}`, {p.metalevel}, ")", - ]; + ); } + return descriptor; }; return ( diff --git a/frontend/src/RiverCrossingNotice.test.tsx b/frontend/src/RiverCrossingNotice.test.tsx new file mode 100644 index 00000000..306ffad4 --- /dev/null +++ b/frontend/src/RiverCrossingNotice.test.tsx @@ -0,0 +1,69 @@ +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Player } from "./gen-types"; +import Players from "./Players"; +import RiverCrossingNotice from "./RiverCrossingNotice"; + +jest.mock("./WebsocketProvider", () => ({ + WebsocketContext: jest + .requireActual("react") + .createContext({ send: jest.fn() }), +})); + +const players: Player[] = [ + { id: 1, name: "Alice", level: "2", metalevel: 1 }, + { id: 2, name: "Bob", level: "2", metalevel: 2 }, + { id: 3, name: "Carol", level: "2", metalevel: 1 }, + { id: 4, name: "Dave", level: "2", metalevel: 1 }, +]; + +describe("river crossing announcements and deal markers", () => { + it("announces every initiator, without naming the return-only partner", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Alice、Bob"); + expect(html).toContain("发起了过河"); + expect(html).toContain('role="status"'); + expect(html).not.toContain("Carol"); + expect(html).not.toContain("Dave"); + }); + + it("places badges next to names while preserving rank and player descriptors", () => { + const html = renderToStaticMarkup( + , + ); + expect(html.match(/class="river-crossing-badge"/g)).toHaveLength(2); + expect(html).toContain('Alice2)"); + expect(html).toContain("Carol (rank 2)"); + }); + + it.each([undefined, []])( + "shows no notice or badge for a fresh or legacy deal (%s)", + (initiators) => { + expect( + renderToStaticMarkup( + , + ), + ).toBe(""); + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain("river-crossing-badge"); + }, + ); +}); diff --git a/frontend/src/RiverCrossingNotice.tsx b/frontend/src/RiverCrossingNotice.tsx new file mode 100644 index 00000000..bba36450 --- /dev/null +++ b/frontend/src/RiverCrossingNotice.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; +import { Player } from "./gen-types"; + +import type { JSX } from "react"; + +interface IProps { + players: Player[]; + initiators?: number[]; +} + +const RiverCrossingNotice = ({ + players, + initiators, +}: IProps): JSX.Element | null => { + const names = players + .filter((player) => initiators?.includes(player.id)) + .map((player) => player.name); + + if (names.length === 0) { + return null; + } + + return ( +

+ 五主过河:{names.join("、")} 发起了过河。 + / River crossing initiated by {names.join(", ")}. +

+ ); +}; + +export default RiverCrossingNotice; diff --git a/frontend/src/Root.tsx b/frontend/src/Root.tsx index c1644a98..11831ca6 100644 --- a/frontend/src/Root.tsx +++ b/frontend/src/Root.tsx @@ -135,7 +135,6 @@ const Root = (): JSX.Element => { { /> - - show last trick - - - - beep on turn diff --git a/frontend/src/gen-types.d.ts b/frontend/src/gen-types.d.ts index cff26467..7efbbd23 100644 --- a/frontend/src/gen-types.d.ts +++ b/frontend/src/gen-types.d.ts @@ -1,3 +1,4 @@ +/* eslint-disable */ /** * This file was automatically generated by json-schema-to-typescript. * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, @@ -11,6 +12,7 @@ export type Action = | "StartGame" | "DrawCard" | "RevealCard" + | "DeclineKittyTheft" | "PickUpKitty" | "PutDownKitty" | "BeginPlay" @@ -114,6 +116,9 @@ export type Action = | { SetKittyTheftPolicy: KittyTheftPolicy; } + | { + SetFiveTrumpRiverCrossingEnabled: boolean; + } | { SetGameShadowingPolicy: GameShadowingPolicy; } @@ -141,6 +146,9 @@ export type Action = | { SetGameVisibility: GameVisibility; } + | { + CutDeck: number; + } | { /** * @minItems 2 @@ -157,6 +165,15 @@ export type Action = | { SetFriends: FriendSelection[]; } + | { + DecideRiverCrossing: boolean; + } + | { + SubmitRiverCrossingCards: Card[]; + } + | { + SubmitRiverReturnCards: Card[]; + } | { PlayCards: Card[]; } @@ -329,6 +346,16 @@ export type GameMode = [k: string]: unknown; }; }; +export type KittyTheftStage = + | "Disabled" + | "Exchanging" + | "Waiting" + | "Complete"; +export type RiverCrossingStage = + | "Deciding" + | "SelectingCrossingCards" + | "SelectingReturnCards" + | "Complete"; export type MessageVariant = | { type: "ResetRequested"; @@ -346,6 +373,11 @@ export type MessageVariant = type: "StartingGame"; [k: string]: unknown; } + | { + count: number; + type: "DeckCut"; + [k: string]: unknown; + } | { points: number; type: "TrickWon"; @@ -472,6 +504,16 @@ export type MessageVariant = type: "KittyTheftPolicySet"; [k: string]: unknown; } + | { + enabled: boolean; + type: "FiveTrumpRiverCrossingEnabledSet"; + [k: string]: unknown; + } + | { + player: number; + type: "RiverCrossingInitiated"; + [k: string]: unknown; + } | { type: "GameVisibilitySet"; visibility: GameVisibility; @@ -485,6 +527,10 @@ export type MessageVariant = type: "TookBackBid"; [k: string]: unknown; } + | { + type: "KittyTheftDeclined"; + [k: string]: unknown; + } | { cards: Card[]; type: "PlayedCards"; @@ -932,6 +978,7 @@ export interface PropagatedState { chat_link?: string | null; compound_formats?: CompoundFormats; first_landlord_selection_policy?: FirstLandlordSelectionPolicy & string; + five_trump_river_crossing_enabled?: boolean; friend_selection_policy?: FriendSelectionPolicy & string; game_mode: GameModeSettings; game_scoring_parameters?: GameScoringParameters; @@ -969,6 +1016,7 @@ export interface DrawPhase { autobid?: Bid | null; bids: Bid[]; deck: Card[]; + deck_cut_player?: number | null; decks?: Deck[]; game_mode: GameMode; hands: Hands; @@ -1000,14 +1048,38 @@ export interface ExchangePhase { hands: Hands; kitty: Card[]; kitty_size: number; + kitty_theft?: KittyTheftState; landlord: number; num_decks: number; player_requested_reset?: number | null; propagated: PropagatedState; removed_cards?: Card[]; + river_crossing?: RiverCrossingState | null; trump: Trump; [k: string]: unknown; } +export interface KittyTheftState { + current_player?: number | null; + remaining_players: number; + stage: KittyTheftStage; + [k: string]: unknown; +} +export interface RiverCrossingState { + players: RiverCrossingPlayerState[]; + stage: RiverCrossingStage; + [k: string]: unknown; +} +export interface RiverCrossingPlayerState { + crossing_cards: Card[]; + crossing_cards_submitted: boolean; + decision?: boolean | null; + eligible: boolean; + player_id: number; + received_crossing_cards: Card[]; + return_cards: Card[]; + return_cards_submitted: boolean; + [k: string]: unknown; +} export interface PlayPhase { decks?: Deck[]; exchanger: number; @@ -1022,12 +1094,22 @@ export interface PlayPhase { penalties: { [k: string]: number; }; + /** + * Completed plays for each player in this deal. This is redacted down to the requesting player's own history before game state is sent. + */ + played_card_history?: { + [k: string]: Card[][]; + }; player_requested_reset?: number | null; points: { [k: string]: Card[]; }; propagated: PropagatedState; removed_cards?: Card[]; + /** + * Public initiator IDs for this deal only; never propagated to the next deal. + */ + river_crossing_initiators?: number[]; trick: Trick; trump: Trump; [k: string]: unknown; diff --git a/frontend/src/gen-types.schema.json b/frontend/src/gen-types.schema.json index 2dac7669..19431670 100644 --- a/frontend/src/gen-types.schema.json +++ b/frontend/src/gen-types.schema.json @@ -117,6 +117,7 @@ "StartGame", "DrawCard", "RevealCard", + "DeclineKittyTheft", "PickUpKitty", "PutDownKitty", "BeginPlay", @@ -458,6 +459,16 @@ }, "additionalProperties": false }, + { + "type": "object", + "required": ["SetFiveTrumpRiverCrossingEnabled"], + "properties": { + "SetFiveTrumpRiverCrossingEnabled": { + "type": "boolean" + } + }, + "additionalProperties": false + }, { "type": "object", "required": ["SetGameShadowingPolicy"], @@ -548,6 +559,18 @@ }, "additionalProperties": false }, + { + "type": "object", + "required": ["CutDeck"], + "properties": { + "CutDeck": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, { "type": "object", "required": ["Bid"], @@ -603,6 +626,42 @@ }, "additionalProperties": false }, + { + "type": "object", + "required": ["DecideRiverCrossing"], + "properties": { + "DecideRiverCrossing": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["SubmitRiverCrossingCards"], + "properties": { + "SubmitRiverCrossingCards": { + "type": "array", + "items": { + "$ref": "#/definitions/Card" + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["SubmitRiverReturnCards"], + "properties": { + "SubmitRiverReturnCards": { + "type": "array", + "items": { + "$ref": "#/definitions/Card" + } + } + }, + "additionalProperties": false + }, { "type": "object", "required": ["PlayCards"], @@ -1029,6 +1088,12 @@ "$ref": "#/definitions/Card" } }, + "deck_cut_player": { + "default": null, + "type": ["integer", "null"], + "format": "uint", + "minimum": 0.0 + }, "decks": { "default": [], "type": "array", @@ -1166,6 +1231,18 @@ "format": "uint", "minimum": 0.0 }, + "kitty_theft": { + "default": { + "current_player": null, + "remaining_players": 0, + "stage": "Disabled" + }, + "allOf": [ + { + "$ref": "#/definitions/KittyTheftState" + } + ] + }, "landlord": { "type": "integer", "format": "uint", @@ -1191,6 +1268,17 @@ "$ref": "#/definitions/Card" } }, + "river_crossing": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/RiverCrossingState" + }, + { + "type": "null" + } + ] + }, "trump": { "$ref": "#/definitions/Trump" } @@ -1780,6 +1868,29 @@ "type": "string", "enum": ["AllowKittyTheft", "NoKittyTheft"] }, + "KittyTheftStage": { + "type": "string", + "enum": ["Disabled", "Exchanging", "Waiting", "Complete"] + }, + "KittyTheftState": { + "type": "object", + "required": ["remaining_players", "stage"], + "properties": { + "current_player": { + "type": ["integer", "null"], + "format": "uint", + "minimum": 0.0 + }, + "remaining_players": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "stage": { + "$ref": "#/definitions/KittyTheftStage" + } + } + }, "MaxRank": { "$ref": "#/definitions/Rank" }, @@ -1825,6 +1936,21 @@ } } }, + { + "type": "object", + "required": ["count", "type"], + "properties": { + "count": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "type": { + "type": "string", + "enum": ["DeckCut"] + } + } + }, { "type": "object", "required": ["points", "type", "winner"], @@ -2187,6 +2313,34 @@ } } }, + { + "type": "object", + "required": ["enabled", "type"], + "properties": { + "enabled": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": ["FiveTrumpRiverCrossingEnabledSet"] + } + } + }, + { + "type": "object", + "required": ["player", "type"], + "properties": { + "player": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "type": { + "type": "string", + "enum": ["RiverCrossingInitiated"] + } + } + }, { "type": "object", "required": ["type", "visibility"], @@ -2220,6 +2374,16 @@ } } }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["KittyTheftDeclined"] + } + } + }, { "type": "object", "required": ["cards", "type"], @@ -2777,6 +2941,20 @@ "minimum": 0.0 } }, + "played_card_history": { + "description": "Completed plays for each player in this deal. This is redacted down to the requesting player's own history before game state is sent.", + "default": {}, + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/Card" + } + } + } + }, "player_requested_reset": { "type": ["integer", "null"], "format": "uint", @@ -2801,6 +2979,16 @@ "$ref": "#/definitions/Card" } }, + "river_crossing_initiators": { + "description": "Public initiator IDs for this deal only; never propagated to the next deal.", + "default": [], + "type": "array", + "items": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + }, "trick": { "$ref": "#/definitions/Trick" }, @@ -2961,6 +3149,10 @@ } ] }, + "five_trump_river_crossing_enabled": { + "default": false, + "type": "boolean" + }, "friend_selection_policy": { "default": "Unrestricted", "allOf": [ @@ -3180,6 +3372,79 @@ "Rank": { "type": "string" }, + "RiverCrossingPlayerState": { + "type": "object", + "required": [ + "crossing_cards", + "crossing_cards_submitted", + "eligible", + "player_id", + "received_crossing_cards", + "return_cards", + "return_cards_submitted" + ], + "properties": { + "crossing_cards": { + "type": "array", + "items": { + "$ref": "#/definitions/Card" + } + }, + "crossing_cards_submitted": { + "type": "boolean" + }, + "decision": { + "type": ["boolean", "null"] + }, + "eligible": { + "type": "boolean" + }, + "player_id": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "received_crossing_cards": { + "type": "array", + "items": { + "$ref": "#/definitions/Card" + } + }, + "return_cards": { + "type": "array", + "items": { + "$ref": "#/definitions/Card" + } + }, + "return_cards_submitted": { + "type": "boolean" + } + } + }, + "RiverCrossingStage": { + "type": "string", + "enum": [ + "Deciding", + "SelectingCrossingCards", + "SelectingReturnCards", + "Complete" + ] + }, + "RiverCrossingState": { + "type": "object", + "required": ["players", "stage"], + "properties": { + "players": { + "type": "array", + "items": { + "$ref": "#/definitions/RiverCrossingPlayerState" + } + }, + "stage": { + "$ref": "#/definitions/RiverCrossingStage" + } + } + }, "ScoreSegment": { "type": "object", "required": ["point_threshold", "results"], diff --git a/frontend/src/state/Settings.ts b/frontend/src/state/Settings.ts index 274db6fc..76d683d1 100644 --- a/frontend/src/state/Settings.ts +++ b/frontend/src/state/Settings.ts @@ -10,7 +10,6 @@ export interface Settings { fourColor: boolean; darkMode: boolean; showCardLabels: boolean; - showLastTrick: boolean; beepOnTurn: boolean; reverseCardOrder: boolean; unsetAutoPlayWhenWinnerChanges: boolean; @@ -44,8 +43,6 @@ const darkMode: State = booleanLocalStorageState("dark_mode"); const svgCards: State = booleanLocalStorageState("svg_cards"); const showCardLabels: State = booleanLocalStorageState("show_card_labels"); -const showLastTrick: State = - booleanLocalStorageState("show_last_trick"); const beepOnTurn: State = booleanLocalStorageState("beep_on_turn"); const reverseCardOrder: State = booleanLocalStorageState("reverse_card_order"); @@ -95,7 +92,6 @@ const settings: State = combineState({ fourColor, darkMode, showCardLabels, - showLastTrick, beepOnTurn, reverseCardOrder, unsetAutoPlayWhenWinnerChanges, diff --git a/frontend/src/style.css b/frontend/src/style.css index f3914d40..17fc0b19 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -72,6 +72,69 @@ button.normal { padding: 10px; } +.trick-history-main { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.current-trick-display { + min-width: 0; + flex: 1; +} + +.play-history-buttons { + display: flex; + flex-direction: column; + min-width: 230px; +} + +.play-history-buttons button.big { + margin: 5px 0; + white-space: nowrap; +} + +.play-history-panel { + margin: 10px 0; + padding: 10px; + border: 1px solid #bbb; + border-radius: 6px; +} + +.play-history-panel h3 { + margin-top: 0; +} + +.own-play-history-list { + max-height: 320px; + overflow-y: auto; +} + +.own-play-history-list .labeled-play { + margin-right: 8px; +} + +.dark-mode .play-history-panel { + border-color: #555; +} + +@media (max-width: 850px) { + .trick-history-main { + flex-direction: column; + } + + .play-history-buttons { + min-width: 0; + flex-direction: row; + flex-wrap: wrap; + } + + .play-history-buttons button.big { + margin: 5px; + white-space: normal; + } +} + .card { display: inline-block; cursor: pointer; @@ -465,3 +528,23 @@ label { .rules .card:last-child { margin-right: 0; } +.river-crossing-badge { + display: inline-block; + margin-left: 0.4em; + padding: 0.1em 0.45em; + border: 1px solid #147d92; + border-radius: 0.4em; + background: #e6f7fa; + color: #095267; + font-size: 0.8em; + font-weight: bold; + white-space: nowrap; +} + +.river-crossing-notice { + padding: 0.6em 0.8em; + border-left: 3px solid #147d92; + background: #e6f7fa; + color: #095267; + overflow-wrap: anywhere; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 8b409eca..7beaa004 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -3,6 +3,7 @@ "noImplicitAny": true, "esModuleInterop": true, "strictNullChecks": true, + "skipLibCheck": true, "module": "esnext", "moduleResolution": "node", "target": "es5",