diff --git a/Cargo.lock b/Cargo.lock index 1113c9b..1e041e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1315,6 +1315,7 @@ dependencies = [ "crossterm 0.26.1", "futures-util", "mahjong_core", + "mahjong_service", "num", "rand", "reqwest", @@ -2760,9 +2761,12 @@ name = "web_lib" version = "0.1.0" dependencies = [ "mahjong_core", + "rustc-hash", + "serde", "serde-wasm-bindgen", "serde_json", "service_contracts", + "ts-rs", "wasm-bindgen", "web-sys", ] diff --git a/README.md b/README.md index 1dd86ff..21acdd2 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ handle games which handles most of the game mechanics. - Includes E2E tests 1. A Rust cli for running simulations +You can find the project's Rust documentation [here](https://mahjong-rust.com/doc/mahjong_core). + ## Development The project main dependencies are Rust and Nodejs. There is a diff --git a/cli/Cargo.toml b/cli/Cargo.toml index c1c7a06..6f15e1e 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -9,6 +9,7 @@ crossterm = "0.26.1" futures-util = "0.3.28" mahjong_core = { path = "../mahjong_core" } service_contracts = { path = "../service_contracts" } +mahjong_service = { path = "../service" } reqwest = { version = "0.11.18", features = ["json"] } serde_json = "1.0.100" tokio = { version = "1.29.1", features = ["full"] } diff --git a/cli/src/base.rs b/cli/src/base.rs index 8e46729..69b598f 100644 --- a/cli/src/base.rs +++ b/cli/src/base.rs @@ -1,8 +1,10 @@ +use crate::print_game::PrintGameOpts; use crate::simulate::SimulateOpts; #[derive(Debug, Clone, PartialEq)] pub enum AppCommand { Simulate(SimulateOpts), + PrintGame(PrintGameOpts), } pub struct App { diff --git a/cli/src/cli.rs b/cli/src/cli.rs index f8e3905..5f652cb 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -1,19 +1,28 @@ use crate::{ base::{App, AppCommand}, + print_game::{get_print_game_command, get_print_game_opts}, simulate::{get_simulate_command, get_simulate_opts}, }; use clap::command; pub async fn parse_args(app: &mut App) { let simulate_command = get_simulate_command(); + let print_game_command = get_print_game_command(); - let matches = command!().subcommand(simulate_command).get_matches(); + let matches = command!() + .subcommand(simulate_command) + .subcommand(print_game_command) + .get_matches(); match matches.subcommand() { Some(("simulate", args_matches)) => { let opts = get_simulate_opts(args_matches); app.command = Some(AppCommand::Simulate(opts)); } + Some(("print-game", args_matches)) => { + let opts = get_print_game_opts(args_matches); + app.command = Some(AppCommand::PrintGame(opts)); + } _ => { println!("Error: no command specified"); std::process::exit(1); diff --git a/cli/src/main.rs b/cli/src/main.rs index b26bee6..c4ae4be 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,11 +1,13 @@ #![deny(clippy::use_self)] use base::{App, AppCommand}; use cli::parse_args; +use print_game::print_game; use simulate::run_simulation; mod base; mod cli; mod log; +mod print_game; mod simulate; #[tokio::main] @@ -20,5 +22,10 @@ async fn main() { AppCommand::Simulate(opts) => { run_simulation(opts).await; } + AppCommand::PrintGame(opts) => { + print_game(opts).await.unwrap_or_else(|e| { + println!("Error: {:?}", e); + }); + } } } diff --git a/cli/src/print_game.rs b/cli/src/print_game.rs new file mode 100644 index 0000000..ba42c68 --- /dev/null +++ b/cli/src/print_game.rs @@ -0,0 +1,46 @@ +use std::io::{Error, ErrorKind}; + +use clap::{Arg, Command}; +use mahjong_service::db_storage::DBStorage; + +#[derive(Debug, Clone, PartialEq)] +pub struct PrintGameOpts { + pub game_id: String, +} + +pub async fn print_game(opts: PrintGameOpts) -> Result<(), Error> { + let storage = DBStorage::new_dyn(); + + let game = storage + .get_game(&opts.game_id, false) + .await + .unwrap() + .ok_or_else(|| { + Error::new( + ErrorKind::Other, + format!("Game with ID {} not found", opts.game_id), + ) + })?; + + println!("Game:\n{}", game.game.get_summary_sorted()); + + Ok(()) +} + +pub fn get_print_game_command() -> Command { + Command::new("print-game") + .about("Print the game summary") + .arg( + Arg::new("game-id") + .short('i') + .help("The ID of the game to print"), + ) +} + +pub fn get_print_game_opts(matches: &clap::ArgMatches) -> PrintGameOpts { + let game_id: &String = matches.get_one("game-id").unwrap(); + + PrintGameOpts { + game_id: game_id.clone(), + } +} diff --git a/cli/src/simulate/mod.rs b/cli/src/simulate/mod.rs index 0f3dd7c..beb2022 100644 --- a/cli/src/simulate/mod.rs +++ b/cli/src/simulate/mod.rs @@ -1,4 +1,9 @@ -use mahjong_core::{ai::StandardAI, Game, GamePhase}; +use std::process; + +use mahjong_core::{ + ai::{PlayActionResult, StandardAI}, + Game, GamePhase, +}; use rustc_hash::FxHashSet; pub use self::simulate_cli::{get_simulate_command, get_simulate_opts, SimulateOpts}; @@ -7,11 +12,19 @@ use self::stats::Stats; mod simulate_cli; mod stats; +#[derive(Debug)] +struct HistoryItem { + game: Game, + result: PlayActionResult, +} + pub async fn run_simulation(opts: SimulateOpts) { let mut stats = Stats::new(); loop { let mut game = Game::new(None); + let mut history: Option> = + if opts.debug { Some(Vec::new()) } else { None }; for player in 0..Game::get_players_num(&game.style) { game.players.push(player.to_string()); @@ -25,13 +38,49 @@ pub async fn run_simulation(opts: SimulateOpts) { game_ai.can_draw_round = true; loop { - let result = game_ai.play_action(); - assert!( - result.changed, - "Didn't change anything in the round\n{}\n{:?}", - game_ai.game.get_summary(), - result - ); + let result = game_ai.play_action(opts.debug); + + if opts.debug { + let history_vect = history.as_mut().unwrap(); + history_vect.push(HistoryItem { + game: game_ai.game.clone(), + result: result.clone(), + }); + } + + if !result.changed { + println!("Game didn't change, breaking"); + if opts.debug { + let history = history + .as_ref() + .unwrap() + .iter() + .rev() + .take(10) + .rev() + .collect::>(); + + println!("History:"); + for (idx, history_item) in history.iter().enumerate() { + if idx > 0 { + println!("---"); + + if idx == history.len() - 1 { + break; + } + } + println!("- {:?}", history_item.result); + println!("{}", history_item.game.get_summary()); + println!("\n\n\n"); + } + } + println!( + "Current state:\n{}\n{:?}", + game_ai.game.get_summary(), + result + ); + process::exit(1); + } if game_ai.game.phase == GamePhase::End { stats.complete_game(game_ai.game); diff --git a/cli/src/simulate/simulate_cli.rs b/cli/src/simulate/simulate_cli.rs index 54eb528..9f0eb55 100644 --- a/cli/src/simulate/simulate_cli.rs +++ b/cli/src/simulate/simulate_cli.rs @@ -3,21 +3,32 @@ use clap::{Arg, ArgAction, Command}; #[derive(Debug, Clone, PartialEq)] pub struct SimulateOpts { pub once: bool, + pub debug: bool, } pub fn get_simulate_command() -> Command { - Command::new("simulate").about("Simulates games").arg( - Arg::new("once") - .short('o') - .help("Only run one simulation") - .action(ArgAction::SetTrue), - ) + Command::new("simulate") + .about("Simulates games") + .arg( + Arg::new("once") + .short('o') + .help("Only run one simulation") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("debug") + .short('d') + .help("Store debugging information to troubleshoot issues") + .action(ArgAction::SetTrue), + ) } pub fn get_simulate_opts(matches: &clap::ArgMatches) -> SimulateOpts { let once: Option<&bool> = matches.get_one("once"); + let debug: Option<&bool> = matches.get_one("debug"); SimulateOpts { once: once == Some(&true), + debug: debug == Some(&true), } } diff --git a/docs/TODO.md b/docs/TODO.md index d01ee7a..6cc19ce 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -10,6 +10,7 @@ - FE: Audio effects - FE: Perspective of tiles - FE: Display the last tile in board in different size +- FE: Display other player melds, and bonus tiles, in small size and on hover - BE: leaderboard using redis - BE: promote anonymous account to real account - BE: decouple mahjong specific logic from server to a different package @@ -20,19 +21,19 @@ - CORE: Support charleston in the drawing phase - CORE: Average rounds are too high in the simulation - CORE: Support the deciding of the dealer with dice -- CORE: Bonus tiles directions - CORE: Support three players: high effort - FS: Refactor logic to support multiple types of games (e.g. listed in wikipedia) - Move most business logic to the core (rust/ts) - FS: Support rhythym of play setting +- FS: Move more logic from web to the web_lib +- FS: Random starting position - Move other projects bash scripts to the main scripts dir -- Convert DB operations into transactions +- Convert DB operations into transactions (there is an example) - Change player names when they are AI - Full AI game - Use the game version in more endpoints - Improve scoring logic (explicitly list points sources) - Add unit tests -- Game hall state where it waits for other real players to join - Statistics for moves - Impersonate player from admin view - Record of games for each player diff --git a/flake.lock b/flake.lock index a0385f2..8b84e97 100644 --- a/flake.lock +++ b/flake.lock @@ -41,11 +41,11 @@ }, "unstable": { "locked": { - "lastModified": 1721379653, - "narHash": "sha256-8MUgifkJ7lkZs3u99UDZMB4kbOxvMEXQZ31FO3SopZ0=", + "lastModified": 1722421184, + "narHash": "sha256-/DJBI6trCeVnasdjUo9pbnodCLZcFqnVZiLUfqLH4jA=", "owner": "nixos", "repo": "nixpkgs", - "rev": "1d9c2c9b3e71b9ee663d11c5d298727dace8d374", + "rev": "9f918d616c5321ad374ae6cb5ea89c9e04bf3e58", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 5423fba..3654129 100644 --- a/flake.nix +++ b/flake.nix @@ -40,7 +40,7 @@ ) ++ ( if is-docker-ci == false && is-checks-ci == false - then [libargon2 gh entr] + then [libargon2 gh entr scc] else [] ) ++ rust.extra-shell-packages; diff --git a/mahjong_core/src/ai/mod.rs b/mahjong_core/src/ai/mod.rs index d014a23..9e30177 100644 --- a/mahjong_core/src/ai/mod.rs +++ b/mahjong_core/src/ai/mod.rs @@ -1,4 +1,4 @@ -use crate::game::{DrawTileResult, InitialDrawError}; +use crate::game::{DrawError, DrawTileResult}; use crate::meld::PossibleMeld; use crate::{Game, GamePhase, PlayerId, TileId, Wind, WINDS_ROUND_ORDER}; use rand::seq::SliceRandom; @@ -17,6 +17,7 @@ pub struct StandardAI<'a> { pub dealer_order_deterministic: Option, pub draw_tile_for_real_player: bool, pub game: &'a mut Game, + pub shuffle_players: bool, pub sort_on_draw: bool, pub sort_on_initial_draw: bool, pub with_dead_wall: bool, @@ -34,9 +35,10 @@ pub enum PlayExitLocation { CouldNotClaimTile, DecidedDealer, InitialDraw, - InitialDrawError(InitialDrawError), + InitialDrawError(DrawError), InitialShuffle, MeldCreated, + NewRoundFromMeld, NoAction, NoAutoDrawTile, RoundPassed, @@ -49,10 +51,15 @@ pub enum PlayExitLocation { WaitingPlayers, } -#[derive(Debug, Eq, PartialEq)] +// This is used for debugging unexpected "NoAction" results +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct Metadata {} + +#[derive(Debug, Eq, PartialEq, Clone)] pub struct PlayActionResult { pub changed: bool, pub exit_location: PlayExitLocation, + pub metadata: Option, } pub fn sort_by_is_mahjong(a: &PossibleMeld, b: &PossibleMeld) -> std::cmp::Ordering { @@ -79,23 +86,32 @@ impl<'a> StandardAI<'a> { dealer_order_deterministic: None, draw_tile_for_real_player: true, game, + shuffle_players: false, sort_on_draw: false, sort_on_initial_draw: false, with_dead_wall: false, } } - pub fn play_action(&mut self) -> PlayActionResult { + pub fn play_action(&mut self, with_metadata: bool) -> PlayActionResult { + let mut metadata: Option = None; + + if with_metadata { + metadata = Some(Metadata {}); + } + match self.game.phase { GamePhase::WaitingPlayers => { - return match self.game.complete_players() { + return match self.game.complete_players(self.shuffle_players) { Ok(_) => PlayActionResult { changed: true, exit_location: PlayExitLocation::CompletedPlayers, + metadata, }, Err(_) => PlayActionResult { changed: false, exit_location: PlayExitLocation::WaitingPlayers, + metadata, }, }; } @@ -105,6 +121,7 @@ impl<'a> StandardAI<'a> { return PlayActionResult { changed: true, exit_location: PlayExitLocation::InitialShuffle, + metadata, }; } GamePhase::DecidingDealer => { @@ -123,6 +140,7 @@ impl<'a> StandardAI<'a> { .unwrap(); } else if self.game.round.initial_winds.is_none() { return PlayActionResult { + metadata, changed: false, exit_location: PlayExitLocation::WaitingDealerOrder, }; @@ -132,14 +150,16 @@ impl<'a> StandardAI<'a> { return PlayActionResult { changed: true, + metadata, exit_location: PlayExitLocation::DecidedDealer, }; } GamePhase::Beginning => { - self.game.start(); + self.game.start(self.shuffle_players); return PlayActionResult { changed: true, + metadata, exit_location: PlayExitLocation::StartGame, }; } @@ -154,10 +174,12 @@ impl<'a> StandardAI<'a> { return PlayActionResult { changed: true, exit_location: PlayExitLocation::InitialDraw, + metadata, }; } Err(e) => { return PlayActionResult { + metadata, changed: false, exit_location: PlayExitLocation::InitialDrawError(e), }; @@ -167,6 +189,7 @@ impl<'a> StandardAI<'a> { return PlayActionResult { changed: false, exit_location: PlayExitLocation::AlreadyEnd, + metadata, }; } GamePhase::Playing => {} @@ -189,6 +212,7 @@ impl<'a> StandardAI<'a> { return PlayActionResult { changed: true, exit_location: PlayExitLocation::SuccessMahjong, + metadata, }; } } @@ -210,12 +234,14 @@ impl<'a> StandardAI<'a> { return PlayActionResult { changed: true, exit_location: PlayExitLocation::ClaimedTile, + metadata, }; } else { // Unexpected state return PlayActionResult { changed: false, exit_location: PlayExitLocation::CouldNotClaimTile, + metadata, }; } } @@ -226,12 +252,28 @@ impl<'a> StandardAI<'a> { continue; } - let meld_created = self.game.create_meld(&meld.player_id, &meld.tiles); + let phase_before = self.game.phase; + + let meld_created = self.game.create_meld( + &meld.player_id, + &meld.tiles, + meld.is_upgrade, + meld.is_concealed, + ); + + if phase_before == GamePhase::Playing && self.game.phase != GamePhase::Playing { + return PlayActionResult { + changed: true, + exit_location: PlayExitLocation::NewRoundFromMeld, + metadata, + }; + } if meld_created.is_ok() { return PlayActionResult { changed: true, exit_location: PlayExitLocation::MeldCreated, + metadata, }; } } @@ -256,6 +298,7 @@ impl<'a> StandardAI<'a> { return PlayActionResult { changed: true, exit_location: PlayExitLocation::AIPlayerTileDrawn, + metadata, }; } DrawTileResult::AlreadyDrawn | DrawTileResult::WallExhausted => {} @@ -272,8 +315,18 @@ impl<'a> StandardAI<'a> { .collect::>(); if !tiles_without_meld.is_empty() { - tiles_without_meld.shuffle(&mut thread_rng()); - let tile_to_discard = tiles_without_meld[0]; + let tile_to_discard = 'a: { + if let Some(tile_claimed) = self.game.round.tile_claimed.clone() { + for tile in tiles_without_meld.iter() { + if tile_claimed.id == *tile { + break 'a tile_claimed.id; + } + } + } + + tiles_without_meld.shuffle(&mut thread_rng()); + tiles_without_meld[0] + }; let discarded = self.game.discard_tile_to_board(&tile_to_discard); @@ -281,6 +334,7 @@ impl<'a> StandardAI<'a> { return PlayActionResult { changed: true, exit_location: PlayExitLocation::TileDiscarded, + metadata, }; } } @@ -310,6 +364,7 @@ impl<'a> StandardAI<'a> { return PlayActionResult { changed: false, exit_location: PlayExitLocation::AutoStoppedDrawMahjong, + metadata, }; } @@ -323,6 +378,7 @@ impl<'a> StandardAI<'a> { return PlayActionResult { changed: false, exit_location: PlayExitLocation::AutoStoppedDrawNormal, + metadata, }; } } @@ -333,6 +389,7 @@ impl<'a> StandardAI<'a> { return PlayActionResult { changed: true, exit_location: PlayExitLocation::AIPlayerTurnPassed, + metadata, }; } }; @@ -344,6 +401,7 @@ impl<'a> StandardAI<'a> { return PlayActionResult { changed: false, exit_location: PlayExitLocation::NoAutoDrawTile, + metadata, }; } @@ -360,6 +418,7 @@ impl<'a> StandardAI<'a> { return PlayActionResult { changed: true, exit_location: PlayExitLocation::TileDrawn, + metadata, }; } DrawTileResult::AlreadyDrawn | DrawTileResult::WallExhausted => {} @@ -373,6 +432,7 @@ impl<'a> StandardAI<'a> { return PlayActionResult { changed: true, exit_location: PlayExitLocation::TurnPassed, + metadata, }; } } @@ -386,6 +446,7 @@ impl<'a> StandardAI<'a> { return PlayActionResult { changed: true, exit_location: PlayExitLocation::RoundPassed, + metadata, }; } } @@ -393,6 +454,7 @@ impl<'a> StandardAI<'a> { PlayActionResult { changed: false, exit_location: PlayExitLocation::NoAction, + metadata, } } diff --git a/mahjong_core/src/game/definition.rs b/mahjong_core/src/game/definition.rs index 1a374d7..66f7f70 100644 --- a/mahjong_core/src/game/definition.rs +++ b/mahjong_core/src/game/definition.rs @@ -8,7 +8,7 @@ use ts_rs::TS; use super::Players; derive_game_common! { -#[derive(PartialEq, TS)] +#[derive(PartialEq, Eq, TS, Copy)] pub enum GamePhase { Beginning, DecidingDealer, diff --git a/mahjong_core/src/game/errors.rs b/mahjong_core/src/game/errors.rs index a3b9acc..9846ec9 100644 --- a/mahjong_core/src/game/errors.rs +++ b/mahjong_core/src/game/errors.rs @@ -11,6 +11,7 @@ pub enum DiscardTileError { #[derive(Debug, PartialEq, Eq, Clone, EnumIter)] pub enum CreateMeldError { + EndRound, NotMeld, TileIsPartOfMeld, } @@ -19,10 +20,12 @@ pub enum CreateMeldError { pub enum PassNullRoundError { HandCanDropTile, HandCanSayMahjong, + WallNotEmpty, } #[derive(Debug, PartialEq, Eq, Clone, EnumIter)] pub enum BreakMeldError { + MeldIsKong, MissingHand, TileIsExposed, } @@ -33,11 +36,11 @@ pub enum DecideDealerError { } #[derive(Debug, PartialEq, Eq, Clone, EnumIter)] -pub enum InitialDrawError { +pub enum DrawError { NotEnoughTiles, } -impl Default for InitialDrawError { +impl Default for DrawError { fn default() -> Self { Self::NotEnoughTiles } diff --git a/mahjong_core/src/game/mod.rs b/mahjong_core/src/game/mod.rs index a100842..a8f8354 100644 --- a/mahjong_core/src/game/mod.rs +++ b/mahjong_core/src/game/mod.rs @@ -2,9 +2,10 @@ pub use self::creation::GameNewOpts; pub use self::definition::{DrawTileResult, Game, GameId, GamePhase, GameStyle, GameVersion}; use self::errors::DecideDealerError; pub use self::errors::{ - BreakMeldError, CreateMeldError, DiscardTileError, InitialDrawError, PassNullRoundError, + BreakMeldError, CreateMeldError, DiscardTileError, DrawError, PassNullRoundError, }; pub use self::players::{PlayerId, Players, PlayersVec}; +use crate::hand::KongTile; use crate::table::PositionTilesOpts; use crate::{ deck::DEFAULT_DECK, @@ -63,7 +64,11 @@ impl Game { } pub fn pass_null_round(&mut self) -> Result<(), PassNullRoundError> { - if self.table.draw_wall.can_draw() || self.round.tile_claimed.is_some() { + if self.table.draw_wall.can_draw() { + return Err(PassNullRoundError::WallNotEmpty); + } + + if self.round.tile_claimed.is_some() { for hand in self.table.hands.0.values() { if hand.can_drop_tile() { return Err(PassNullRoundError::HandCanDropTile); @@ -84,14 +89,14 @@ impl Game { Ok(()) } - pub fn start(&mut self) { + pub fn start(&mut self, shuffle_players: bool) { if self.phase != GamePhase::Beginning { return; } self.phase = GamePhase::WaitingPlayers; - self.complete_players().unwrap_or_default(); + self.complete_players(shuffle_players).unwrap_or_default(); } pub fn decide_dealer(&mut self) -> Result<(), DecideDealerError> { @@ -110,6 +115,7 @@ impl Game { self.round.dealer_player_index = 0; self.round.east_player_index = 0; self.round.player_index = 0; + self.round.tile_claimed = None; self.phase = GamePhase::InitialShuffle; @@ -125,28 +131,43 @@ impl Game { self.phase = GamePhase::InitialDraw; } - pub fn initial_draw(&mut self) -> Result<(), InitialDrawError> { - let tiles_after_claim = self.style.tiles_after_claim(); + fn draw_tile_for_player(&mut self, player_id: &PlayerId) -> Result<(), DrawError> { + let player_wind = self.round.get_player_wind(&self.players.0, player_id); - for (player_id, hand) in self.table.hands.0.iter_mut() { - while hand.len() < tiles_after_claim - 1 { - let player_wind = self.round.get_player_wind(&self.players.0, player_id); - let tile_id = self.table.draw_wall.pop_for_wind(&player_wind); + loop { + let tile_id = self.table.draw_wall.pop_for_wind(&player_wind); - if tile_id.is_none() { - return Err(InitialDrawError::NotEnoughTiles); - } + if tile_id.is_none() { + return Err(DrawError::NotEnoughTiles); + } - let tile_id = tile_id.unwrap(); - let is_bonus = DEFAULT_DECK.0[tile_id].is_bonus(); + let tile_id = tile_id.unwrap(); + let is_bonus = DEFAULT_DECK.0[tile_id].is_bonus(); - if is_bonus { - let bonus_tiles = self.table.bonus_tiles.get_or_create(player_id); + if is_bonus { + let bonus_tiles = self.table.bonus_tiles.get_or_create(player_id); - bonus_tiles.push(tile_id); - } else { - hand.push(HandTile::from_id(tile_id)); + bonus_tiles.push(tile_id); + continue; + } + + let hand = self.table.hands.0.get_mut(player_id).unwrap(); + hand.push(HandTile::from_id(tile_id)); + return Ok(()); + } + } + + pub fn initial_draw(&mut self) -> Result<(), DrawError> { + let tiles_after_claim = self.style.tiles_after_claim(); + + for player_id in self.players.0.clone() { + 'loop_label: loop { + let hand = self.table.hands.0.get(&player_id).unwrap(); + if hand.len() == tiles_after_claim - 1 { + break 'loop_label; } + + self.draw_tile_for_player(&player_id)?; } } @@ -197,7 +218,9 @@ impl Game { for meld in possible_melds { melds.push(PossibleMeld { discard_tile: None, + is_concealed: meld.is_concealed, is_mahjong: meld.is_mahjong, + is_upgrade: meld.is_upgrade, player_id: player.clone(), tiles: meld.tiles, }); @@ -297,7 +320,9 @@ impl Game { melds.push(PossibleMeld { discard_tile: Some(hand_tile.id), + is_concealed: meld.is_concealed, is_mahjong: meld.is_mahjong, + is_upgrade: meld.is_upgrade, player_id: meld.player_id.clone(), tiles: meld.tiles.clone(), }); @@ -364,7 +389,15 @@ impl Game { let player_id = player_with_max_tiles.unwrap().clone(); let player_hand = self.table.hands.0.get_mut(&player_id).unwrap(); - let tile_index = player_hand.list.iter().position(|t| &t.id == tile_id); + let tiles_with_id = player_hand + .list + .iter() + .filter(|t| t.id == *tile_id) + .collect::>(); + let tile_index = player_hand + .list + .iter() + .position(|t| &t.id == tile_id && (tiles_with_id.len() == 1 || t.set_id.is_none())); if tile_index.is_none() { return Err(DiscardTileError::PlayerHasNoTile); @@ -381,20 +414,20 @@ impl Game { return Err(DiscardTileError::TileIsPartOfMeld); } - if self.round.tile_claimed.is_some() { - let tile_claimed = self.round.tile_claimed.clone().unwrap(); - if tile_claimed.by.is_some() - && tile_claimed.by.unwrap() == player_id - && tile.id != tile_claimed.id - && player_hand - .list - .iter() - .find(|t| t.id == tile_claimed.id) - .unwrap() - .set_id - .is_none() - { - return Err(DiscardTileError::ClaimedAnotherTile); + if let Some(tile_claimed) = self.round.tile_claimed.clone() { + if let Some(by) = tile_claimed.by { + if by == player_id + && tile.id != tile_claimed.id + && player_hand + .list + .iter() + .find(|t| t.id == tile_claimed.id) + .unwrap() + .set_id + .is_none() + { + return Err(DiscardTileError::ClaimedAnotherTile); + } } } @@ -415,6 +448,8 @@ impl Game { &mut self, player_id: &PlayerId, tiles: &[TileId], + is_upgrade: bool, + is_concealed: bool, ) -> Result<(), CreateMeldError> { let tiles_set = tiles.iter().cloned().collect::>(); let hand = self.table.hands.get(player_id); @@ -426,9 +461,10 @@ impl Game { .cloned() .collect::>(); - if sub_hand_tiles - .iter() - .any(|t| t.set_id.is_some() || !t.concealed) + if !is_upgrade + && sub_hand_tiles + .iter() + .any(|t| t.set_id.is_some() || !t.concealed) { return Err(CreateMeldError::TileIsPartOfMeld); } @@ -447,20 +483,65 @@ impl Game { sub_hand: &tiles_full, }; - if get_is_pung(&opts) || get_is_chow(&opts) || get_is_kong(&opts) { + let mut is_kong = false; + + if get_is_pung(&opts) || get_is_chow(&opts) || { + is_kong = get_is_kong(&opts); + is_kong + } { + if (is_upgrade && !is_kong) || (is_concealed && opts_claimed_tile.is_some()) { + return Err(CreateMeldError::NotMeld); + } + let set_id = Uuid::new_v4().to_string(); - let concealed = board_tile_player_diff.is_none(); let player_hand = self.table.hands.0.get_mut(player_id).unwrap(); + for tile in tiles.iter() { + let tile = player_hand.list.iter().find(|t| t.id == *tile); + + if tile.is_none() { + return Err(CreateMeldError::NotMeld); + } + } + player_hand .list .iter_mut() .filter(|t| tiles.contains(&t.id)) .for_each(|tile| { - tile.concealed = concealed; + tile.concealed = is_concealed; tile.set_id = Some(set_id.clone()); }); + if is_kong { + let moved_tile = player_hand + .list + .iter() + .find(|t| t.set_id == Some(set_id.clone())) + .unwrap() + .clone(); + + self.draw_tile_for_player(player_id) + .map_err(|_| match self.pass_null_round() { + Ok(_) => CreateMeldError::EndRound, + Err(_) => CreateMeldError::NotMeld, + })?; + + let next_player_hand = self.table.hands.0.get_mut(player_id).unwrap(); + + let position = next_player_hand + .list + .iter() + .position(|t| t.id == moved_tile.id) + .unwrap(); + next_player_hand.list.remove(position); + next_player_hand.kong_tiles.insert(KongTile { + set_id: set_id.clone(), + concealed: is_concealed, + id: moved_tile.id, + }); + } + return Ok(()); } @@ -480,6 +561,10 @@ impl Game { let mut hand = hand.unwrap().clone(); + if hand.kong_tiles.iter().any(|t| t.set_id == set_id.clone()) { + return Err(BreakMeldError::MeldIsKong); + } + for hand_tile in hand.list.iter_mut() { if hand_tile.set_id.is_some() && hand_tile.set_id.clone().unwrap() == *set_id { if !hand_tile.concealed { @@ -584,7 +669,7 @@ impl Game { self.round.get_player_wind(&self.players.0, ¤t_player) } - pub fn complete_players(&mut self) -> Result<(), &'static str> { + pub fn complete_players(&mut self, shuffle_players: bool) -> Result<(), &'static str> { if self.phase != GamePhase::WaitingPlayers { return Err("Game is not waiting for players"); } @@ -595,6 +680,10 @@ impl Game { return Err("Not enough players"); } + if shuffle_players { + self.players.shuffle(); + } + for player_id in self.players.0.clone() { self.table.hands.insert(player_id.clone(), Hand::default()); self.score.insert(player_id, 0); diff --git a/mahjong_core/src/game_summary.rs b/mahjong_core/src/game_summary.rs index 998068e..c0b3ba0 100644 --- a/mahjong_core/src/game_summary.rs +++ b/mahjong_core/src/game_summary.rs @@ -1,29 +1,46 @@ use crate::{ + deck::DEFAULT_DECK, game::{GameStyle, GameVersion, Players}, meld::{PlayerDiff, PossibleMeld}, table::BonusTiles, Board, Game, GameId, GamePhase, Hand, HandTile, Hands, PlayerId, Score, TileId, Wind, + WINDS_ROUND_ORDER, }; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use serde::{Deserialize, Serialize}; use ts_rs::TS; #[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] pub struct RoundSummary { consecutive_same_seats: usize, - dealer_player_index: usize, + pub dealer_player_index: usize, east_player_index: usize, pub discarded_tile: Option, pub player_index: usize, wind: Wind, } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct VisibleMeld { + set_id: String, + tiles: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct OtherPlayerHand { pub tiles: usize, pub visible: Hand, } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct HandTileStat { + in_other_melds: usize, + in_board: usize, +} + #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct OtherPlayerHands(pub FxHashMap); @@ -69,13 +86,8 @@ pub struct GameSummary { impl GameSummary { pub fn from_game(game: &Game, player_id: &PlayerId) -> Option { - let discarded_tile = if game.round.tile_claimed.is_some() { - let tile_claimed = game.round.tile_claimed.as_ref().unwrap(); - if tile_claimed.by.is_none() { - Some(tile_claimed.id) - } else { - None - } + let discarded_tile = if let Some(tile_claimed) = game.round.tile_claimed.clone() { + Some(tile_claimed.id) } else { None }; @@ -99,7 +111,7 @@ impl GameSummary { hand: game.table.hands.get(player_id), id: game.id.clone(), other_hands, - phase: game.phase.clone(), + phase: game.phase, player_id: player_id.clone(), players: game.players.clone(), round, @@ -113,15 +125,32 @@ impl GameSummary { &self.players.0[self.round.player_index] } - fn get_can_claim_tile(&self) -> bool { + pub fn get_can_claim_tile(&self) -> bool { if self.hand.is_none() { return false; } - self.hand.clone().unwrap().len() < self.style.tiles_after_claim() + let tiles_after_claim = self.style.tiles_after_claim(); + self.hand.clone().unwrap().len() < tiles_after_claim + && self + .other_hands + .0 + .iter() + .all(|(_, hand)| hand.tiles < tiles_after_claim) && self.round.discarded_tile.is_some() } + pub fn get_can_pass_turn(&self) -> bool { + self.phase == GamePhase::Playing + && self.hand.is_some() + && self.hand.as_ref().unwrap().len() == self.style.tiles_after_claim() - 1 + && self.get_current_player() == &self.player_id + } + + pub fn get_can_discard_tile(&self) -> bool { + self.hand.is_some() && self.hand.clone().unwrap().len() == self.style.tiles_after_claim() + } + pub fn get_possible_melds(&self) -> Vec { let tested_hand = self.hand.clone(); if tested_hand.is_none() { @@ -133,7 +162,7 @@ impl GameSummary { let mut possible_melds: Vec = vec![]; let can_claim_tile = self.get_can_claim_tile(); - let mut claimed_tile: Option = None; + let claimed_tile: Option = self.round.discarded_tile; let mut player_diff: PlayerDiff = None; let player_index = self .players @@ -143,15 +172,13 @@ impl GameSummary { let current_player_index = self.round.player_index; if can_claim_tile { - let tile_id = self.round.discarded_tile.unwrap(); let tile = HandTile { concealed: true, - id: tile_id, + id: claimed_tile.unwrap(), set_id: None, }; tested_hand.push(tile); - claimed_tile = Some(tile_id); player_diff = Some(match player_index as i32 - current_player_index as i32 { -3 => 1, val => val, @@ -170,7 +197,9 @@ impl GameSummary { for raw_meld in raw_melds { let possible_meld = PossibleMeld { discard_tile: None, + is_concealed: raw_meld.is_concealed, is_mahjong: raw_meld.is_mahjong, + is_upgrade: raw_meld.is_upgrade, player_id: self.player_id.clone(), tiles: raw_meld.tiles.clone(), }; @@ -180,4 +209,168 @@ impl GameSummary { possible_melds } + + pub fn get_players_winds(&self) -> FxHashMap { + let mut winds = FxHashMap::default(); + + let east_index = WINDS_ROUND_ORDER + .iter() + .position(|w| w == &Wind::East) + .unwrap(); + + for (index, player_id) in self.players.iter().enumerate() { + let wind_index = (east_index + index) % WINDS_ROUND_ORDER.len(); + let wind = WINDS_ROUND_ORDER[wind_index].clone(); + winds.insert(player_id.clone(), wind); + } + + winds + } + + pub fn get_players_visible_melds(&self) -> FxHashMap> { + let mut visible_melds_set = FxHashMap::default(); + + fn get_visible_melds(player_hand: &Hand) -> Vec { + let mut visible_melds = vec![]; + let player_melds = player_hand + .list + .iter() + .filter(|t| !t.concealed) + .filter_map(|t| t.set_id.clone()) + .collect::>(); + + for meld_id in player_melds { + let mut tiles = player_hand + .list + .iter() + .filter(|t| t.set_id.as_ref() == Some(&meld_id)) + .map(|t| t.id) + .collect::>(); + + let kong_tile = player_hand + .kong_tiles + .iter() + .find(|t| t.set_id.as_ref() == meld_id); + + if let Some(kong_tile) = kong_tile { + tiles.push(kong_tile.id); + } + + visible_melds.push(VisibleMeld { + set_id: meld_id.clone(), + tiles, + }) + } + visible_melds + } + + if self.hand.is_none() { + return visible_melds_set; + } + + visible_melds_set.insert( + self.player_id.clone(), + get_visible_melds(&self.hand.clone().unwrap()), + ); + + for (player_id, other_player_hand) in self.other_hands.0.iter() { + let hand = &other_player_hand.visible; + + visible_melds_set.insert(player_id.clone(), get_visible_melds(hand)); + } + + visible_melds_set + } + + pub fn get_can_pass_round(&self) -> bool { + let tiles_after_claim = self.style.tiles_after_claim(); + + self.phase == GamePhase::Playing + && self.hand.is_some() + && self.draw_wall_count == 0 + && self.hand.as_ref().unwrap().len() < tiles_after_claim + && self + .other_hands + .0 + .iter() + .all(|(_, hand)| hand.tiles < tiles_after_claim) + } + + pub fn get_can_draw_tile(&self) -> bool { + self.phase == GamePhase::Playing + && self.hand.is_some() + && self.hand.as_ref().unwrap().len() < self.style.tiles_after_claim() + && self.draw_wall_count > 0 + && self.get_current_player() == &self.player_id + } + + pub fn get_can_say_mahjong(&self) -> bool { + self.phase == GamePhase::Playing + && self.hand.is_some() + && self.hand.as_ref().unwrap().can_say_mahjong().is_ok() + } + + pub fn get_hand_stats(&self) -> FxHashMap { + let mut hand_stats = FxHashMap::default(); + + if self.hand.is_none() { + return hand_stats; + } + + let hand = self.hand.as_ref().unwrap(); + + let mut own_meld_tiles = hand + .list + .iter() + .filter(|t| t.set_id.is_some()) + .map(|t| t.id) + .collect::>(); + + for kong_tile in hand.kong_tiles.iter() { + own_meld_tiles.push(kong_tile.id); + } + + for hand_tile in hand.list.iter() { + if hand_tile.set_id.is_some() { + continue; + } + + let mut stat = HandTileStat { + in_other_melds: 0, + in_board: 0, + }; + + let hand_tile_full = &DEFAULT_DECK.0[hand_tile.id]; + + for own_meld_tile in own_meld_tiles.iter() { + let tile = &DEFAULT_DECK.0[*own_meld_tile]; + + if tile.is_same_content(hand_tile_full) { + stat.in_other_melds += 1; + } + } + + for (_, other_hand) in self.other_hands.0.iter() { + other_hand.visible.list.iter().for_each(|t| { + let tile = &DEFAULT_DECK.0[t.id]; + + if tile.is_same_content(hand_tile_full) { + stat.in_other_melds += 1; + } + }) + } + + for board_tile in self.board.0.iter() { + let tile = &DEFAULT_DECK.0[*board_tile]; + + if tile.is_same_content(hand_tile_full) { + stat.in_board += 1; + } + } + + hand_stats.insert(hand_tile.id, stat); + } + + hand_stats + } } diff --git a/mahjong_core/src/hand.rs b/mahjong_core/src/hand.rs index 49f88fd..5f52f50 100644 --- a/mahjong_core/src/hand.rs +++ b/mahjong_core/src/hand.rs @@ -2,7 +2,8 @@ use crate::{ deck::DEFAULT_DECK, game::GameStyle, meld::{ - get_is_chow, get_is_kong, get_is_pair, get_is_pung, PlayerDiff, PossibleMeld, SetCheckOpts, + get_is_chow, get_is_kong, get_is_pair, get_is_pung, MeldType, PlayerDiff, PossibleMeld, + SetCheckOpts, }, PlayerId, Tile, TileId, }; @@ -17,19 +18,32 @@ pub type SetId = Option; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct HandPossibleMeld { pub is_mahjong: bool, + pub is_concealed: bool, + pub is_upgrade: bool, pub tiles: Vec, } impl From for HandPossibleMeld { fn from(meld: PossibleMeld) -> Self { Self { + is_concealed: meld.is_concealed, is_mahjong: meld.is_mahjong, + is_upgrade: meld.is_upgrade, tiles: meld.tiles.clone(), } } } -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, TS)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, TS)] +#[ts(export)] +pub struct KongTile { + pub concealed: bool, + pub id: TileId, + pub set_id: SetIdContent, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, TS)] +#[ts(export)] pub struct HandTile { pub concealed: bool, pub id: TileId, @@ -53,17 +67,25 @@ impl HandTile { } } -#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize, TS)] +#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, TS)] pub struct Hand { pub list: Vec, + pub kong_tiles: FxHashSet, #[serde(skip)] pub style: Option, } -type MeldsCollection<'a> = FxHashMap>; +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export)] +pub struct HandMeld { + pub meld_type: MeldType, + pub tiles: Vec, +} -pub struct GetHandMeldsReturn<'a> { - pub melds: MeldsCollection<'a>, +#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq, TS)] +#[ts(export)] +pub struct HandMelds { + pub melds: Vec, pub tiles_without_meld: usize, } @@ -99,11 +121,16 @@ pub enum CanSayMahjongError { impl Hand { pub fn new(list: Vec) -> Self { - Self { list, style: None } + Self { + list, + style: None, + kong_tiles: FxHashSet::default(), + } } pub fn from_ref_vec(tiles: &[&HandTile]) -> Self { Self { + kong_tiles: FxHashSet::default(), list: tiles.iter().cloned().cloned().collect(), style: None, } @@ -112,6 +139,7 @@ impl Hand { pub fn from_ids(tiles: &[TileId]) -> Self { Self { list: tiles.iter().cloned().map(HandTile::from_id).collect(), + kong_tiles: FxHashSet::default(), style: None, } } @@ -167,33 +195,31 @@ impl Hand { Ok(()) } - pub fn get_melds(&self) -> GetHandMeldsReturn { - let mut melds: MeldsCollection = FxHashMap::default(); - let mut tiles_without_meld = 0; - - for hand_tile in &self.list { - if hand_tile.set_id.is_none() { - tiles_without_meld += 1; + pub fn get_melds(&self) -> HandMelds { + let mut melds = HandMelds::default(); + let sets_groups = self.get_sets_groups(); + for (set, tiles) in sets_groups.iter() { + if set.is_none() { + melds.tiles_without_meld = tiles.len(); continue; } - let set_id = hand_tile.set_id.clone().unwrap(); - let list = melds.get(&set_id); - let mut list = match list { - Some(list) => list.clone(), - None => vec![], - }; + let meld_type = MeldType::from_tiles(tiles); - list.push(hand_tile); + if meld_type.is_none() { + continue; + } - melds.insert(set_id, list); - } + let meld_type = meld_type.unwrap(); - GetHandMeldsReturn { - melds, - tiles_without_meld, + melds.melds.push(HandMeld { + meld_type, + tiles: tiles.clone(), + }); } + + melds } pub fn can_say_mahjong(&self) -> Result<(), CanSayMahjongError> { @@ -217,6 +243,38 @@ impl Hand { Ok(()) } + fn get_pungs_tiles(&self) -> Vec<(Tile, SetId)> { + let sets_ids: FxHashSet = + self.list.iter().filter_map(|t| t.set_id.clone()).collect(); + let mut pungs: Vec<(Tile, SetId)> = vec![]; + let existing_kongs = self + .kong_tiles + .iter() + .map(|t| t.set_id.clone()) + .collect::>(); + + for set_id in sets_ids { + let tiles: Vec<&Tile> = self + .list + .iter() + .filter(|t| t.set_id == Some(set_id.clone())) + .map(|t| &DEFAULT_DECK.0[t.id]) + .collect(); + + let is_pung = get_is_pung(&SetCheckOpts { + board_tile_player_diff: PlayerDiff::default(), + claimed_tile: None, + sub_hand: &tiles, + }); + + if is_pung && !existing_kongs.contains(&set_id) { + pungs.push((tiles[0].clone(), Some(set_id))); + } + } + + pungs + } + pub fn get_possible_melds( &self, board_tile_player_diff: PlayerDiff, @@ -226,6 +284,7 @@ impl Hand { let hand_filtered: Vec<&HandTile> = self.list.iter().filter(|h| h.set_id.is_none()).collect(); let mut melds: Vec = vec![]; + let existing_pungs = self.get_pungs_tiles(); if check_for_mahjong { if self.can_say_mahjong().is_ok() { @@ -236,6 +295,8 @@ impl Hand { .map(|t| t.id) .collect(); let meld = HandPossibleMeld { + is_upgrade: false, + is_concealed: false, is_mahjong: true, tiles, }; @@ -274,8 +335,12 @@ impl Hand { }; if get_is_pung(&opts) || get_is_chow(&opts) { + let is_concealed = claimed_tile.is_none(); + let meld = HandPossibleMeld { + is_concealed, is_mahjong: false, + is_upgrade: false, tiles: vec![first_tile, second_tile, third_tile], }; melds.push(meld); @@ -294,8 +359,12 @@ impl Hand { opts.sub_hand = &sub_hand_inner; if get_is_kong(&opts) { + let is_concealed = claimed_tile.is_none(); + let meld = HandPossibleMeld { + is_concealed, is_mahjong: false, + is_upgrade: false, tiles: vec![first_tile, second_tile, third_tile, forth_tile.id], }; melds.push(meld); @@ -303,6 +372,27 @@ impl Hand { } } } + + for (concealed_pung_tile, set_id) in existing_pungs.iter() { + if first_tile_full.is_same_content(concealed_pung_tile) { + let is_concealed = claimed_tile.is_none(); + let mut tiles: Vec = self + .list + .iter() + .filter(|t| t.set_id == *set_id) + .map(|t| t.id) + .collect(); + tiles.push(first_tile); + + let meld = HandPossibleMeld { + is_mahjong: false, + is_upgrade: true, + is_concealed, + tiles, + }; + melds.push(meld); + } + } } melds @@ -312,21 +402,19 @@ impl Hand { self.list.iter().any(|t| t.id == *tile_id) } - pub fn get_sets_groups(&self) -> FxHashMap> { - let mut sets: FxHashMap> = FxHashMap::default(); + pub fn get_sets_groups(&self) -> FxHashMap> { + let mut sets: FxHashMap> = FxHashMap::default(); for tile in &self.list { let set_id = tile.set_id.clone(); - let list = sets.get(&set_id); - let mut list = match list { - Some(list) => list.clone(), - None => vec![], - }; - - list.push(tile); + sets.entry(set_id.clone()).or_default().push(tile.id); + } - sets.insert(set_id, list); + for kong_tile in &self.kong_tiles { + sets.entry(Some(kong_tile.set_id.clone())) + .or_default() + .push(kong_tile.id); } sets diff --git a/mahjong_core/src/meld.rs b/mahjong_core/src/meld.rs index 2f04a63..ba26910 100644 --- a/mahjong_core/src/meld.rs +++ b/mahjong_core/src/meld.rs @@ -6,6 +6,52 @@ use ts_rs::TS; pub type PlayerDiff = Option; +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, TS)] +#[ts(export)] +pub enum MeldType { + Chow, + Kong, + Pair, + Pung, +} + +impl MeldType { + pub fn from_tiles(tiles: &[TileId]) -> Option { + if tiles.len() < 2 || tiles.len() > 4 { + return None; + } + + let tiles = tiles + .iter() + .map(|t| &DEFAULT_DECK.0[*t]) + .collect::>(); + + let opts = SetCheckOpts { + board_tile_player_diff: None, + claimed_tile: None, + sub_hand: &tiles, + }; + + if get_is_pung(&opts) { + return Some(Self::Pung); + } + + if get_is_chow(&opts) { + return Some(Self::Chow); + } + + if get_is_kong(&opts) { + return Some(Self::Kong); + } + + if get_is_pair(opts.sub_hand) { + return Some(Self::Pair); + } + + None + } +} + #[derive(Debug, Clone)] pub struct SetCheckOpts<'a> { pub board_tile_player_diff: PlayerDiff, @@ -171,11 +217,13 @@ pub fn get_is_pair(hand: &[&Tile]) -> bool { hand[0].is_same_content(hand[1]) } -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, TS)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, TS)] #[ts(export)] pub struct PossibleMeld { pub discard_tile: Option, + pub is_concealed: bool, pub is_mahjong: bool, + pub is_upgrade: bool, pub player_id: PlayerId, pub tiles: Vec, } diff --git a/mahjong_core/src/score.rs b/mahjong_core/src/score.rs index 5617fad..ee73fcf 100644 --- a/mahjong_core/src/score.rs +++ b/mahjong_core/src/score.rs @@ -1,8 +1,13 @@ // http://mahjongtime.com/hong-kong-mahjong-scoring.html +// https://en.wikipedia.org/wiki/Hong_Kong_mahjong_scoring_rules -use crate::{deck::DEFAULT_DECK, Flower, Game, PlayerId, Season, Tile}; +use crate::{ + deck::DEFAULT_DECK, meld::MeldType, Flower, Game, PlayerId, Season, Tile, FLOWERS_ORDER, + SEASONS_ORDER, WINDS_ROUND_ORDER, +}; use rustc_hash::{FxHashMap, FxHashSet}; use serde::{Deserialize, Serialize}; +use strum_macros::EnumIter; use ts_rs::TS; pub type ScoreItem = u32; @@ -42,13 +47,18 @@ impl Score { } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, EnumIter)] pub enum ScoringRule { AllFlowers, + AllInTriplets, AllSeasons, - BasePoint, // This is a custome rule until all other rules are implemented + BasePoint, // This is a custom rule until all other rules are implemented + CommonHand, + GreatDragons, LastWallTile, NoFlowersSeasons, + SeatFlower, + SeatSeason, SelfDraw, } @@ -79,10 +89,15 @@ impl Game { for rule in scoring_rules { round_points += match rule { ScoringRule::AllFlowers => 2, + ScoringRule::AllInTriplets => 3, ScoringRule::AllSeasons => 2, ScoringRule::BasePoint => 1, + ScoringRule::CommonHand => 1, + ScoringRule::GreatDragons => 8, ScoringRule::LastWallTile => 1, ScoringRule::NoFlowersSeasons => 1, + ScoringRule::SeatFlower => 1, + ScoringRule::SeatSeason => 1, ScoringRule::SelfDraw => 1, } } @@ -94,6 +109,14 @@ impl Game { let mut rules = Vec::new(); rules.push(ScoringRule::BasePoint); let empty_bonus = vec![]; + let winner_hand = self.table.hands.0.get(winner_player).unwrap(); + let winner_melds = winner_hand.get_melds(); + let melds_without_pair = winner_melds + .melds + .iter() + .filter(|meld| meld.meld_type != MeldType::Pair) + .collect::>(); + let winner_bonus = self .table .bonus_tiles @@ -101,6 +124,37 @@ impl Game { .get(winner_player) .unwrap_or(&empty_bonus); + if melds_without_pair + .iter() + .all(|meld| meld.meld_type == MeldType::Chow) + { + rules.push(ScoringRule::CommonHand); + } + + if melds_without_pair + .iter() + .all(|meld| meld.meld_type == MeldType::Pung || meld.meld_type == MeldType::Kong) + { + rules.push(ScoringRule::AllInTriplets); + } + + if melds_without_pair + .iter() + .filter(|meld| { + if meld.meld_type == MeldType::Chow { + return false; + } + + let tile = &DEFAULT_DECK.0[meld.tiles[0]]; + + matches!(tile, Tile::Dragon(_)) + }) + .count() + == 3 + { + rules.push(ScoringRule::GreatDragons); + } + if self.table.draw_wall.is_empty() { rules.push(ScoringRule::LastWallTile); } @@ -135,6 +189,24 @@ impl Game { if seasons.len() == 4 { rules.push(ScoringRule::AllSeasons); } + + let player_wind = self.round.get_player_wind(&self.players.0, winner_player); + let has_seat_flower = flowers.iter().any(|flower| { + let flower_index = FLOWERS_ORDER.iter().position(|f| f == flower).unwrap(); + WINDS_ROUND_ORDER[flower_index] == player_wind + }); + let has_seat_season = seasons.iter().any(|season| { + let season_index = SEASONS_ORDER.iter().position(|s| s == season).unwrap(); + WINDS_ROUND_ORDER[season_index] == player_wind + }); + + if has_seat_flower { + rules.push(ScoringRule::SeatFlower); + } + + if has_seat_season { + rules.push(ScoringRule::SeatSeason); + } } rules diff --git a/mahjong_core/src/summary_view.rs b/mahjong_core/src/summary_view.rs index 9cf22bd..a67d30e 100644 --- a/mahjong_core/src/summary_view.rs +++ b/mahjong_core/src/summary_view.rs @@ -1,11 +1,15 @@ -use std::str::FromStr; +use std::{ + fmt::{Display, Formatter}, + str::FromStr, +}; use uuid::Uuid; use crate::{ deck::DEFAULT_DECK, - hand::HandPossibleMeld, + hand::{HandPossibleMeld, KongTile, SetIdContent}, round::{Round, RoundTileClaimed}, + score::ScoringRule, table::{BonusTiles, PositionTilesOpts}, Board, Deck, Dragon, DragonTile, DrawWall, Flower, FlowerTile, Game, GamePhase, Hand, HandTile, Hands, Season, SeasonTile, Suit, SuitTile, Tile, TileId, Wind, WindTile, @@ -110,9 +114,12 @@ impl Game { result.push_str("\nWall:"); let player_wind = self.get_player_wind(); if self.table.draw_wall.len() > 3 { - result.push_str(" ... "); + result.push_str(" ..."); } - result.push_str(&self.table.draw_wall.summary_next(&player_wind)); + result.push_str(&format!( + " {}", + self.table.draw_wall.summary_next(&player_wind) + )); } if !self.table.board.0.is_empty() { @@ -151,7 +158,11 @@ impl Game { result.push_str(&print_game_tile(&DEFAULT_DECK.0[tile.id])); if let Some(by) = tile.by { result.push_str("(P"); - result.push_str(&(by.parse::().unwrap() + 1).to_string()); + let player_id = match by.parse::() { + Ok(player_num) => (player_num + 1).to_string(), + Err(_) => by.clone(), + }; + result.push_str(&player_id); result.push(')'); } } @@ -163,6 +174,16 @@ impl Game { result.trim().to_string() } + pub fn get_summary_sorted(&self) -> String { + let mut game = self.clone(); + + for hand in game.table.hands.0.values_mut() { + hand.sort_default(); + } + + game.get_summary() + } + pub fn from_summary(summary: &str) -> Self { let mut game = Self::new(None); let mut lines = summary.trim().lines(); @@ -201,21 +222,19 @@ impl Game { .bonus_tiles .set_from_summary(player_id, &line[5..]); - line = lines.next().unwrap().trim(); + line = lines.next().unwrap_or("").trim(); } - println!("line {:?}", line); - let mut wall_line: Option = None; if let Some(w) = line.strip_prefix("Wall:") { wall_line = Some(w.to_string()); - line = lines.next().unwrap(); + line = lines.next().unwrap_or(""); } else { game.table.draw_wall.clear(); } - if line.starts_with("Board: ") { - let board_line = line[7..].replace("...", ""); + if let Some(board_line) = line.trim().strip_prefix("Board: ") { + let board_line = board_line.replace("...", ""); game.table.board.push_by_summary(&board_line); line = lines.next().unwrap_or(""); } @@ -234,7 +253,6 @@ impl Game { game.round.wind = Wind::from_str(wind.trim()).unwrap(); } else if let Some(phase) = fragment.strip_prefix("Phase: ") { game.phase = GamePhase::from_str(phase.trim()).unwrap(); - println!("game.phase {:?}", game.phase); } else if let Some(winds_str) = fragment.strip_prefix("Initial Winds: ") { let mut winds: [Wind; 4] = [Wind::East, Wind::South, Wind::West, Wind::North]; winds_str.split(',').enumerate().for_each(|(i, w)| { @@ -260,17 +278,20 @@ impl Game { let (from, by) = if tile.contains('(') { let mut parts = tile.split('('); let from = parts.next().unwrap().trim(); - let by = parts + let by_str = parts .next() .unwrap() .trim() .strip_prefix('P') .unwrap() .strip_suffix(')') - .unwrap() - .parse::() - .unwrap() - - 1; + .unwrap(); + + let by = match by_str.parse::() { + Ok(player_num) => (player_num - 1).to_string(), + Err(_) => by_str.to_string(), + }; + (from, Some(by.to_string())) } else { (tile, None) @@ -308,12 +329,9 @@ impl Game { pub fn get_meld_id_from_summary(&self, player_id: &str, summary: &str) -> String { let tile_id = Tile::from_summary(summary).get_id(); - self.table - .hands - .0 - .get(player_id) - .unwrap() - .list + let hand = self.table.hands.0.get(player_id).unwrap(); + + hand.list .iter() .find(|hand_tile| hand_tile.id == tile_id) .unwrap() @@ -413,6 +431,8 @@ impl HandPossibleMeld { match summary_parts.len() { 2 => Self { is_mahjong: summary_parts[1] == "YES", + is_upgrade: false, + is_concealed: false, tiles: Hand::from_summary(summary_parts[0]) .list .iter() @@ -448,12 +468,16 @@ impl HandTile { impl Hand { pub fn from_summary(summary: &str) -> Self { - Self::new( + let mut hand = Self::new( summary .split(' ') .filter(|tile_set| !tile_set.is_empty()) .enumerate() .flat_map(|(idx, tile_set)| { + if tile_set == "_" { + return vec![]; + } + let set_id = if idx == 0 { None } else { @@ -482,34 +506,81 @@ impl Hand { .collect::>() }) .collect(), - ) + ); + + let kong_sets = hand + .get_sets_groups() + .into_iter() + .filter(|(set_id, tiles)| set_id.is_some() && tiles.len() == 4) + .map(|(set_id, _)| set_id.clone().unwrap()) + .collect::>(); + + for set_id in kong_sets { + let first_tile = hand + .list + .iter() + .find(|tile| tile.set_id == Some(set_id.clone())) + .unwrap() + .clone(); + + let position = hand + .list + .iter() + .position(|tile| tile.id == first_tile.id) + .unwrap(); + hand.list.remove(position); + hand.kong_tiles.insert(KongTile { + concealed: first_tile.concealed, + id: first_tile.id, + set_id: set_id.clone(), + }); + } + + hand } pub fn to_summary(&self) -> String { - let mut sets_parsed = self + let sets_parsed = self .list .iter() .map(|tile| print_game_tile(DEFAULT_DECK.get_sure(tile.id))) .collect::>(); - sets_parsed.sort(); sets_parsed.join(",") } pub fn to_summary_full(&self) -> String { let mut result = String::new(); - let sets_groups = self.get_sets_groups(); + let mut hand_clone = self.clone(); + for kong_tile in hand_clone.kong_tiles.iter() { + hand_clone.list.push(HandTile { + concealed: kong_tile.concealed, + id: kong_tile.id, + set_id: Some(kong_tile.set_id.clone()), + }); + } + let sets_groups = hand_clone.get_sets_groups(); if let Some(tiles) = sets_groups.get(&None) { - result.push_str(&Self::from_ref_vec(tiles).to_summary()); + let hand_tiles = hand_clone + .list + .iter() + .filter(|tile| tiles.contains(&tile.id)) + .collect::>(); + result.push_str(&Self::from_ref_vec(&hand_tiles).to_summary()); } for (_, tiles) in sets_groups.iter().filter(|(set_id, _)| set_id.is_some()) { + let hand_tiles = hand_clone + .list + .iter() + .filter(|tile| tiles.contains(&tile.id)) + .collect::>(); result.push(' '); - if tiles.len() > 1 && !tiles[0].concealed { + if tiles.len() > 1 && !hand_tiles[0].concealed { result.push('*'); } - result.push_str(&Self::from_ref_vec(tiles).to_summary()); + result.push_str(&Self::from_ref_vec(&hand_tiles).to_summary()); } result @@ -577,6 +648,7 @@ impl BonusTiles { self.0.insert( player_id.to_string(), summary + .replace('_', "") .trim() .replace(' ', ",") .replace('*', "") @@ -596,3 +668,9 @@ impl Round { game.round } } + +impl Display for ScoringRule { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} diff --git a/mahjong_core/src/tests/test_ai/play_action.rs b/mahjong_core/src/tests/test_ai/play_action.rs index 5937d33..8416566 100644 --- a/mahjong_core/src/tests/test_ai/play_action.rs +++ b/mahjong_core/src/tests/test_ai/play_action.rs @@ -2,7 +2,7 @@ mod test { use crate::{ ai::{PlayExitLocation, StandardAI}, - game::InitialDrawError, + game::DrawError, Game, }; use pretty_assertions::assert_eq; @@ -22,6 +22,7 @@ mod test { "- P2: 一萬,一萬,一萬,三萬,五萬,七萬,九萬,一筒,三筒,五筒,七筒,九筒,一索,三索 Turn: P2, Phase: Playing" } + PlayExitLocation::NewRoundFromMeld => "", PlayExitLocation::NoAutoDrawTile => "", PlayExitLocation::AutoStoppedDrawNormal => "", PlayExitLocation::NoAction => "", @@ -84,7 +85,7 @@ mod test { } PlayExitLocation::InitialShuffle => "Phase: Initial Shuffle", PlayExitLocation::InitialDrawError(e) => match e { - InitialDrawError::NotEnoughTiles => "Phase: Initial Draw", + DrawError::NotEnoughTiles => "Phase: Initial Draw", }, PlayExitLocation::WaitingDealerOrder => "Phase: Deciding Dealer", PlayExitLocation::CompletedPlayers => "Phase: Waiting Players", @@ -110,8 +111,7 @@ mod test { game_ai.can_draw_round = true; - println!("game_ai {}", game_ai.game.get_summary()); - let actual = game_ai.play_action(); + let actual = game_ai.play_action(false); assert_eq!( actual.exit_location, exit_location, diff --git a/mahjong_core/src/tests/test_ai/sort_by.rs b/mahjong_core/src/tests/test_ai/sort_by.rs index c083d92..b5fbb5e 100644 --- a/mahjong_core/src/tests/test_ai/sort_by.rs +++ b/mahjong_core/src/tests/test_ai/sort_by.rs @@ -6,10 +6,12 @@ mod test { #[test] fn it_puts_melds_with_mahjong_at_the_beginning() { let default_meld = PossibleMeld { + discard_tile: None, + is_concealed: false, is_mahjong: false, + is_upgrade: false, player_id: "0".to_string(), tiles: vec![], - discard_tile: None, }; let mut melds = [ PossibleMeld { diff --git a/mahjong_core/src/tests/test_game/discards.rs b/mahjong_core/src/tests/test_game/discards.rs index 8e6cd29..b9c7d42 100644 --- a/mahjong_core/src/tests/test_game/discards.rs +++ b/mahjong_core/src/tests/test_game/discards.rs @@ -21,7 +21,7 @@ Board: 一筒,二筒,三筒 assert_eq!( game.get_summary(), r#" -- P1: 一索,一萬,七萬,三索,三萬,九萬,二索,五索,五萬,八萬,六萬,四索,四萬 +- P1: 一萬,三萬,四萬,五萬,六萬,七萬,八萬,九萬,一索,二索,三索,四索,五索 - P2: 八筒 - P3: 九筒 Board: 二萬,三筒... @@ -38,14 +38,14 @@ Consecutive: 0, Discarded: 二萬 for error in DiscardTileError::iter() { let (summary, tile_summary) = match error { DiscardTileError::TileIsPartOfMeld => ( - "- P1: 一萬,三萬,四萬,五萬,六萬,七萬,八萬,九萬,一索,二索,三索 二萬,二萬,二萬 + "- P1: 一萬,三萬,四萬,五萬,六萬,七萬,八萬,九萬,一索,二索,三索 一筒,二筒,三筒 Turn: P1", - "二萬", + "二筒", ), DiscardTileError::TileIsExposed => ( - "- P1: 一萬,三萬,四萬,五萬,六萬,七萬,八萬,九萬,一索,二索,三索 *二萬,二萬,二萬 + "- P1: 一萬,三萬,四萬,五萬,六萬,七萬,八萬,九萬,一索,二索,三索 *一筒,二筒,三筒 Turn: P1", - "二萬", + "二筒", ), DiscardTileError::PlayerHasNoTile => ( "- P1: 一萬,三萬,四萬,五萬,六萬,七萬,八萬,九萬,一索,二索,三索,三索,三索,三索 diff --git a/mahjong_core/src/tests/test_game/operations.rs b/mahjong_core/src/tests/test_game/operations.rs index 063a60d..2e988e7 100644 --- a/mahjong_core/src/tests/test_game/operations.rs +++ b/mahjong_core/src/tests/test_game/operations.rs @@ -11,6 +11,11 @@ mod test { fn test_break_meld() { for error in BreakMeldError::iter() { let (summary, player_id) = match error { + BreakMeldError::MeldIsKong => ( + "- P1: 一萬,三萬 四萬,五萬,六萬 七萬,八萬,九萬 一索,二索,三索 二萬,二萬,二萬,二萬 + Turn: P1", + "0", + ), BreakMeldError::MissingHand => ( "- P1: 一萬,三萬 四萬,五萬,六萬 七萬,八萬,九萬 一索,二索,三索 二萬,二萬,二萬 Turn: P1", @@ -47,6 +52,7 @@ mod test { fn test_create_meld() { for error in CreateMeldError::iter() { let (summary, tiles_summary) = match error { + CreateMeldError::EndRound => ("", ""), CreateMeldError::NotMeld => ( "- P1: 一萬,三萬,四萬,五萬,六萬,七萬,八萬,九萬,一索,二索,三索,二萬,二萬,二萬 Turn: P1", @@ -59,10 +65,14 @@ mod test { ), }; + if summary.is_empty() { + continue; + } + let mut game = Game::from_summary(summary); let tiles = Tile::ids_from_summary(tiles_summary); - let result = game.create_meld(&"0".to_string(), &tiles); + let result = game.create_meld(&"0".to_string(), &tiles, false, false); assert_eq!(result, Err(error.clone()), "Test case: {:?}", error); } @@ -72,8 +82,12 @@ mod test { Turn: P1", ); - let result = - game_correct.create_meld(&"0".to_string(), &Tile::ids_from_summary("一索,二索,三索")); + let result = game_correct.create_meld( + &"0".to_string(), + &Tile::ids_from_summary("一索,二索,三索"), + false, + false, + ); assert_eq!(result, Ok(()), "Test case correct"); } diff --git a/mahjong_core/src/tests/test_game/parsing.rs b/mahjong_core/src/tests/test_game/parsing.rs index 803c7d7..c67585e 100644 --- a/mahjong_core/src/tests/test_game/parsing.rs +++ b/mahjong_core/src/tests/test_game/parsing.rs @@ -141,10 +141,12 @@ mod test { }, "hands": { "3": { - "list": [] + "list": [], + "kong_tiles": [] }, "2": { - "list": [] + "list": [], + "kong_tiles": [] }, "0": { "list": [ @@ -153,10 +155,12 @@ mod test { "id": 17, "set_id": null } - ] + ], + "kong_tiles": [] }, "1": { - "list": [] + "list": [], + "kong_tiles": [] } }, "bonus_tiles": { diff --git a/mahjong_core/src/tests/test_meld.rs b/mahjong_core/src/tests/test_meld.rs index 838e655..0432822 100644 --- a/mahjong_core/src/tests/test_meld.rs +++ b/mahjong_core/src/tests/test_meld.rs @@ -28,12 +28,12 @@ mod test { ]; const POSSIBLE_MELDS_FIXTURES: &[(&str, PlayerDiff, &[&str])] = &[ - ("二筒,一筒,三筒", Some(0), &["一筒,三筒,二筒 NO"]), + ("二筒,一筒,三筒", Some(0), &["二筒,一筒,三筒 NO"]), ("一筒,二筒,四筒", None, &[]), ( "三筒,一筒,二筒,五筒,五筒,五筒", None, - &["一筒,三筒,二筒 NO", "五筒,五筒,五筒 NO"], + &["三筒,一筒,二筒 NO", "五筒,五筒,五筒 NO"], ), ]; diff --git a/mahjong_core/src/tests/test_score.rs b/mahjong_core/src/tests/test_score.rs index 67c59f3..61dfed4 100644 --- a/mahjong_core/src/tests/test_score.rs +++ b/mahjong_core/src/tests/test_score.rs @@ -1,35 +1,88 @@ #[cfg(test)] mod test { use crate::{score::ScoringRule, Game}; + use strum::IntoEnumIterator; - fn test_contains(hand: &str, bonus_tiles: &str, expected: ScoringRule) { - let mut game = Game::new(None); - game.start_with_players(); + #[test] + fn test_all_rules_has() { + for score_rule in ScoringRule::iter() { + let base_hand = + "_ 一萬,二萬,三萬 四萬,五萬,六萬 七萬,八萬,九萬, 一筒,二筒,三筒 四筒,四筒"; + let game_summary = match score_rule { + ScoringRule::AllFlowers => { + format!("- P1: {base_hand} 竹,菊,蘭,梅") + } + ScoringRule::AllSeasons => format!("- P1: {base_hand} 冬,春,秋,夏"), + ScoringRule::AllInTriplets => { + "- P1: _ 一萬,一萬,一萬 三筒,三筒,三筒 四筒,四筒,四筒 四萬,四萬,四萬 四筒,四筒" + .to_string() + } + ScoringRule::GreatDragons => { + "- P1: _ 白,白,白 發,發,發 中,中,中 一萬,二萬,三萬 四筒,四筒".to_string() + } + ScoringRule::BasePoint => format!("- P1: {base_hand} 四筒,四筒"), + ScoringRule::LastWallTile => format!("- P1: {base_hand}"), + ScoringRule::NoFlowersSeasons => format!("- P1: {base_hand}"), + ScoringRule::SeatFlower => format!("- P1: {base_hand} 竹,菊,蘭,梅"), + ScoringRule::SeatSeason => format!("- P1: {base_hand} 冬,春,秋,夏"), + ScoringRule::SelfDraw => String::new(), + ScoringRule::CommonHand => format!("- P1: {base_hand}"), + }; + + if game_summary.is_empty() { + continue; + } - game.table.hands.update_players_hands(&[hand, "", "", ""]); - game.table.bonus_tiles.set_from_summary("0", bonus_tiles); + let mut game = Game::from_summary(&game_summary); + game.score.insert("0", 0); - let (scoring_rules, _) = game.calculate_hand_score(&"0".to_string()); + let (scoring_rules, _) = game.calculate_hand_score(&"0".to_string()); - assert!(scoring_rules.contains(&expected)); + assert!(scoring_rules.contains(&score_rule)); + } } - fn test_not_contains(hand: &str, expected: ScoringRule) { - let mut game = Game::new(None); - game.start_with_players(); + #[test] + fn test_all_rules_has_not() { + for score_rule in ScoringRule::iter() { + let base_hand = + "_ 一萬,二萬,三萬 四萬,五萬,六萬 七萬,八萬,九萬, 一筒,二筒,三筒 四筒,四筒"; + let game_summary = match score_rule { + ScoringRule::CommonHand => { + "- P1: _ 一萬,一萬,一萬 四萬,五萬,六萬 七萬,八萬,九萬 一筒,二筒,三筒 四筒,四筒" + .to_string() + } + ScoringRule::GreatDragons => { + format!("- P1: {base_hand}") + } + ScoringRule::AllInTriplets => { + format!("- P1: {base_hand}") + } + ScoringRule::AllFlowers => { + format!("- P1: {base_hand} 竹,菊,蘭") + } + ScoringRule::AllSeasons => format!("- P1: {base_hand} 冬,春,秋"), + ScoringRule::BasePoint => String::new(), + ScoringRule::LastWallTile => format!( + "- P1: {base_hand} + Wall: 一萬" + ), + ScoringRule::NoFlowersSeasons => format!("- P1: {base_hand} 春"), + ScoringRule::SeatFlower => format!("- P1: {base_hand} 竹"), + ScoringRule::SeatSeason => format!("- P1: {base_hand} 冬"), + ScoringRule::SelfDraw => String::new(), + }; - game.table.hands.update_players_hands(&[hand, "", "", ""]); + if game_summary.is_empty() { + continue; + } - let (scoring_rules, _) = game.calculate_hand_score(&"0".to_string()); + let mut game = Game::from_summary(&game_summary); + game.score.insert("0", 0); - assert!(!scoring_rules.contains(&expected)); - } + let (scoring_rules, _) = game.calculate_hand_score(&"0".to_string()); - #[test] - fn test_common_rules() { - test_contains("", "竹,菊,蘭,梅", ScoringRule::AllFlowers); - test_not_contains("菊,蘭,梅", ScoringRule::AllFlowers); - test_contains("", "春,夏,秋,冬", ScoringRule::AllSeasons); - test_not_contains("夏,秋,冬", ScoringRule::AllSeasons); + assert!(!scoring_rules.contains(&score_rule), "Rule: {}", score_rule); + } } } diff --git a/mahjong_core/src/tests/utils.rs b/mahjong_core/src/tests/utils.rs index ef965c2..7668b79 100644 --- a/mahjong_core/src/tests/utils.rs +++ b/mahjong_core/src/tests/utils.rs @@ -7,6 +7,6 @@ impl Game { self.players.push("1".to_string()); self.players.push("2".to_string()); self.players.push("3".to_string()); - self.start(); + self.start(false); } } diff --git a/scripts/src/check.sh b/scripts/src/check.sh index 5410b24..b2cb001 100755 --- a/scripts/src/check.sh +++ b/scripts/src/check.sh @@ -19,6 +19,8 @@ run_check() { run_clippy + cargo doc --release --no-deps + (cd service && sqlfluff lint --dialect postgres migrations/**/*.sql) run_pack_wasm @@ -33,3 +35,23 @@ run_check() { echo "All checks passed" } + +count_lines() { + scc \ + service/src \ + service/migrations \ + service_contracts/src \ + cli/src \ + web_client/src \ + web_lib/src \ + scripts/src \ + mahjong_core/src +} + +run_test() { + RESULT=$(cargo test --all-targets || echo "error") + run_fix >/dev/null 2>&1 + if [[ "$RESULT" = "error" ]]; then + exit 1 + fi +} diff --git a/scripts/src/docker.sh b/scripts/src/docker.sh index 1ae6cd0..f8af11b 100755 --- a/scripts/src/docker.sh +++ b/scripts/src/docker.sh @@ -4,9 +4,11 @@ DOCKER_IMAGE_TAG=$(uname -m) run_docker() ( web() { + cargo doc --release --no-deps run_pack_wasm (cd web_client && bun install && bun run build) + mv target/doc web_client/out/doc } docker_service() { diff --git a/scripts/src/main.sh b/scripts/src/main.sh index c1f821b..2cb5286 100755 --- a/scripts/src/main.sh +++ b/scripts/src/main.sh @@ -30,6 +30,8 @@ Run various scripts for the Mahjong project - list: List root files to be used in a pipe - pack_wasm: Pack the wasm files - profile_instruments: Create a trace file to be inspected by Instruments + - count_lines: Count the lines of code + - test: Runs tests plus formatting - tests_summaries_fix: Convert the tests summaries to chinese chars" # This is specially convenient for maintaining the clippy rules, which need to @@ -85,6 +87,12 @@ main() { service_watch) service_watch ;; + test) + run_test + ;; + count_lines) + count_lines + ;; *) echo "$USAGE" exit 1 diff --git a/service/Cargo.toml b/service/Cargo.toml index cf3a316..a47e26d 100644 --- a/service/Cargo.toml +++ b/service/Cargo.toml @@ -29,3 +29,11 @@ redis = "0.23.3" diesel_migrations = "2.1.0" ts-rs = "9.0.1" env_logger = "0.10.0" + +[lib] +name = "mahjong_service" +path = "src/lib.rs" + +[[bin]] +name = "mahjong_service" +path = "src/bin.rs" diff --git a/service/migrations/006_create_game_hand/up.sql b/service/migrations/006_create_game_hand/up.sql index 8b021ce..d5e3cad 100644 --- a/service/migrations/006_create_game_hand/up.sql +++ b/service/migrations/006_create_game_hand/up.sql @@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS game_hand ( set_id TEXT NULL, tile_id INT NOT NULL, tile_index INT NOT NULL, + is_kong BOOLEAN NOT NULL, PRIMARY KEY (game_id, tile_id) ); diff --git a/service/src/ai_wrapper.rs b/service/src/ai_wrapper.rs index 898df14..a422277 100644 --- a/service/src/ai_wrapper.rs +++ b/service/src/ai_wrapper.rs @@ -28,6 +28,7 @@ impl<'a> AIWrapper<'a> { standard_ai.dealer_order_deterministic = Some(false); standard_ai.with_dead_wall = service_game.settings.dead_wall; + standard_ai.shuffle_players = true; Self { standard_ai, @@ -58,7 +59,7 @@ impl<'a> AIWrapper<'a> { .contains(¤t_player); } - let result = self.standard_ai.play_action(); + let result = self.standard_ai.play_action(false); if result.changed && result.exit_location == PlayExitLocation::TileDiscarded { self.game_settings.last_discard_time = now_time; diff --git a/service/src/main.rs b/service/src/bin.rs similarity index 96% rename from service/src/main.rs rename to service/src/bin.rs index 2639220..9ebc89c 100644 --- a/service/src/main.rs +++ b/service/src/bin.rs @@ -4,11 +4,10 @@ use auth::AuthHandler; use db_storage::DBStorage; use dotenv::dotenv; use http_server::MahjongServer; +use logs::setup_logs; use std::process; use tracing::{error, info}; -use crate::logs::setup_logs; - mod ai_wrapper; mod auth; mod common; diff --git a/service/src/db_storage.rs b/service/src/db_storage.rs index a32f5ff..3131e46 100644 --- a/service/src/db_storage.rs +++ b/service/src/db_storage.rs @@ -269,7 +269,7 @@ impl DBStorage { pub fn new_dyn() -> Box { let db_path = std::env::var(ENV_PG_URL) .unwrap_or("postgres://postgres:postgres@localhost/mahjong".to_string()); - let redis_path = std::env::var(ENV_REDIS_URL).unwrap(); + let redis_path = std::env::var(ENV_REDIS_URL).unwrap_or("redis://localhost".to_string()); debug!("DBStorage: {} {}", db_path, redis_path); diff --git a/service/src/db_storage/models.rs b/service/src/db_storage/models.rs index 34607ff..084a738 100644 --- a/service/src/db_storage/models.rs +++ b/service/src/db_storage/models.rs @@ -125,6 +125,7 @@ pub struct DieselGameHand { pub set_id: Option, pub tile_id: i32, pub tile_index: i32, + pub is_kong: bool, } #[derive(Insertable, AsChangeset, Queryable, Clone)] diff --git a/service/src/db_storage/models_translation.rs b/service/src/db_storage/models_translation.rs index 060ed99..80cfd28 100644 --- a/service/src/db_storage/models_translation.rs +++ b/service/src/db_storage/models_translation.rs @@ -8,6 +8,7 @@ use crate::auth::{AuthInfo, AuthInfoAnonymous, AuthInfoData, AuthInfoEmail, Auth use diesel::prelude::*; use diesel::PgConnection; use mahjong_core::deck::DEFAULT_DECK; +use mahjong_core::hand::KongTile; use mahjong_core::{ game::GameStyle, round::{Round, RoundTileClaimed}, @@ -650,15 +651,17 @@ impl DieselGamePlayer { use schema::game_player::table as game_player_table; db_request(|| { - diesel::delete(game_player_table) - .filter(schema::game_player::dsl::game_id.eq(&game.id)) - .execute(connection) - }); + connection.transaction(|t_connection| { + diesel::delete(game_player_table) + .filter(schema::game_player::dsl::game_id.eq(&game.id)) + .execute(t_connection)?; - db_request(|| { - diesel::insert_into(game_player_table) - .values(diesel_game_players) - .execute(connection) + diesel::insert_into(game_player_table) + .values(diesel_game_players) + .execute(t_connection)?; + + diesel::result::QueryResult::Ok(()) + }) }); } @@ -675,38 +678,44 @@ impl DieselGameScore { pub fn update_from_game(connection: &mut PgConnection, service_game: &ServiceGame) { use schema::game_score::table as game_score_table; - loop { - if diesel::delete(game_score_table) - .filter(schema::game_score::dsl::game_id.eq(&service_game.game.id)) - .execute(connection) - .is_ok() - { - break; - } - wait_common(); - } + db_request(|| { + connection.transaction(|t_connection| { + loop { + if diesel::delete(game_score_table) + .filter(schema::game_score::dsl::game_id.eq(&service_game.game.id)) + .execute(t_connection) + .is_ok() + { + break; + } + wait_common(); + } - let scores = service_game - .game - .score - .iter() - .map(|(player_id, score)| Self { - game_id: service_game.game.id.clone(), - player_id: player_id.clone(), - score: *score as i32, - }) - .collect::>(); + let scores = service_game + .game + .score + .iter() + .map(|(player_id, score)| Self { + game_id: service_game.game.id.clone(), + player_id: player_id.clone(), + score: *score as i32, + }) + .collect::>(); + + loop { + if diesel::insert_into(game_score_table) + .values(&scores) + .execute(t_connection) + .is_ok() + { + break; + } + wait_common(); + } - loop { - if diesel::insert_into(game_score_table) - .values(&scores) - .execute(connection) - .is_ok() - { - break; - } - wait_common(); - } + diesel::result::QueryResult::Ok(()) + }) + }) } pub fn read_from_game(connection: &mut PgConnection, game_id: &GameId) -> Score { @@ -939,6 +948,7 @@ impl DieselGameHand { let game_hand = Self { concealed, game_id: service_game.game.id.clone(), + is_kong: false, player_id: player_id.clone(), set_id, tile_id: tile_id as i32, @@ -947,6 +957,27 @@ impl DieselGameHand { hands.push(game_hand); }); + + hand.kong_tiles + .iter() + .enumerate() + .for_each(|(tile_index, tile)| { + let tile_id = tile.id; + let concealed = if tile.concealed { 1 } else { 0 }; + let set_id = tile.set_id.clone(); + + let game_hand = Self { + concealed, + game_id: service_game.game.id.clone(), + is_kong: true, + player_id: player_id.clone(), + set_id: Some(set_id), + tile_id: tile_id as i32, + tile_index: tile_index as i32, + }; + + hands.push(game_hand); + }); }); service_game @@ -963,6 +994,7 @@ impl DieselGameHand { let game_hand = Self { concealed: 0, game_id: service_game.game.id.clone(), + is_kong: false, player_id: player_id.clone(), set_id: None, tile_id: *tile_id as i32, @@ -1015,11 +1047,20 @@ impl DieselGameHand { .get(&player_id) .unwrap_or(&Hand::new(Vec::new())) .clone(); - current_hand.push(HandTile { - id: tile_id, - concealed, - set_id, - }); + + if game_hand.is_kong { + current_hand.kong_tiles.insert(KongTile { + id: tile_id, + concealed, + set_id: set_id.unwrap(), + }); + } else { + current_hand.push(HandTile { + id: tile_id, + concealed, + set_id, + }); + } hands.0.insert(player_id, current_hand); } diff --git a/service/src/db_storage/schema.rs b/service/src/db_storage/schema.rs index fe8b118..9000c2a 100644 --- a/service/src/db_storage/schema.rs +++ b/service/src/db_storage/schema.rs @@ -86,6 +86,7 @@ diesel::table! { set_id -> Nullable, tile_id -> Int4, tile_index -> Int4, + is_kong -> Bool, } } diff --git a/service/src/game_wrapper.rs b/service/src/game_wrapper.rs index b8d6d95..e1a9716 100644 --- a/service/src/game_wrapper.rs +++ b/service/src/game_wrapper.rs @@ -535,6 +535,8 @@ impl<'a> GameWrapper<'a> { .create_meld( &body.player_id, &body.tiles.clone().into_iter().collect::>(), + body.is_upgrade, + body.is_concealed, ) .map_err(|_| ServiceError::Custom("Error when creating meld"))?; @@ -563,6 +565,8 @@ impl<'a> GameWrapper<'a> { .create_meld( &body.player_id, &body.tiles.clone().into_iter().collect::>(), + body.is_upgrade, + body.is_concealed, ) .map_err(|_| ServiceError::Custom("Error when creating meld"))?; @@ -738,7 +742,7 @@ fn create_game(player: &Option, opts: &CreateGameOpts) -> Service created_at: timestamp.to_string(), id: game_player.clone(), is_ai: true, - name: player_names.get(index).unwrap_or(&default_name).clone(), + name: player_names.get(index + 1).unwrap_or(&default_name).clone(), }; players_set.insert(game_player.clone(), service_player); } diff --git a/service/src/games_loop.rs b/service/src/games_loop.rs index 7638463..daf364e 100644 --- a/service/src/games_loop.rs +++ b/service/src/games_loop.rs @@ -1,3 +1,4 @@ +#![allow(clippy::await_holding_lock)] use crate::common::Storage; use crate::game_wrapper::GameWrapper; use crate::http_server::GamesManager; diff --git a/service/src/http_server/admin.rs b/service/src/http_server/admin.rs index 624891c..9a3b1a6 100644 --- a/service/src/http_server/admin.rs +++ b/service/src/http_server/admin.rs @@ -1,3 +1,4 @@ +#![allow(clippy::await_holding_lock)] use crate::auth::AuthHandler; use crate::game_wrapper::{CreateGameOpts, GameWrapper}; use crate::http_server::base::{get_lock, GamesManagerData}; diff --git a/service/src/http_server/base.rs b/service/src/http_server/base.rs index cfa828b..9db28a8 100644 --- a/service/src/http_server/base.rs +++ b/service/src/http_server/base.rs @@ -1,3 +1,4 @@ +#![allow(clippy::await_holding_lock)] use crate::common::Storage; use crate::socket::MahjongWebsocketServer; use actix::prelude::*; diff --git a/service/src/http_server/user.rs b/service/src/http_server/user.rs index c77983a..1dbdc59 100644 --- a/service/src/http_server/user.rs +++ b/service/src/http_server/user.rs @@ -1,3 +1,4 @@ +#![allow(clippy::await_holding_lock)] use crate::auth::{AuthHandler, UnauthorizedError, UserRole}; use crate::game_wrapper::{CreateGameOpts, GameWrapper}; use crate::http_server::base::{get_lock, DataSocketServer, DataStorage, GamesManagerData}; diff --git a/service/src/lib.rs b/service/src/lib.rs new file mode 100644 index 0000000..82526a1 --- /dev/null +++ b/service/src/lib.rs @@ -0,0 +1,13 @@ +mod ai_wrapper; +mod auth; +mod common; +pub mod db_storage; +mod env; +mod game_wrapper; +mod games_loop; +mod http_server; +pub mod logs; +mod service_error; +mod socket; +mod time; +mod user_wrapper; diff --git a/service_contracts/src/lib.rs b/service_contracts/src/lib.rs index f200087..c740e0b 100644 --- a/service_contracts/src/lib.rs +++ b/service_contracts/src/lib.rs @@ -144,6 +144,19 @@ impl ServiceGameSummary { settings: GameSettingsSummary::from_game_settings(&game.settings, player_id), }) } + + pub fn get_turn_player(&self) -> Option { + let player_id = self.game_summary.players.0[self.game_summary.round.player_index].clone(); + + self.players.get(&player_id).cloned() + } + + pub fn get_dealer_player(&self) -> Option { + let player_id = + self.game_summary.players.0[self.game_summary.round.dealer_player_index].clone(); + + self.players.get(&player_id).cloned() + } } #[allow(clippy::large_enum_variant)] @@ -182,6 +195,8 @@ pub type AdminPostDrawTileResponse = Hand; #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] pub struct AdminPostCreateMeldRequest { + pub is_concealed: bool, + pub is_upgrade: bool, pub player_id: String, pub tiles: FxHashSet, } @@ -287,6 +302,8 @@ pub struct UserPostSortHandResponse(pub ServiceGameSummary); pub struct UserPostCreateMeldRequest { pub player_id: PlayerId, pub tiles: FxHashSet, + pub is_upgrade: bool, + pub is_concealed: bool, } #[derive(Deserialize, Serialize, TS)] #[ts(export)] diff --git a/web_client/bindings/AdminPostCreateMeldRequest.ts b/web_client/bindings/AdminPostCreateMeldRequest.ts index 19df5e4..d64064c 100644 --- a/web_client/bindings/AdminPostCreateMeldRequest.ts +++ b/web_client/bindings/AdminPostCreateMeldRequest.ts @@ -1,6 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type AdminPostCreateMeldRequest = { + is_concealed: boolean; + is_upgrade: boolean; player_id: string; tiles: Array; }; diff --git a/web_client/bindings/Hand.ts b/web_client/bindings/Hand.ts index c8a43a4..2a0f0e6 100644 --- a/web_client/bindings/Hand.ts +++ b/web_client/bindings/Hand.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { HandTile } from "./HandTile"; +import type { KongTile } from "./KongTile"; -export type Hand = { list: Array }; +export type Hand = { kong_tiles: Array; list: Array }; diff --git a/web_client/bindings/HandMeld.ts b/web_client/bindings/HandMeld.ts new file mode 100644 index 0000000..970a091 --- /dev/null +++ b/web_client/bindings/HandMeld.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MeldType } from "./MeldType"; + +export type HandMeld = { meld_type: MeldType; tiles: Array }; diff --git a/web_client/bindings/HandMelds.ts b/web_client/bindings/HandMelds.ts new file mode 100644 index 0000000..3f39ab6 --- /dev/null +++ b/web_client/bindings/HandMelds.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HandMeld } from "./HandMeld"; + +export type HandMelds = { melds: Array; tiles_without_meld: number }; diff --git a/web_client/bindings/AdminPostSwapDrawTilesRequest.ts b/web_client/bindings/HandTileStat.ts similarity index 54% rename from web_client/bindings/AdminPostSwapDrawTilesRequest.ts rename to web_client/bindings/HandTileStat.ts index e146945..9e7d1ee 100644 --- a/web_client/bindings/AdminPostSwapDrawTilesRequest.ts +++ b/web_client/bindings/HandTileStat.ts @@ -1,6 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AdminPostSwapDrawTilesRequest = { - tile_id_a: number; - tile_id_b: number; -}; +export type HandTileStat = { in_board: number; in_other_melds: number }; diff --git a/web_client/bindings/KongTile.ts b/web_client/bindings/KongTile.ts new file mode 100644 index 0000000..0d033ac --- /dev/null +++ b/web_client/bindings/KongTile.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type KongTile = { concealed: boolean; id: number; set_id: string }; diff --git a/web_client/bindings/LibGetGamePlayingExtrasParam.ts b/web_client/bindings/LibGetGamePlayingExtrasParam.ts new file mode 100644 index 0000000..e24f712 --- /dev/null +++ b/web_client/bindings/LibGetGamePlayingExtrasParam.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ServiceGameSummary } from "./ServiceGameSummary"; + +export type LibGetGamePlayingExtrasParam = ServiceGameSummary; diff --git a/web_client/bindings/LibGetGamePlayingExtrasReturn.ts b/web_client/bindings/LibGetGamePlayingExtrasReturn.ts new file mode 100644 index 0000000..6e08098 --- /dev/null +++ b/web_client/bindings/LibGetGamePlayingExtrasReturn.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PlayingExtras } from "./PlayingExtras"; + +export type LibGetGamePlayingExtrasReturn = PlayingExtras; diff --git a/web_client/bindings/Provider.ts b/web_client/bindings/LibGetIsMeldParam.ts similarity index 65% rename from web_client/bindings/Provider.ts rename to web_client/bindings/LibGetIsMeldParam.ts index afd864e..2e4fa1b 100644 --- a/web_client/bindings/Provider.ts +++ b/web_client/bindings/LibGetIsMeldParam.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Provider = "Anonymous" | "Email" | "Github"; +export type LibGetIsMeldParam = Array; diff --git a/web_client/bindings/LibGetPossibleMeldsParam.ts b/web_client/bindings/LibGetPossibleMeldsParam.ts new file mode 100644 index 0000000..7c805ad --- /dev/null +++ b/web_client/bindings/LibGetPossibleMeldsParam.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ServiceGame } from "./ServiceGame"; + +export type LibGetPossibleMeldsParam = ServiceGame; diff --git a/web_client/bindings/LibGetPossibleMeldsReturn.ts b/web_client/bindings/LibGetPossibleMeldsReturn.ts new file mode 100644 index 0000000..58d0303 --- /dev/null +++ b/web_client/bindings/LibGetPossibleMeldsReturn.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PossibleMeld } from "./PossibleMeld"; + +export type LibGetPossibleMeldsReturn = Array; diff --git a/web_client/bindings/MeldType.ts b/web_client/bindings/MeldType.ts new file mode 100644 index 0000000..3ed5407 --- /dev/null +++ b/web_client/bindings/MeldType.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MeldType = "Chow" | "Kong" | "Pair" | "Pung"; diff --git a/web_client/bindings/PlayingExtras.ts b/web_client/bindings/PlayingExtras.ts new file mode 100644 index 0000000..3d3bd40 --- /dev/null +++ b/web_client/bindings/PlayingExtras.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HandTileStat } from "./HandTileStat"; +import type { PossibleMeld } from "./PossibleMeld"; +import type { ServicePlayerSummary } from "./ServicePlayerSummary"; +import type { VisibleMeld } from "./VisibleMeld"; +import type { Wind } from "./Wind"; + +export type PlayingExtras = { + can_claim_tile: boolean; + can_discard_tile: boolean; + can_draw_tile: boolean; + can_pass_round: boolean; + can_pass_turn: boolean; + can_say_mahjong: boolean; + dealer_player: null | ServicePlayerSummary; + hand_stats: { [key: number]: HandTileStat }; + players_visible_melds: { [key: string]: Array }; + players_winds: { [key: string]: Wind }; + playing_player: null | ServicePlayerSummary; + possible_melds: Array; + turn_player: null | ServicePlayerSummary; +}; diff --git a/web_client/bindings/PossibleMeld.ts b/web_client/bindings/PossibleMeld.ts index bdb4056..e8b43a7 100644 --- a/web_client/bindings/PossibleMeld.ts +++ b/web_client/bindings/PossibleMeld.ts @@ -2,7 +2,9 @@ export type PossibleMeld = { discard_tile: null | number; + is_concealed: boolean; is_mahjong: boolean; + is_upgrade: boolean; player_id: string; tiles: Array; }; diff --git a/web_client/bindings/UserPostCreateMeldRequest.ts b/web_client/bindings/UserPostCreateMeldRequest.ts index e097865..67b5706 100644 --- a/web_client/bindings/UserPostCreateMeldRequest.ts +++ b/web_client/bindings/UserPostCreateMeldRequest.ts @@ -1,6 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type UserPostCreateMeldRequest = { + is_concealed: boolean; + is_upgrade: boolean; player_id: string; tiles: Array; }; diff --git a/web_client/bindings/VisibleMeld.ts b/web_client/bindings/VisibleMeld.ts new file mode 100644 index 0000000..95e07c5 --- /dev/null +++ b/web_client/bindings/VisibleMeld.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type VisibleMeld = { set_id: string; tiles: Array }; diff --git a/web_client/bun.lockb b/web_client/bun.lockb index 2f50bbe..e693bee 100755 Binary files a/web_client/bun.lockb and b/web_client/bun.lockb differ diff --git a/web_client/next.config.js b/web_client/next.config.js index 8813a9f..c781fb6 100644 --- a/web_client/next.config.js +++ b/web_client/next.config.js @@ -1,8 +1,17 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const webpack = require("webpack"); + const nextConfig = { output: "export", - webpack: (config) => { + webpack: (config, { isServer }) => { config.experiments = { ...config.experiments, asyncWebAssembly: true }; + if (isServer) { + config.plugins.push( + new webpack.NormalModuleReplacementPlugin(/pkg$/, "src/pkg_mock.js"), + ); + } + return config; }, }; diff --git a/web_client/package.json b/web_client/package.json index 9ca5540..cdb83d9 100644 --- a/web_client/package.json +++ b/web_client/package.json @@ -21,6 +21,7 @@ "@playwright/test": "^1.45.1", "@types/qs": "^6.9.15", "antd": "^5.19.1", + "caniuse-lite": "^1.0.30001646", "dayjs": "^1.11.11", "dotenv": "^16.4.5", "eslint-plugin-perfectionist": "^2.11.0", diff --git a/web_client/public/locales/en/translation.json b/web_client/public/locales/en/translation.json index d50bf13..566d94c 100644 --- a/web_client/public/locales/en/translation.json +++ b/web_client/public/locales/en/translation.json @@ -27,8 +27,7 @@ "board": { "help": { "dealer": "Dealer player", - "intro": "The highlighted user is the one who should play in the current turn", - "meld": "Visible meld" + "intro": "The highlighted user is the one who should play in the current turn" } }, "code": "Code", @@ -94,8 +93,11 @@ "hand": "Your hand", "itsYou": "it's you", "meld": { + "chow": "Chow", "concealed": "concealed", + "kong": "Kong", "open": "open", + "pung": "Pung", "title": "Meld" }, "option": { @@ -110,7 +112,8 @@ "sayMahjong": "Say Mahjong", "scanQR": "Or tell them to scan this QR code:", "sortHand": "Sort hand", - "visibleMeld": "Meld of {{player}}", + "upgradeMeld": "Upgrade Meld", + "visibleMeld": "Exposed Meld", "wait": { "10sec": "10 seconds", "1min": "1 minute", diff --git a/web_client/public/locales/zh/translation.json b/web_client/public/locales/zh/translation.json index c3cebd4..6308d75 100644 --- a/web_client/public/locales/zh/translation.json +++ b/web_client/public/locales/zh/translation.json @@ -27,8 +27,7 @@ "board": { "help": { "dealer": "莊家玩家", - "intro": "突出顯示的用戶是當前回合中應該玩的用戶", - "meld": "可見麻將牌" + "intro": "突出顯示的用戶是當前回合中應該玩的用戶" } }, "code": "代碼庫", @@ -91,8 +90,11 @@ "hand": "你的手牌", "itsYou": "是你", "meld": { + "chow": "Chow", "concealed": "隱", + "kong": "Kong", "open": "可見的", + "pung": "Pung", "title": "牌組合" }, "option": { @@ -106,7 +108,8 @@ "sayMahjong": "說麻將", "scanQR": "或告訴他們掃描此QR代碼", "sortHand": "排序手牌", - "visibleMeld": "{{player}}的牌融合", + "upgradeMeld": "升級融合", + "visibleMeld": "可見的融合", "wait": { "10sec": "10秒", "1min": "1分鐘", diff --git a/web_client/src/containers/dashboard-player.tsx b/web_client/src/containers/dashboard-player.tsx index 402aa5e..1d82cff 100644 --- a/web_client/src/containers/dashboard-player.tsx +++ b/web_client/src/containers/dashboard-player.tsx @@ -346,14 +346,15 @@ const DashboardUser = ({ userId }: TProps) => { onClick={() => { const aiPlayersNum = 4 - realPlayersNum; + const aiPlayersNames = Array.from({ length: aiPlayersNum }).map( + (_, i) => + t("dashboard.defaultPlayerName", "AI Player {{number}}", { + number: realPlayersNum + i + 1, + }), + ); + HttpClient.userCreateGame({ - ai_player_names: Array.from({ length: aiPlayersNum }) - .map((_, i) => - t("dashboard.defaultPlayerName", "Player {{number}}", { - number: realPlayersNum + i, - }), - ) - .slice(0, aiPlayersNum), + ai_player_names: aiPlayersNames, auto_sort_own: autoSortOwn, dead_wall: useDeadWall, player_id: userId, diff --git a/web_client/src/containers/game/admin.tsx b/web_client/src/containers/game/admin.tsx index 5a5e348..15a32ac 100644 --- a/web_client/src/containers/game/admin.tsx +++ b/web_client/src/containers/game/admin.tsx @@ -3,7 +3,7 @@ import { Fragment, useEffect, useState } from "react"; import { ModelServiceGame } from "src/lib/models/service-game"; import { SiteUrls } from "src/lib/site/urls"; -import type { SetId, TAdminGetGameResponse } from "src/sdk/core"; +import type { SetIdContent, TAdminGetGameResponse } from "src/sdk/core"; import { HttpClient } from "src/sdk/http-client"; import Button from "src/ui/common/button"; import CopyToClipboard from "src/ui/common/copy-to-clipboard"; @@ -125,7 +125,7 @@ const Game = ({ gameId }: IProps) => { } return acc; - }, new Set()); + }, new Set()); return ( diff --git a/web_client/src/containers/game/board.module.scss b/web_client/src/containers/game/board.module.scss index 1e5a083..6c2ac4a 100644 --- a/web_client/src/containers/game/board.module.scss +++ b/web_client/src/containers/game/board.module.scss @@ -47,6 +47,21 @@ $top-offset: 60px; position: relative; } +.userContentWrap { + width: 20px; + overflow: visible; + position: absolute; +} + +.userContent { + display: flex; + gap: 5px; + height: max-content; + justify-content: flex-start; + overflow: visible; + width: max-content; +} + .userWrapper { position: absolute; display: flex; @@ -58,25 +73,27 @@ $top-offset: 60px; bottom: 0; transform: translateY($top-offset - 40px); align-items: center; + .userContent { + flex-direction: column; + } } &.userLeft { left: $user-gap; + .tilesColumn { + transform: translateX(-10px); + } } &.userRight { - right: $user-gap; + right: $user-gap + 40px; + .tilesColumn { + transform: translateX(-20px); + } } &.userBottom { - bottom: $user-gap; - - .userIcons { - position: absolute; - right: -5px; - bottom: 0; - transform: translateX(100%); - } + bottom: $user-gap + 30px; } &.userTop { @@ -88,6 +105,9 @@ $top-offset: 60px; right: 0; left: 0; justify-content: center; + .userContent { + flex-direction: row; + } } &.userActive { diff --git a/web_client/src/containers/game/board.tsx b/web_client/src/containers/game/board.tsx index 08377f5..ad45351 100644 --- a/web_client/src/containers/game/board.tsx +++ b/web_client/src/containers/game/board.tsx @@ -1,13 +1,17 @@ import { - CaretRightFilled, + InfoCircleOutlined, QuestionCircleOutlined, SettingFilled, ThunderboltOutlined, } from "@ant-design/icons"; import type { ServiceGameSummary } from "bindings/ServiceGameSummary"; -import { memo, useState } from "react"; +import type { Wind } from "bindings/Wind"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; +import type { PlayingExtrasParsed } from "src/sdk/pkg-wrapper"; + +import type { TileId } from "src/sdk/core"; import type { ModelServiceGameSummary } from "src/sdk/service-game-summary"; import Modal from "src/ui/common/modal"; import Text from "src/ui/common/text"; @@ -33,23 +37,30 @@ interface IProps { activePlayer: BoardPlayer["id"] | undefined; canDropInBoard: boolean; dealerPlayer: BoardPlayer["id"] | undefined; + getMeldType: (tiles: TileId[]) => string; isMobile: boolean; players: [BoardPlayer, BoardPlayer, BoardPlayer, BoardPlayer]; + playersVisibleMelds: PlayingExtrasParsed["players_visible_melds"] | undefined; + playersWinds: PlayingExtrasParsed["players_winds"] | undefined; serviceGameM: ModelServiceGameSummary; serviceGameSummary: ServiceGameSummary; + windToText: Record; } const DealerIcon = ThunderboltOutlined; -const MeldIcon = CaretRightFilled; const GameBoard = ({ activePlayer, canDropInBoard, dealerPlayer, + getMeldType, isMobile, players, + playersVisibleMelds, + playersWinds, serviceGameM, serviceGameSummary, + windToText, }: IProps) => { const [displayHelpModal, setDisplayHelpModal] = useState(false); const [displaySettingsModal, setDisplaySettingsModal] = useState(false); @@ -113,27 +124,53 @@ const GameBoard = ({ styles.userRight, ][idx]; - const isCurrentPlayer = - player.id === serviceGameSummary.game_summary.player_id; - - const playerVisibleMelds = serviceGameSummary.game_summary.hand?.list - ? new Set( - isCurrentPlayer - ? serviceGameSummary.game_summary.hand.list - .filter((h) => !h.concealed) - .map((h) => h.set_id) - .filter(Boolean) - : serviceGameSummary.game_summary.other_hands[ - player.id - ]?.visible.list - .map((handTile) => handTile.set_id) - .filter(Boolean), - ).size - : 0; - - const tooltip = ( + const playerWind = playersWinds?.get(player.id); + + const visibleMelds = playersVisibleMelds?.get(player.id); + + const tilesColumn = (visibleMelds || []).map((visibleMeld) => { + const { set_id: setId, tiles } = visibleMeld; + const meldType = getMeldType(tiles); + + const tooltipTitle = ( + + + {t("game.visibleMeld", { + player: player.name, + })} + +
+ {meldType} +
+ ); + + return ( +
+ {tiles.map((tileId) => { + const tile = serviceGameM.getTile(tileId); + + return ( + + + + ); + })} + + + +
+ ); + }); + + const avatarTooltip = ( <> - {player.name} + + {player.name} + {playerWind ? ` | ${windToText[playerWind]}` : ""} +
{t("game.points", { @@ -158,34 +195,25 @@ const GameBoard = ({ .join(" ")} key={player.id} > - - - - + +
+ +
+ +
+
+
{dealerPlayer === player.id && ( - +
- +
)} - {Array.from({ length: playerVisibleMelds }).map( - (_, index) => ( - - - - ), - )} -
- - +
+ {!!visibleMelds?.length && ( +
{tilesColumn}
+ )} +
+
); })} @@ -227,12 +255,6 @@ const GameBoard = ({
{" "} {t("board.help.dealer")} -
  • - - - {" "} - {t("board.help.meld", "Visible meld")} -
  • { } }, [joinUrl]); - const getCanDiscardTile = () => { - if (!serviceGameSummary) return false; - - const { hand } = serviceGameSummary.game_summary; - - if (!hand?.list) return false; - - // This should be from API - return hand.list.length === 14; - }; - serviceGameM.updateStates( gameState as ModelState, loadingState, @@ -167,8 +156,25 @@ const Game = ({ gameId, userId }: IProps) => { [serviceGameSummary?.game_summary.players.join("")], ); + const { + can_claim_tile, + can_discard_tile, + can_draw_tile, + can_pass_round, + can_pass_turn, + can_say_mahjong, + dealer_player, + hand_stats: handStats, + players_visible_melds, + players_winds, + playing_player, + possible_melds, + turn_player, + } = serviceGameM.getGamePlayingExtras() || {}; + const { boardDropRef, canDropInBoard, handTilesProps } = useGameUI({ - getCanDiscardTile, + canDiscardTile: can_discard_tile, + handStats, serviceGameM, serviceGameSummary, }); @@ -191,41 +197,35 @@ const Game = ({ gameId, userId }: IProps) => { } return acc; - }, new Set()); - - const playingPlayer = serviceGameM.getPlayingPlayer(); - const turnPlayer = serviceGameM.getTurnPlayer(); - const possibleMelds = serviceGameM.getPossibleMelds(); - - const dealerPlayerId = - serviceGameSummary.game_summary.players[ - serviceGameSummary.game_summary.round.dealer_player_index - ]; - - const dealerPlayer = serviceGameSummary.players[dealerPlayerId]; + }, new Set()); const isDraggingOther = handTilesProps?.some((props) => props.hasItemOver); - const canPassRound = serviceGameSummary.game_summary.board.length === 92; - - const canDrawTile = - !!playingPlayer && - playingPlayer.id === userId && - serviceGameSummary.game_summary.hand?.list && - serviceGameSummary.game_summary.hand.list.length < 14; - const playerBonusTiles = bonus_tiles[serviceGameSummary.game_summary.player_id]; const isPlaying = serviceGameSummary.game_summary.phase === "Playing"; + const getMeldType = (tiles: TileId[]): string => { + switch (true) { + case serviceGameM.getIsPung(tiles): + return t("game.meld.pung", "Pung"); + case serviceGameM.getIsKong(tiles): + return t("game.meld.kong", "Kong"); + case serviceGameM.getIsChow(tiles): + return t("game.meld.chow", "Chow"); + default: + return ""; + } + }; + return (
    - {!isMobile && isPlaying && ( + {!isMobile && isPlaying && !!playing_player && ( <> - {playingPlayer.name} ( + {playing_player.name} ( { )} - - {t("game.currentWind")}{" "} - {windToText[serviceGameSummary.game_summary.round.wind]},{" "} - {t("game.currentDealer")} {dealerPlayer.name} - + {!!dealer_player && ( + + {t("game.currentWind")}{" "} + {windToText[serviceGameSummary.game_summary.round.wind]},{" "} + {t("game.currentDealer")} {dealer_player.name} + + )} )}{" "} }> {!isWaitingPlayers && !isPlaying && ( @@ -307,7 +313,7 @@ const Game = ({ gameId, userId }: IProps) => { )} ))()} - {isPlaying && ( + {isPlaying && !!turn_player && !!playing_player && ( <>
    { <> {t("game.currentTurn")}:{" "} - {turnPlayer.name} - {turnPlayer.id === playingPlayer.id + {turn_player.name} + {turn_player.id === playing_player.id ? ` (${t("game.itsYou")})` : ""} @@ -366,12 +372,11 @@ const Game = ({ gameId, userId }: IProps) => { : lastTile; const claimTileTitle = tile ? getTileInfo(tile, i18n)?.[1] : null; - const disabled = !gameState[0]?.game_summary.round.discarded_tile; return (
    - {canPassRound && ( + {can_pass_round && ( )} - + {!!serviceGameSummary.settings && + !serviceGameSummary.settings.auto_sort && ( + + )} {!serviceGameSummary.settings.ai_enabled && ( )}
    ); })} - {gameState[0]?.game_summary.players.reduce((acc, playerId) => { - const playerHand = gameState[0]?.game_summary.other_hands[playerId]; - - if (!playerHand?.visible) { - return acc; - } - - const sets = new Set( - playerHand.visible.list?.map((tile) => tile.set_id) || [], - ); - - const otherPlayer = gameState[0]?.players[playerId]; - - Array.from(sets) - .sort() - .forEach((setId) => { - const tiles = playerHand.visible.list - .filter((tile) => tile.set_id === setId) - .map((tile) => tile.id); - - acc.push( - - - {t("game.visibleMeld", { - player: otherPlayer?.name, - })} - - - } - > -
    - {tiles.map((tileId) => { - const tile = serviceGameM.getTile(tileId); - - return ( - - - - ); - })} -
    -
    , - ); - }); - - return acc; - }, [] as React.ReactElement[])} {!!playerBonusTiles?.length && ( { - setFormatTile(format_tile); - - setGetPossibleMeldsSummary((game) => - get_possible_melds_summary(JSON.stringify(game)), - ); - - const deck = get_deck(); + const deck = getDeck(); setDeck(deck); }; diff --git a/web_client/src/lib/models/service-game.ts b/web_client/src/lib/models/service-game.ts index b0e2663..99e8130 100644 --- a/web_client/src/lib/models/service-game.ts +++ b/web_client/src/lib/models/service-game.ts @@ -1,7 +1,6 @@ -import type { PossibleMeld } from "bindings/PossibleMeld"; import type { ServiceGame } from "bindings/ServiceGame"; -import { format_tile, get_possible_melds } from "pkg"; +import { formatTile, getPossibleMelds } from "src/sdk/pkg-wrapper"; import type { PlayerId, TileId } from "src/sdk/core"; import { getDeck } from "src/sdk/service-game-summary"; @@ -19,15 +18,13 @@ export class ModelServiceGame { return this.data.game.score[playerId]; } - getPossibleMelds(): PossibleMeld[] { - const possibleMelds = get_possible_melds(JSON.stringify(this.data)); - - return possibleMelds; + getPossibleMelds() { + return getPossibleMelds(this.data); } getTileString(tileId: TileId) { const tile = getDeck()[tileId]; - const tileString = format_tile(tile); + const tileString = formatTile(tile); return `[${tileString}]`; } diff --git a/web_client/src/pkg_mock.js b/web_client/src/pkg_mock.js new file mode 100644 index 0000000..1f50532 --- /dev/null +++ b/web_client/src/pkg_mock.js @@ -0,0 +1,13 @@ +export const format_tile = () => {}; +export const get_can_discard_tile = () => {}; +export const get_dealer_player = () => {}; +export const get_deck = () => {}; +export const get_game_playing_extras = () => {}; +export const get_players_visible_melds = () => {}; +export const get_players_winds = () => {}; +export const get_possible_melds = () => {}; +export const get_possible_melds_summary = () => {}; +export const get_turn_player = () => {}; +export const is_chow = () => {}; +export const is_kong = () => {}; +export const is_pung = () => {}; diff --git a/web_client/src/sdk/core.ts b/web_client/src/sdk/core.ts index 7e29b57..b1fdc47 100644 --- a/web_client/src/sdk/core.ts +++ b/web_client/src/sdk/core.ts @@ -1,5 +1,6 @@ import type { GameSummary } from "bindings/GameSummary"; import type { Hand } from "bindings/Hand"; +import type { HandTile } from "bindings/HandTile"; import type { ServiceGame } from "bindings/ServiceGame"; import type { ServiceGameSummary } from "bindings/ServiceGameSummary"; import type { ServicePlayerGame } from "bindings/ServicePlayerGame"; @@ -7,10 +8,9 @@ import type { ServicePlayerGame } from "bindings/ServicePlayerGame"; export type GameId = GameSummary["id"]; export type GameVersion = GameSummary["version"]; export type PlayerId = GameSummary["players"][number]; -export type TileId = number; -export type SetId = NonNullable< - NonNullable["list"][number]["set_id"] ->; +export type TileId = HandTile["id"]; +export type SetId = HandTile["set_id"]; +export type SetIdContent = NonNullable; export type TAdminPostBreakMeldRequest = { player_id: PlayerId; diff --git a/web_client/src/sdk/pkg-wrapper.ts b/web_client/src/sdk/pkg-wrapper.ts new file mode 100644 index 0000000..70466ed --- /dev/null +++ b/web_client/src/sdk/pkg-wrapper.ts @@ -0,0 +1,45 @@ +import type { LibGetGamePlayingExtrasParam } from "bindings/LibGetGamePlayingExtrasParam"; +import type { LibGetGamePlayingExtrasReturn } from "bindings/LibGetGamePlayingExtrasReturn"; +import type { LibGetIsMeldParam } from "bindings/LibGetIsMeldParam"; +import type { LibGetPossibleMeldsParam } from "bindings/LibGetPossibleMeldsParam"; +import type { LibGetPossibleMeldsReturn } from "bindings/LibGetPossibleMeldsReturn"; +import type { Tile } from "bindings/Tile"; + +import { + format_tile, + get_deck, + get_game_playing_extras, + get_possible_melds, + is_chow, + is_kong, + is_pung, +} from "pkg"; + +type ObjToMap = R extends Record ? Map : never; + +export const isPung = (param: LibGetIsMeldParam) => is_pung(param); +export const isChow = (param: LibGetIsMeldParam) => is_chow(param); +export const isKong = (param: LibGetIsMeldParam) => is_kong(param); + +export const formatTile = (tile: Tile) => format_tile(tile); + +export const getPossibleMelds = ( + param: LibGetPossibleMeldsParam, +): LibGetPossibleMeldsReturn => get_possible_melds(param); + +export const getDeck = () => get_deck(); + +export type PlayingExtrasParsed = Omit< + LibGetGamePlayingExtrasReturn, + "hand_stats" | "players_visible_melds" | "players_winds" +> & { + hand_stats: ObjToMap; + players_visible_melds: ObjToMap< + LibGetGamePlayingExtrasReturn["players_visible_melds"] + >; + players_winds: ObjToMap; +}; + +export const getGamePlayingExtras = ( + param: LibGetGamePlayingExtrasParam, +): PlayingExtrasParsed => get_game_playing_extras(param); diff --git a/web_client/src/sdk/service-game-summary.ts b/web_client/src/sdk/service-game-summary.ts index 9b0f1ac..4973ef5 100644 --- a/web_client/src/sdk/service-game-summary.ts +++ b/web_client/src/sdk/service-game-summary.ts @@ -6,14 +6,20 @@ import type { ServiceGameSummary } from "bindings/ServiceGameSummary"; import type { Tile } from "bindings/Tile"; import { Subject } from "rxjs"; +import { + formatTile, + getGamePlayingExtras, + isChow, + isKong, + isPung, +} from "./pkg-wrapper"; + import type { TileId } from "./core"; import { HttpClient } from "./http-client"; -export type ModelState = [A, (v: A) => void]; +export type ModelState = [A | null, (v: A) => void]; let deck: Deck; -let format_tile: (tile: Tile) => string; -let get_possible_melds_summary: (game: ServiceGameSummary) => PossibleMeld[]; export const setDeck = (newDeck: Deck) => { deck = newDeck; @@ -21,26 +27,16 @@ export const setDeck = (newDeck: Deck) => { export const getDeck = () => deck; -export const setFormatTile = (newFormatTile: typeof format_tile) => { - format_tile = newFormatTile; -}; - export const getTile = (tileId: TileId) => deck[tileId] as Tile; -export const setGetPossibleMeldsSummary = ( - newGetPossibleMeldsSummary: typeof get_possible_melds_summary, -) => { - get_possible_melds_summary = newGetPossibleMeldsSummary; -}; - export enum ModelServiceGameSummaryError { INVALID_SAY_MAHJONG = "INVALID_SAY_MAHJONG", } export class ModelServiceGameSummary { - public errorEmitter$ = new Subject(); + errorEmitter$ = new Subject(); - public gameState!: ModelState; + gameState!: ModelState; private handleError = (error?: ModelServiceGameSummaryError) => { if (error) { this.errorEmitter$.next(error); @@ -48,19 +44,21 @@ export class ModelServiceGameSummary { this.loadingState[1](false); }; - public isLoading = false; + isLoading = false; - public loadingState!: ModelState; + loadingState!: ModelState; breakMeld(setId: string) { - if (this.loadingState[0]) { + const [gameState] = this.gameState; + + if (this.loadingState[0] || !gameState) { return; } this.loadingState[1](true); - HttpClient.userBreakMeld(this.gameState[0].game_summary.id, { - player_id: this.gameState[0].game_summary.player_id, + HttpClient.userBreakMeld(gameState.game_summary.id, { + player_id: gameState.game_summary.player_id, set_id: setId, }).subscribe({ error: () => { @@ -74,14 +72,16 @@ export class ModelServiceGameSummary { } claimTile() { - if (this.loadingState[0]) { + const [gameState] = this.gameState; + + if (this.loadingState[0] || !gameState) { return; } this.loadingState[1](true); - HttpClient.userClaimTile(this.gameState[0].game_summary.id, { - player_id: this.gameState[0].game_summary.player_id, + HttpClient.userClaimTile(gameState.game_summary.id, { + player_id: gameState.game_summary.player_id, }).subscribe({ error: () => { this.handleError(); @@ -93,16 +93,20 @@ export class ModelServiceGameSummary { }); } - createMeld(tiles: TileId[]) { - if (this.loadingState[0]) { + createMeld(meld: PossibleMeld) { + const [gameState] = this.gameState; + + if (this.loadingState[0] || !gameState) { return; } this.loadingState[1](true); - HttpClient.userCreateMeld(this.gameState[0].game_summary.id, { - player_id: this.gameState[0].game_summary.player_id, - tiles, + HttpClient.userCreateMeld(gameState.game_summary.id, { + is_concealed: meld.is_concealed, + is_upgrade: meld.is_upgrade, + player_id: gameState.game_summary.player_id, + tiles: meld.tiles, }).subscribe({ error: () => { this.handleError(); @@ -115,13 +119,15 @@ export class ModelServiceGameSummary { } discardTile(tileId: TileId) { - if (this.loadingState[0]) { + const [gameState] = this.gameState; + + if (this.loadingState[0] || !gameState) { return; } this.loadingState[1](true); - HttpClient.userDiscardTile(this.gameState[0].game_summary.id, { + HttpClient.userDiscardTile(gameState.game_summary.id, { tile_id: tileId, }).subscribe({ error: () => { @@ -134,36 +140,46 @@ export class ModelServiceGameSummary { }); } - getPlayerHandWithoutMelds(): Hand | null { - const { hand } = this.gameState[0].game_summary; + getGamePlayingExtras() { + const [gameState] = this.gameState; - if (!hand?.list) return null; + if (!gameState) return null; - return { ...hand, list: hand.list.filter((tile) => !tile.set_id) }; + return getGamePlayingExtras(gameState); } - getPlayingPlayer() { - return this.gameState[0].players[this.gameState[0].game_summary.player_id]; + getIsChow(tiles: TileId[]) { + return isChow(tiles); } - getPlayingPlayerIndex() { - return this.gameState[0].game_summary.players.findIndex( - (player) => player === this.gameState[0].game_summary.player_id, - ); + getIsKong(tiles: TileId[]) { + return isKong(tiles); } - getPossibleMelds(): PossibleMeld[] { - try { - if (this.gameState[0].game_summary.phase !== "Playing") return []; + getIsPung(tiles: TileId[]) { + return isPung(tiles); + } - const possibleMelds = get_possible_melds_summary(this.gameState[0]); + getPlayerHandWithoutMelds(): Hand | null { + const [gameState] = this.gameState; - return possibleMelds; - } catch (error) { - console.error("debug: service-game-summary.ts: error", error); - } + if (!gameState) return null; + + const { hand } = gameState.game_summary; + + if (!hand?.list) return null; + + return { ...hand, list: hand.list.filter((tile) => !tile.set_id) }; + } + + getPlayingPlayerIndex() { + const [gameState] = this.gameState; + + if (!gameState) return null; - return []; + return gameState.game_summary.players.findIndex( + (player) => player === gameState.game_summary.player_id, + ); } getShareLink(gameId: string) { @@ -177,7 +193,7 @@ export class ModelServiceGameSummary { getTileString(tileId: TileId) { try { const tile = this.getTile(tileId); - const tileString = format_tile(tile); + const tileString = formatTile(tile); return `[${tileString}]`; } catch (err) { @@ -187,18 +203,17 @@ export class ModelServiceGameSummary { return ""; } - getTurnPlayer() { - const playerId = - this.gameState[0].game_summary.players[ - this.gameState[0].game_summary.round.player_index - ]; + passRound() { + const [gameState] = this.gameState; - return this.gameState[0].players[playerId]; - } + if (this.loadingState[0] || !gameState) { + return; + } - passRound() { - HttpClient.userPassRound(this.gameState[0].game_summary.id, { - player_id: this.gameState[0].game_summary.player_id, + this.loadingState[1](true); + + HttpClient.userPassRound(gameState.game_summary.id, { + player_id: gameState.game_summary.player_id, }).subscribe({ error: () => { this.handleError(); @@ -211,14 +226,16 @@ export class ModelServiceGameSummary { } sayMahjong() { - if (this.loadingState[0]) { + const [gameState] = this.gameState; + + if (this.loadingState[0] || !gameState) { return; } this.loadingState[1](true); - HttpClient.userSayMahjong(this.gameState[0].game_summary.id, { - player_id: this.gameState[0].game_summary.player_id, + HttpClient.userSayMahjong(gameState.game_summary.id, { + player_id: gameState.game_summary.player_id, }).subscribe({ error: (error) => { console.error("debug: service-game-summary.ts: error", error); @@ -232,21 +249,27 @@ export class ModelServiceGameSummary { } setGameSettings(gameSettings: GameSettingsSummary) { - if (this.loadingState[0]) { + const [gameState] = this.gameState; + + if (this.loadingState[0] || !gameState) { return; } this.loadingState[1](true); - HttpClient.userSetGameSettings(this.gameState[0].game_summary.id, { - player_id: this.gameState[0].game_summary.player_id, + HttpClient.userSetGameSettings(gameState.game_summary.id, { + player_id: gameState.game_summary.player_id, settings: gameSettings, }).subscribe({ next: () => { this.loadingState[1](false); + const [gameState2] = this.gameState; + + if (!gameState2) return; + this.gameState[1]({ - ...this.gameState[0], + ...gameState2, settings: gameSettings, }); }, @@ -254,15 +277,17 @@ export class ModelServiceGameSummary { } sortHands(tiles?: TileId[]) { - if (this.loadingState[0]) { + const [gameState] = this.gameState; + + if (this.loadingState[0] || !gameState) { return; } this.loadingState[1](true); - HttpClient.userSortHand(this.gameState[0].game_summary.id, { - game_version: this.gameState[0].game_summary.version, - player_id: this.gameState[0].game_summary.player_id, + HttpClient.userSortHand(gameState.game_summary.id, { + game_version: gameState.game_summary.version, + player_id: gameState.game_summary.player_id, tiles: tiles || null, }).subscribe({ error: () => { @@ -281,7 +306,7 @@ export class ModelServiceGameSummary { tileIdToIndex.set(tileId, index); }); - const prevHand = this.gameState[0].game_summary.hand; + const prevHand = gameState.game_summary.hand; if (!prevHand) return; @@ -299,16 +324,16 @@ export class ModelServiceGameSummary { }); this.gameState[1]({ - ...this.gameState[0], + ...gameState, game_summary: { - ...this.gameState[0].game_summary, + ...gameState.game_summary, hand: newHand, }, }); } } - public updateStates( + updateStates( gameState: ModelState, loadingState: ModelState, ) { diff --git a/web_client/src/ui/game/use-game-ui.tsx b/web_client/src/ui/game/use-game-ui.tsx index dec518a..5be3c38 100644 --- a/web_client/src/ui/game/use-game-ui.tsx +++ b/web_client/src/ui/game/use-game-ui.tsx @@ -5,9 +5,10 @@ import { useDrop } from "react-dnd"; import { useTranslation } from "react-i18next"; import { Subject } from "rxjs"; +import type { PlayingExtrasParsed } from "src/sdk/pkg-wrapper"; + import type { TileId } from "src/sdk/core"; import type { ModelServiceGameSummary } from "src/sdk/service-game-summary"; -import { getIsSameTile } from "src/sdk/tile-content"; export const DROP_BG = "#e7e7e7"; export const DROP_BORDER = "2px solid #333"; @@ -19,18 +20,19 @@ enum DropType { } type Opts = { - getCanDiscardTile: () => boolean; + canDiscardTile: boolean | undefined; + handStats: PlayingExtrasParsed["hand_stats"] | undefined; serviceGameM: ModelServiceGameSummary; serviceGameSummary: null | ServiceGameSummary; }; export const useGameUI = ({ - getCanDiscardTile, + canDiscardTile, + handStats, serviceGameM, serviceGameSummary, }: Opts) => { const { t } = useTranslation(); - const canDiscardTile = getCanDiscardTile(); const handWithoutMelds = serviceGameSummary ? serviceGameM.getPlayerHandWithoutMelds() @@ -43,7 +45,7 @@ export const useGameUI = ({ const [{ canDropInBoard }, boardDropRef] = useDrop( { accept: DropType.HAND_TILE, - canDrop: () => canDiscardTile, + canDrop: () => !!canDiscardTile, collect: (monitor) => ({ canDropInBoard: !!monitor.canDrop(), }), @@ -116,9 +118,7 @@ export const useGameUI = ({ () => handWithoutMelds?.list.map((handTile) => { const handler: MouseEventHandler = (e) => { - const canDiscardTileNew = getCanDiscardTile(); - - if (e.detail === 2 && canDiscardTileNew) { + if (e.detail === 2 && canDiscardTile) { serviceGameM.discardTile(handTile.id); } }; @@ -142,23 +142,10 @@ export const useGameUI = ({ handWithoutMelds?.list.map((handTile) => // eslint-disable-next-line react/display-name (title?: string) => { - const tile = serviceGameM.getTile(handTile.id); - - const sameTilesInBoard = board?.filter((ti) => { - const boardTile = serviceGameM.getTile(ti); - - return getIsSameTile(boardTile, tile); - }).length; - - const sameTilesInMelds = visibleMelds - .map((h) => h.list) - .flat() - .concat(handMelds) - .filter((otherHandTile) => { - const otherTile = serviceGameM.getTile(otherHandTile.id); + const sameTilesInBoard = handStats?.get(handTile.id)?.in_board || 0; - return getIsSameTile(otherTile, tile); - }).length; + const sameTilesInMelds = + handStats?.get(handTile.id)?.in_other_melds || 0; return ( <> diff --git a/web_client/src/ui/tile-img.tsx b/web_client/src/ui/tile-img.tsx index 9535e7d..cf6c140 100644 --- a/web_client/src/ui/tile-img.tsx +++ b/web_client/src/ui/tile-img.tsx @@ -18,6 +18,7 @@ type Props = { onClick?: MouseEventHandler; onIsDraggingChange?: (isDragging: boolean) => void; paddingLeft?: number; + size?: number; tile?: Tile; tooltipFormatter?: (title?: string) => React.ReactNode; }; @@ -30,6 +31,7 @@ const TileImg = ({ onClick, onIsDraggingChange, paddingLeft, + size = 50, tile, tooltipFormatter, }: Props) => { @@ -66,6 +68,8 @@ const TileImg = ({ return null; } + const sizePx = `${size}px`; + const imgEl = ( diff --git a/web_lib/Cargo.toml b/web_lib/Cargo.toml index 06484ff..54dfbbf 100644 --- a/web_lib/Cargo.toml +++ b/web_lib/Cargo.toml @@ -9,12 +9,15 @@ crate-type = ["cdylib", "rlib"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -wasm-bindgen = { version = "0.2.87", features = ["serde-serialize", "serde", "serde_json"] } mahjong_core = { path = "../mahjong_core" } +serde-wasm-bindgen = "0.5.0" +serde_json = "1.0.100" service_contracts = { path = "../service_contracts" } +serde = { version = "1.0.167", features = ["derive"] } +ts-rs = "9.0.1" +wasm-bindgen = { version = "0.2.87", features = ["serde-serialize", "serde", "serde_json"] } web-sys = { version= "0.3.64", features = ["console"] } -serde_json = "1.0.100" -serde-wasm-bindgen = "0.5.0" +rustc-hash = "1.1.0" [package.metadata.wasm-pack.profile.release] wasm-opt = false diff --git a/web_lib/src/lib.rs b/web_lib/src/lib.rs index 334c222..74c3f48 100644 --- a/web_lib/src/lib.rs +++ b/web_lib/src/lib.rs @@ -1,8 +1,11 @@ #![deny(clippy::use_self, clippy::shadow_unrelated)] use mahjong_core::{deck::DEFAULT_DECK, ui::format_to_emoji, Tile}; -use service_contracts::{ServiceGame, ServiceGameSummary}; +pub use melds::{get_possible_melds, is_chow, is_kong, is_pung}; use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; +mod melds; +mod service_game_summary; + #[wasm_bindgen] pub fn format_tile(tile: JsValue) -> String { let tile: Tile = serde_wasm_bindgen::from_value(tile).unwrap(); @@ -10,22 +13,6 @@ pub fn format_tile(tile: JsValue) -> String { format_to_emoji(&tile) } -#[wasm_bindgen] -pub fn get_possible_melds(game: String) -> JsValue { - let service_game: ServiceGame = serde_json::from_str(&game).unwrap(); - let possible_melds = service_game.game.get_possible_melds(false); - - serde_wasm_bindgen::to_value(&possible_melds).unwrap() -} - -#[wasm_bindgen] -pub fn get_possible_melds_summary(game: String) -> JsValue { - let service_game: ServiceGameSummary = serde_json::from_str(&game).unwrap(); - let possible_melds = service_game.game_summary.get_possible_melds(); - - serde_wasm_bindgen::to_value(&possible_melds).unwrap() -} - #[wasm_bindgen] pub fn get_deck() -> JsValue { let deck = DEFAULT_DECK.clone(); diff --git a/web_lib/src/melds.rs b/web_lib/src/melds.rs new file mode 100644 index 0000000..b3d8572 --- /dev/null +++ b/web_lib/src/melds.rs @@ -0,0 +1,66 @@ +use mahjong_core::{ + deck::DEFAULT_DECK, + meld::{get_is_chow, get_is_kong, get_is_pung, PossibleMeld, SetCheckOpts}, + TileId, +}; +use serde::{Deserialize, Serialize}; +use service_contracts::ServiceGame; +use ts_rs::TS; +use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; + +#[derive(TS, Serialize, Deserialize)] +#[ts(export)] +struct LibGetIsMeldParam(Vec); + +macro_rules! get_meld_opts { + ($val:expr) => {{ + let sub_hand: LibGetIsMeldParam = serde_wasm_bindgen::from_value($val).unwrap(); + + SetCheckOpts { + board_tile_player_diff: None, + claimed_tile: None, + sub_hand: &sub_hand + .0 + .iter() + .map(|id| &DEFAULT_DECK.0[*id]) + .collect::>(), + } + }}; +} + +#[wasm_bindgen] +pub fn is_pung(val: JsValue) -> bool { + let opts = get_meld_opts!(val); + + get_is_pung(&opts) +} + +#[wasm_bindgen] +pub fn is_chow(val: JsValue) -> bool { + let opts = get_meld_opts!(val); + + get_is_chow(&opts) +} + +#[wasm_bindgen] +pub fn is_kong(val: JsValue) -> bool { + let opts = get_meld_opts!(val); + + get_is_kong(&opts) +} + +#[derive(TS, Serialize, Deserialize)] +#[ts(export)] +struct LibGetPossibleMeldsParam(ServiceGame); + +#[derive(TS, Serialize, Deserialize)] +#[ts(export)] +struct LibGetPossibleMeldsReturn(Vec); + +#[wasm_bindgen] +pub fn get_possible_melds(game: JsValue) -> JsValue { + let service_game: LibGetPossibleMeldsParam = serde_wasm_bindgen::from_value(game).unwrap(); + let possible_melds = service_game.0.game.get_possible_melds(false); + + serde_wasm_bindgen::to_value(&LibGetPossibleMeldsReturn(possible_melds)).unwrap() +} diff --git a/web_lib/src/service_game_summary.rs b/web_lib/src/service_game_summary.rs new file mode 100644 index 0000000..509144b --- /dev/null +++ b/web_lib/src/service_game_summary.rs @@ -0,0 +1,78 @@ +use mahjong_core::{ + game_summary::{HandTileStat, VisibleMeld}, + meld::PossibleMeld, + PlayerId, TileId, Wind, +}; +use rustc_hash::FxHashMap; +use serde::{Deserialize, Serialize}; +use service_contracts::{ServiceGameSummary, ServicePlayerSummary}; +use ts_rs::TS; +use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; + +#[derive(TS, Serialize, Deserialize)] +#[ts(export)] +struct PlayingExtras { + can_claim_tile: bool, + can_discard_tile: bool, + can_draw_tile: bool, + can_pass_round: bool, + can_pass_turn: bool, + can_say_mahjong: bool, + dealer_player: Option, + hand_stats: FxHashMap, + players_visible_melds: FxHashMap>, + players_winds: FxHashMap, + playing_player: Option, + possible_melds: Vec, + turn_player: Option, +} + +#[derive(TS, Serialize, Deserialize)] +#[ts(export)] +struct LibGetGamePlayingExtrasParam(ServiceGameSummary); + +#[derive(TS, Serialize, Deserialize)] +#[ts(export)] +struct LibGetGamePlayingExtrasReturn(PlayingExtras); + +#[wasm_bindgen] +pub fn get_game_playing_extras(param: JsValue) -> JsValue { + let parsed_val: LibGetGamePlayingExtrasParam = serde_wasm_bindgen::from_value(param).unwrap(); + + let can_draw_tile = parsed_val.0.game_summary.get_can_draw_tile(); + let can_say_mahjong = parsed_val.0.game_summary.get_can_say_mahjong(); + let can_pass_round = parsed_val.0.game_summary.get_can_pass_round(); + let can_claim_tile = parsed_val.0.game_summary.get_can_claim_tile(); + let can_pass_turn = parsed_val.0.game_summary.get_can_pass_turn(); + let can_discard_tile = parsed_val.0.game_summary.get_can_discard_tile(); + + let dealer_player = parsed_val.0.get_dealer_player(); + let possible_melds = parsed_val.0.game_summary.get_possible_melds(); + let players_visible_melds = parsed_val.0.game_summary.get_players_visible_melds(); + let players_winds = parsed_val.0.game_summary.get_players_winds(); + let playing_player = parsed_val + .0 + .players + .get(&parsed_val.0.game_summary.player_id) + .cloned(); + let turn_player = parsed_val.0.get_turn_player(); + let hand_stats = parsed_val.0.game_summary.get_hand_stats(); + + let rv = LibGetGamePlayingExtrasReturn(PlayingExtras { + can_claim_tile, + can_discard_tile, + can_draw_tile, + can_pass_round, + can_pass_turn, + can_say_mahjong, + dealer_player, + hand_stats, + players_visible_melds, + players_winds, + playing_player, + possible_melds, + turn_player, + }); + + serde_wasm_bindgen::to_value(&rv).unwrap() +}