From 16e8ebbd65b731e8f1f3590905f8794060cdd9a1 Mon Sep 17 00:00:00 2001 From: piaca24601 Date: Thu, 10 Sep 2026 18:55:41 +0900 Subject: [PATCH 1/5] Add optional five-trump river crossing --- core/src/game_state/exchange_phase.rs | 616 +++++++++++++++++++++++++- core/src/interactive.rs | 39 +- core/src/message.rs | 7 + core/src/settings.rs | 16 + frontend/src/Exchange.tsx | 171 +++++++ frontend/src/Initialize.tsx | 38 ++ frontend/src/gen-types.d.ts | 41 ++ frontend/src/gen-types.schema.json | 147 ++++++ 8 files changed, 1072 insertions(+), 3 deletions(-) diff --git a/core/src/game_state/exchange_phase.rs b/core/src/game_state/exchange_phase.rs index fb447636..d29e0975 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,34 @@ 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, Clone, Serialize, Deserialize, JsonSchema)] pub struct ExchangePhase { propagated: PropagatedState, @@ -48,6 +76,8 @@ pub struct ExchangePhase { removed_cards: Vec, #[serde(default)] decks: Vec, + #[serde(default)] + river_crossing: Option, player_requested_reset: Option, } @@ -80,6 +110,7 @@ impl ExchangePhase { autobid, removed_cards, decks, + river_crossing: None, finalized: false, epoch: 1, player_requested_reset: None, @@ -95,6 +126,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 +141,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 +171,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 +254,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") } @@ -228,6 +271,9 @@ impl ExchangePhase { } 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!") } @@ -262,7 +308,7 @@ impl ExchangePhase { } pub fn bid(&mut self, id: PlayerID, card: Card, count: usize) -> bool { - if !self.finalized || self.autobid.is_some() { + if self.river_crossing.is_some() || !self.finalized || self.autobid.is_some() { return false; } Bid::bid( @@ -283,6 +329,9 @@ impl ExchangePhase { } 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,6 +367,12 @@ impl ExchangePhase { } pub fn next_player(&self) -> Result { + 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); + } if self.propagated.kitty_theft_policy == KittyTheftPolicy::AllowKittyTheft && self.autobid.is_none() && !self.finalized @@ -352,6 +407,13 @@ impl ExchangePhase { bail!("must give other players a chance to over-bid and swap cards") } + 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 .propagated .players @@ -389,6 +451,303 @@ impl ExchangePhase { ) } + 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.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") + } + + 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 + }; + } + Ok(()) + } + + 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,6 +787,7 @@ 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 { @@ -442,5 +802,257 @@ 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![], + ) + } + + #[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/interactive.rs b/core/src/interactive.rs index 246b7407..c6a25910 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)? @@ -366,7 +373,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); + state.decide_river_crossing(id, cross)?; + if state.river_crossing_complete() { + let landlord = state.landlord(); + self.state = GameState::Play(state.advance(landlord)?); + } + vec![] + } + (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 +496,7 @@ pub enum Action { SetPlayTakebackPolicy(PlayTakebackPolicy), SetBidTakebackPolicy(BidTakebackPolicy), SetKittyTheftPolicy(KittyTheftPolicy), + SetFiveTrumpRiverCrossingEnabled(bool), SetGameShadowingPolicy(GameShadowingPolicy), SetGameStartPolicy(GameStartPolicy), SetShouldRevealKittyAtEndOfGame(bool), @@ -482,6 +516,9 @@ pub enum Action { 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..1cba8348 100644 --- a/core/src/message.rs +++ b/core/src/message.rs @@ -105,6 +105,9 @@ pub enum MessageVariant { KittyTheftPolicySet { policy: KittyTheftPolicy, }, + FiveTrumpRiverCrossingEnabledSet { + enabled: bool, + }, GameVisibilitySet { visibility: GameVisibility, }, @@ -367,6 +370,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/Exchange.tsx b/frontend/src/Exchange.tsx index 8135c51c..d94770de 100644 --- a/frontend/src/Exchange.tsx +++ b/frontend/src/Exchange.tsx @@ -38,6 +38,7 @@ function ExchangeWrapper(props: IExchangeProps) { interface IExchangeState { friends: Friend[]; + selectedRiverCards: string[]; } class Exchange extends React.Component { constructor(props: IExchangeProps) { @@ -50,6 +51,7 @@ class Exchange extends React.Component { this.pickFriends = this.pickFriends.bind(this); this.state = { friends: [], + selectedRiverCards: [], }; this.fixFriends = this.fixFriends.bind(this); @@ -163,6 +165,175 @@ class Exchange extends React.Component { const kittyTheftEnabled = this.props.state.propagated.kitty_theft_policy === "AllowKittyTheft"; + const riverCrossing = this.props.state.river_crossing; + if (riverCrossing !== null && riverCrossing !== undefined) { + const playerState = riverCrossing.players.find( + (p) => p.player_id === playerId, + ); + const playerPosition = this.props.state.propagated.players.findIndex( + (p) => p.id === playerId, + ); + const partnerId = + playerPosition >= 0 && this.props.state.propagated.players.length === 4 + ? this.props.state.propagated.players[(playerPosition + 2) % 4].id + : -1; + const partnerState = riverCrossing.players.find( + (p) => p.player_id === partnerId, + ); + + let actionUI: JSX.Element = ( +

Waiting for other players... / 等待其他玩家……

+ ); + if ( + riverCrossing.stage === "Deciding" && + playerState?.eligible && + playerState.decision === null + ) { + actionUI = ( + <> +

+ You have five or fewer trump cards. Would you like to cross the + river?(你的主牌不超过五张,是否选择五主过河?) +

+ + + + ); + } 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 nextPlayer = kittyTheftEnabled && !this.props.state.finalized && 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} +
); diff --git a/frontend/src/gen-types.d.ts b/frontend/src/gen-types.d.ts index 6e629507..1923d4d4 100644 --- a/frontend/src/gen-types.d.ts +++ b/frontend/src/gen-types.d.ts @@ -12,6 +12,7 @@ export type Action = | "StartGame" | "DrawCard" | "RevealCard" + | "DeclineKittyTheft" | "PickUpKitty" | "PutDownKitty" | "BeginPlay" @@ -342,6 +343,11 @@ export type GameMode = [k: string]: unknown; }; }; +export type KittyTheftStage = + | "Disabled" + | "Exchanging" + | "Waiting" + | "Complete"; export type RiverCrossingStage = | "Deciding" | "SelectingCrossingCards" @@ -513,6 +519,10 @@ export type MessageVariant = type: "TookBackBid"; [k: string]: unknown; } + | { + type: "KittyTheftDeclined"; + [k: string]: unknown; + } | { cards: Card[]; type: "PlayedCards"; @@ -1029,6 +1039,7 @@ export interface ExchangePhase { hands: Hands; kitty: Card[]; kitty_size: number; + kitty_theft?: KittyTheftState; landlord: number; num_decks: number; player_requested_reset?: number | null; @@ -1038,6 +1049,12 @@ export interface ExchangePhase { 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; diff --git a/frontend/src/gen-types.schema.json b/frontend/src/gen-types.schema.json index e7f56363..da7e84b3 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", @@ -1212,6 +1213,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", @@ -1837,6 +1850,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" }, @@ -2305,6 +2341,16 @@ } } }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["KittyTheftDeclined"] + } + } + }, { "type": "object", "required": ["cards", "type"], From 9cd483478e91b93e8a6ab7968af43ea9c9832cae Mon Sep 17 00:00:00 2001 From: piaca24601 Date: Sat, 12 Sep 2026 17:50:57 +0800 Subject: [PATCH 4/5] Add private play history controls --- core/src/game_state/play_phase.rs | 94 ++++++++++++++++++++ frontend/src/Play.tsx | 46 +++++----- frontend/src/PlayHistory.test.tsx | 126 +++++++++++++++++++++++++++ frontend/src/PlayHistory.tsx | 135 +++++++++++++++++++++++++++++ frontend/src/Root.tsx | 1 - frontend/src/SettingsPane.tsx | 13 --- frontend/src/gen-types.d.ts | 6 ++ frontend/src/gen-types.schema.json | 14 +++ frontend/src/state/Settings.ts | 4 - frontend/src/style.css | 63 ++++++++++++++ 10 files changed, 461 insertions(+), 41 deletions(-) create mode 100644 frontend/src/PlayHistory.test.tsx create mode 100644 frontend/src/PlayHistory.tsx diff --git a/core/src/game_state/play_phase.rs b/core/src/game_state/play_phase.rs index 900cadec..a81c02a8 100644 --- a/core/src/game_state/play_phase.rs +++ b/core/src/game_state/play_phase.rs @@ -55,6 +55,10 @@ 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, @@ -83,6 +87,11 @@ impl PlayPhase { 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, @@ -112,6 +121,7 @@ impl PlayPhase { river_crossing_initiators, game_ended_early: false, last_trick: None, + played_card_history, player_requested_reset: None, }) } @@ -356,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) @@ -674,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) { @@ -692,3 +710,79 @@ impl PlayPhase { } } } + +#[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.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/frontend/src/Play.tsx b/frontend/src/Play.tsx index 580e1535..dd622c41 100644 --- a/frontend/src/Play.tsx +++ b/frontend/src/Play.tsx @@ -19,6 +19,7 @@ import Points, { calculatePoints, ProgressBarDisplay } from "./Points"; import LabeledPlay from "./LabeledPlay"; import Players from "./Players"; import RiverCrossingNotice from "./RiverCrossingNotice"; +import PlayHistory from "./PlayHistory"; import ArrayUtils from "./util/array"; import AutoPlayButton from "./AutoPlayButton"; import BeepButton from "./BeepButton"; @@ -44,7 +45,6 @@ interface IProps { playPhase: PlayPhase; name: string; beepOnTurn: boolean; - showLastTrick: boolean; unsetAutoPlayWhenWinnerChanges: boolean; showTrickInPlayerOrder: boolean; } @@ -225,6 +225,9 @@ const Play = (props: IProps): JSX.Element => { ]); const isCurrentPlayerTurn = currentPlayer.id === nextPlayer; + const ownPlayHistory = isSpectator + ? [] + : (playPhase.played_card_history?.[currentPlayer.id] ?? []); const canTakeBack = lastPlay !== undefined && currentPlayer.id === lastPlay.id && @@ -368,15 +371,28 @@ const Play = (props: IProps): JSX.Element => { smallerTeamSize={smallerTeamSize} /> )} - + } + lastTrick={playPhase.last_trick} + ownHistory={ownPlayHistory} + ownHistoryAvailable={!isSpectator} players={playPhase.propagated.players} landlord={playPhase.landlord} - landlord_suffix={landlordSuffix} - landlords_team={playPhase.landlords_team} - next={nextPlayer} + landlordSuffix={landlordSuffix} + landlordsTeam={playPhase.landlords_team} name={props.name} - showTrickInPlayerOrder={props.showTrickInPlayerOrder} + trump={playPhase.trump} /> { /> )} - {playPhase.last_trick !== undefined && - playPhase.last_trick !== null && - props.showLastTrick ? ( -
-

Previous trick

- -
- ) : null} {playPhase.propagated.game_scoring_parameters ? ( + ({ + cards, + className, + label, + }: { + cards?: string[]; + className?: string; + label: React.ReactNode; + }) => ( +
+ {label} +
+ ), +); + +const players: Player[] = [ + { id: 1, name: "Alice", level: "2", metalevel: 1 }, + { id: 2, name: "Bob", level: "2", metalevel: 1 }, + { id: 3, name: "Carol", level: "2", metalevel: 1 }, + { id: 4, name: "Dave", level: "2", metalevel: 1 }, +]; +const trump: Trump = { Standard: { number: "2", suit: "♤" } }; +const lastTrick: Trick = { + trump, + current_winner: 2, + played_cards: [ + { id: 1, cards: ["♤A"], bad_throw_cards: [] }, + { id: 2, cards: ["♤K"], bad_throw_cards: [] }, + { id: 3, cards: ["♤Q"], bad_throw_cards: [] }, + { id: 4, cards: ["♤J"], bad_throw_cards: [] }, + ], + player_queue: [], + trick_format: null, +}; + +describe("play history controls", () => { + it("always renders two collapsed buttons and disables unavailable data", () => { + const html = renderToStaticMarkup( + Current trick} + 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/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 1923d4d4..dba1c6ed 100644 --- a/frontend/src/gen-types.d.ts +++ b/frontend/src/gen-types.d.ts @@ -1085,6 +1085,12 @@ 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[]; diff --git a/frontend/src/gen-types.schema.json b/frontend/src/gen-types.schema.json index da7e84b3..39c631fa 100644 --- a/frontend/src/gen-types.schema.json +++ b/frontend/src/gen-types.schema.json @@ -2908,6 +2908,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", 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 2e72e2d1..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; From cb8eaf1f451450ab32829a561a1f4175f5746b78 Mon Sep 17 00:00:00 2001 From: piaca24601 Date: Sun, 13 Sep 2026 22:57:27 +0800 Subject: [PATCH 5/5] Add deck cutting before the draw phase --- core/examples/simulate_play.rs | 3 + core/src/game_state/draw_phase.rs | 90 +++++++++++++++++++++++++++++ core/src/game_state/mod.rs | 6 ++ core/src/game_state/play_phase.rs | 1 + core/src/interactive.rs | 6 ++ core/src/message.rs | 4 ++ frontend/src/Draw.tsx | 93 +++++++++++++++++++++++------- frontend/src/gen-types.d.ts | 9 +++ frontend/src/gen-types.schema.json | 33 +++++++++++ 9 files changed, 223 insertions(+), 22 deletions(-) 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 2b093684..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") @@ -364,3 +419,38 @@ impl DrawPhase { 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/mod.rs b/core/src/game_state/mod.rs index ed0ebe89..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, @@ -923,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]; @@ -976,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(); @@ -1058,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, @@ -1429,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 a81c02a8..9cb68e6a 100644 --- a/core/src/game_state/play_phase.rs +++ b/core/src/game_state/play_phase.rs @@ -725,6 +725,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(p1).unwrap(); + draw.cut_deck(draw.next_player().unwrap(), 0).unwrap(); *draw.deck_mut() = vec![ S_2, diff --git a/core/src/interactive.rs b/core/src/interactive.rs index 38650f14..43074bf9 100644 --- a/core/src/interactive.rs +++ b/core/src/interactive.rs @@ -311,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()?] @@ -513,6 +518,7 @@ pub enum Action { SetCompoundFormats(CompoundFormats), SetGameVisibility(GameVisibility), StartGame, + CutDeck(usize), DrawCard, RevealCard, Bid(Card, usize), diff --git a/core/src/message.rs b/core/src/message.rs index f60b6760..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, @@ -223,6 +226,7 @@ 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 } => 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={ <>