diff --git a/flake.nix b/flake.nix index 3a74213f..49b0b41b 100644 --- a/flake.nix +++ b/flake.nix @@ -17,32 +17,36 @@ let pkgs = import nixpkgs { inherit system; }; + llvmPackage = pkgs.llvmPackages_21; + gccPackage = pkgs.gcc15Stdenv; + buildPackages = with pkgs; [ cmake git ninja ]; - llvmPackage = pkgs.llvmPackages_21; - gccPackage = pkgs.gcc15Stdenv; - # clang wrapper with gcc's libstdc++ clangWithGccStdlib = pkgs.wrapCCWith { cc = llvmPackage.clang-unwrapped; - bintools = gccPackage.cc.bintools; + bintools = llvmPackage.bintools; + libcxx = gccPackage.cc.cc.lib; extraBuildCommands = '' echo "-isystem ${gccPackage.cc.cc}/include/c++/${gccPackage.cc.version}" >> $out/nix-support/cc-cflags echo "-isystem ${gccPackage.cc.cc}/include/c++/${gccPackage.cc.version}/x86_64-unknown-linux-gnu" >> $out/nix-support/cc-cflags + + echo "-isystem ${gccPackage.cc.cc}/include/c++/${gccPackage.cc.version}" > $out/nix-support/libcxx-cxxflags + echo "-isystem ${gccPackage.cc.cc}/include/c++/${gccPackage.cc.version}/x86_64-unknown-linux-gnu" >> $out/nix-support/libcxx-cxxflags ''; }; - # Create stdenv with clang + gcc libstdc++ - clangStdenv = pkgs.overrideCC gccPackage clangWithGccStdlib; + clangStdenv = pkgs.stdenvAdapters.overrideCC gccPackage clangWithGccStdlib; - # Override clang-tools to match our clang + gcc libstdc++ env - clangTools = llvmPackage.clang-tools.overrideAttrs (oldAttrs: { + clangTools = llvmPackage.clang-tools.override { stdenv = clangStdenv; - }); + clang = clangWithGccStdlib; + clang-unwrapped = llvmPackage.clang-unwrapped; + }; commonAttrs = { pname = "libmahjong"; @@ -80,12 +84,16 @@ ]; in { - devShells.default = pkgs.mkShell.override { stdenv = clangStdenv; } { + devShells.default = clangStdenv.mkDerivation { + name = "libmahjong-dev"; nativeBuildInputs = clangNativeBuildInputs; # Disable all hardening hardeningDisable = [ "all" ]; + # Explicitly set to prevent contamination + NIX_CFLAGS_COMPILE = "-isystem ${gccPackage.cc.cc}/include/c++/${gccPackage.cc.version} -isystem ${gccPackage.cc.cc}/include/c++/${gccPackage.cc.version}/x86_64-unknown-linux-gnu"; + shellHook = '' mkdir -p .vscode echo "{" > .vscode/settings.json @@ -98,7 +106,7 @@ echo "}" >> .vscode/settings.json ${pkgs.cmake}/bin/cmake -S . -B build -G Ninja \ - -Dlibmahjong_build_tests=ON \ + -Dlibmahjong_build_tests=OFF \ -DCMAKE_CXX_FLAGS="-O1 -g" ''; }; diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 78f19dcb..4e413dcd 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -17,6 +17,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + [[package]] name = "anyhow" version = "1.0.99" @@ -49,6 +55,16 @@ name = "bitflags" version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" +dependencies = [ + "bytemuck", + "serde", +] + +[[package]] +name = "bytemuck" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" [[package]] name = "bytes" @@ -71,6 +87,32 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "clap" +version = "4.5.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + [[package]] name = "cmake" version = "0.1.54" @@ -80,6 +122,92 @@ dependencies = [ "cc", ] +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "cxx" +version = "1.0.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4a0beb369d20d0de6aa7084ee523e4c9a31d7d8c61ba357b119bb574d7f368" +dependencies = [ + "cc", + "cxx-build", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d955b93e56a8e45cbc34df0ae920d8b5ad01541a4571222c78527c00e1a40a" +dependencies = [ + "cc", + "codespan-reporting", + "indexmap", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052f6c468d9dabdc2b8b228bcb2d7843b2bea0f3fb9c4e2c6ba5852574ec0150" +dependencies = [ + "clap", + "codespan-reporting", + "indexmap", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fd145fa180986cb8002c63217d03b2c782fdcd5fa323adcd1f62d2d6ece6144" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02ac4a3bc4484a2daa0a8421c9588bd26522be9682a2fe02c7087bc4e8bc3c60" +dependencies = [ + "indexmap", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "futures" version = "0.3.31" @@ -175,6 +303,22 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "indexmap" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92119844f513ffa41556430369ab02c295a3578af21cf945caa3e9e0c2481ac3" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "io-uring" version = "0.7.10" @@ -197,12 +341,24 @@ name = "libmahjong-rs" version = "0.1.0" dependencies = [ "anyhow", + "bitflags", "cmake", + "cxx", + "cxx-build", "futures", "thiserror", "tokio", ] +[[package]] +name = "link-cplusplus" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" +dependencies = [ + "cc", +] + [[package]] name = "lock_api" version = "0.4.13" @@ -316,12 +472,54 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scratch" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" + +[[package]] +name = "serde" +version = "1.0.225" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.225" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.225" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "shlex" version = "1.3.0" @@ -359,6 +557,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.106" @@ -370,6 +574,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "2.0.16" @@ -427,12 +640,27 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-width" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index a09788ba..c4413a99 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -5,6 +5,8 @@ edition = "2024" [dependencies] thiserror = "2" +cxx = "1" +bitflags = { version = "2", features = ["serde", "bytemuck"] } [dev-dependencies] anyhow = "1" @@ -13,3 +15,4 @@ futures = "0.3" [build-dependencies] cmake = "0.1" +cxx-build = "1" diff --git a/rust/build.rs b/rust/build.rs index 2b845c32..b3b6747f 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -1,8 +1,33 @@ -use cmake; +use std::path::PathBuf; fn main() { - let dst = cmake::Config::new("..").build_target("libmahjong").build(); + let dst = cmake::Config::new("..") + .build_target("libmahjong") + .define("libmahjong_build_tests", "OFF") + .define("libmahjong_build_tools", "OFF") + .define("libmahjong_use_clang_utils", "OFF") + .define("BUILD_SHARED_LIBS", "ON") + .build(); println!("cargo:rustc-link-search=native={}/build", dst.display()); println!("cargo:rustc-link-lib=dylib=mahjong"); + + let cpp_src_dir = PathBuf::from("..").join("src"); + let rust_include_dir = PathBuf::from("include"); + + // All bridge files + let bridge_files = vec!["src/types/wind.rs", "src/types/piece.rs"]; + + let mut bridge_build = cxx_build::bridges(bridge_files.clone()); + bridge_build + .include(&cpp_src_dir) + .include(&rust_include_dir) + .std("c++23") + .compile("libmahjong-bridge"); + + for file in bridge_files { + println!("cargo:rerun-if-changed={}", file); + } + + println!("cargo:rerun-if-changed=../src"); } diff --git a/rust/include/piece_adapter.h b/rust/include/piece_adapter.h new file mode 100644 index 00000000..ef41da31 --- /dev/null +++ b/rust/include/piece_adapter.h @@ -0,0 +1,13 @@ +#pragma once +#include "types/piecetype.h" + +namespace mahjong { + +// Piece::Type is hard to map into rust, formPieceFromU8 allows us to supply +// a uint8_t for the suit instead +inline Piece formPieceFromU8(uint8_t suit, uint8_t number, + bool is_red_five = false) { + return Piece::formPiece(static_cast(suit), number, is_red_five); +} + +} // namespace mahjong diff --git a/rust/src/ffi/error.rs b/rust/src/ffi/error.rs deleted file mode 100644 index b7befad1..00000000 --- a/rust/src/ffi/error.rs +++ /dev/null @@ -1,14 +0,0 @@ -#[derive(Debug, thiserror::Error)] -/// Error type for Mahjong FFI operations -pub enum MahjongFFIError { - #[error("Failed to create CString from Rust string")] - FailedToCreateCString, - #[error("Failed to allocate a game with given settings")] - FailedToAllocateGameState, - #[error("The game has ended")] - GameEnded, - #[error("Mutex was poisoned")] - MutexPoisoned, - #[error("Game state was already consumed")] - GameStateConsumed, -} diff --git a/rust/src/ffi/gamesettings.rs b/rust/src/ffi/gamesettings.rs deleted file mode 100644 index 726a9641..00000000 --- a/rust/src/ffi/gamesettings.rs +++ /dev/null @@ -1,36 +0,0 @@ -use std::ffi::{c_char, c_int, CString}; - -use crate::settings::GameSettings; - -use super::error::MahjongFFIError; - -/// C-compatible GameSettings structure -#[repr(C)] -pub struct CGameSettings { - pub seed: u64, - pub seat_controllers: [*const c_char; 4], - pub num_controllers: c_int, -} - -fn try_string_to_cstring(s: &str) -> Result<*const c_char, MahjongFFIError> { - CString::new(s) - .map(|cstr| cstr.into_raw() as *const c_char) - .map_err(|_| MahjongFFIError::FailedToCreateCString) -} - -impl TryFrom for CGameSettings { - type Error = MahjongFFIError; - - fn try_from(value: GameSettings) -> Result { - Ok(Self { - seed: value.seed, - seat_controllers: [ - try_string_to_cstring(&value.seat_controllers[0])?, - try_string_to_cstring(&value.seat_controllers[1])?, - try_string_to_cstring(&value.seat_controllers[2])?, - try_string_to_cstring(&value.seat_controllers[3])?, - ], - num_controllers: 4, - }) - } -} diff --git a/rust/src/ffi/gamestate.rs b/rust/src/ffi/gamestate.rs deleted file mode 100644 index d6b8d3fa..00000000 --- a/rust/src/ffi/gamestate.rs +++ /dev/null @@ -1,126 +0,0 @@ -use std::ffi::c_int; -use std::sync::{Arc, Mutex, MutexGuard}; - -use super::{error::MahjongFFIError, gamesettings::CGameSettings, observe::ObserveGameState}; -use crate::observe::ObservedGameState; - -/// Opaque type representing a GameState -#[repr(C)] -pub struct RawGameState { - _data: [u8; 0], - _marker: std::marker::PhantomData<(*mut u8, std::marker::PhantomPinned)>, -} - -#[link(name = "mahjong")] -unsafe extern "C" { - /// Start a game with the given settings - pub fn StartGame(settings: *const CGameSettings, async_mode: bool) -> c_int; - - /// Exit a game by its ID - pub fn ExitGame(game: c_int); - - /// Initialize a new game state - pub fn InitGameState(settings: *const CGameSettings) -> *mut RawGameState; - - /// Advance the game state to the next state - pub fn AdvanceGameState(state: *mut RawGameState) -> *mut RawGameState; - - /// Free a game state - pub fn FreeGameState(state: *mut RawGameState); -} - -/// Safe wrapper for GameState -pub struct GameState { - ptr: Arc>>, -} - -// Safe due to the use of mutexes -unsafe impl Send for GameState {} -unsafe impl Sync for GameState {} - -impl GameState { - /// Create a new game state from settings - pub fn new>( - settings: S, - ) -> Result { - let ptr = unsafe { - let settings: CGameSettings = settings.try_into()?; - Ok(InitGameState(&settings)) - }?; - if ptr.is_null() { - Err(MahjongFFIError::FailedToAllocateGameState) - } else { - Ok(Self { - ptr: Arc::new(Mutex::new(Some(ptr))), - }) - } - } - - /// Advance the game state - pub fn advance(self) -> Result { - let mut guard = self - .ptr - .lock() - .map_err(|_| MahjongFFIError::MutexPoisoned)?; - - let take_ptr = guard.take(); - - if let Some(ptr) = take_ptr { - let new_ptr = unsafe { AdvanceGameState(ptr) }; - let _ = ptr; // Prevent double-free since C++ takes ownership - - if new_ptr.is_null() { - Err(MahjongFFIError::GameEnded) - } else { - Ok(Self { - ptr: Arc::new(Mutex::new(Some(new_ptr))), - }) - } - } else { - Err(MahjongFFIError::GameStateConsumed) - } - } - - /// Observe the current game state - pub fn observe(&self) -> Option { - let guard = self.ptr.lock().ok()?; - - if let Some(ptr) = *guard { - let c_observed = unsafe { ObserveGameState(ptr) }; - Some(c_observed.into()) - } else { - None - } - } - - /// Get the raw pointer (sync version for internal use) - pub fn as_ptr( - &self, - ) -> Result< - MutexGuard<'_, Option<*mut RawGameState>>, - std::sync::PoisonError>>, - > { - self.ptr.lock() - } -} - -impl Drop for GameState { - fn drop(&mut self) { - // Now we can safely lock synchronously in Drop - if let Ok(mut guard) = self.ptr.lock() { - if let Some(ptr) = guard.take() { - unsafe { FreeGameState(ptr) }; - } - } - // If the mutex is poisoned, we can't clean up safely - } -} - -/// Safe wrapper for game operations -pub fn start_game(settings: &CGameSettings, async_mode: bool) -> c_int { - unsafe { StartGame(settings as *const CGameSettings, async_mode) } -} - -pub fn exit_game(game_id: c_int) { - unsafe { ExitGame(game_id) } -} diff --git a/rust/src/ffi/mod.rs b/rust/src/ffi/mod.rs deleted file mode 100644 index b4818605..00000000 --- a/rust/src/ffi/mod.rs +++ /dev/null @@ -1,175 +0,0 @@ -pub mod error; -pub mod gamesettings; -pub mod gamestate; -pub mod observe; - -#[cfg(test)] -mod tests { - use crate::ffi::gamestate::GameState; - use crate::observe::StateFunctionType; - use crate::settings::GameSettings; - use futures::future::join_all; - use tokio::time::{sleep, Duration}; - - #[test] - fn can_create_game() -> anyhow::Result<()> { - let settings = GameSettings { - seed: 12345, - seat_controllers: [ - "AlphabeticalBot".to_string(), - "AlphabeticalBot".to_string(), - "AlphabeticalBot".to_string(), - "AlphabeticalBot".to_string(), - ], - }; - - let game_state = GameState::new(settings)?; - - assert!( - game_state - .as_ptr() - .map_err(|e| anyhow::anyhow!("Failed to lock game state: {}", e))? - .is_some(), - "Game state pointer should not be null" - ); - - Ok(()) - } - - #[test] - fn can_run_game() -> anyhow::Result<()> { - let settings = GameSettings { - seed: 12345, - seat_controllers: [ - "AlphabeticalBot".to_string(), - "AlphabeticalBot".to_string(), - "AlphabeticalBot".to_string(), - "AlphabeticalBot".to_string(), - ], - }; - - let mut game_state = GameState::new(settings)?; - - let mut counter = 0; - loop { - if counter < 5 { - counter += 1; - game_state = game_state.advance()?; - } else { - break; - } - } - - assert!( - game_state - .as_ptr() - .map_err(|e| anyhow::anyhow!("Failed to lock game state: {}", e))? - .is_some(), - "Game state pointer shouldnt be null" - ); - - Ok(()) - } - - #[test] - fn can_observe_game_state() -> anyhow::Result<()> { - let settings = GameSettings { - seed: 12345, - seat_controllers: [ - "AlphabeticalBot".to_string(), - "AlphabeticalBot".to_string(), - "AlphabeticalBot".to_string(), - "AlphabeticalBot".to_string(), - ], - }; - - let game_state = GameState::new(settings)?; - - // Test that we can observe the game state - let observed = game_state - .observe() - .ok_or(anyhow::anyhow!("Failed to observe game state"))?; - - // Basic sanity checks - newly created game state will have initial values - assert_eq!(observed.seed(), 12345); - assert_eq!(observed.current_player(), -1); - assert_eq!(observed.current_state(), StateFunctionType::Error); - - // Check that we have correct array sizes - assert_eq!(observed.hands().len(), 4); - assert_eq!(observed.scores().len(), 4); - assert_eq!(observed.points().len(), 4); - assert_eq!(observed.has_ronned().len(), 4); - - // Advance the game state - let current_gs = game_state.advance()?; - let observed = current_gs.observe().ok_or(anyhow::anyhow!( - "Failed to observe game state after advance" - ))?; - - // Observe that the next game state is what's expected - assert_eq!(observed.current_state(), StateFunctionType::GameStart); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread")] - async fn can_run_game_async() -> anyhow::Result<()> { - let mut game_states = vec![]; - for i in 0..25 { - let settings = GameSettings { - seed: 12345 + i, - seat_controllers: [ - "AlphabeticalBot".to_string(), - "AlphabeticalBot".to_string(), - "AlphabeticalBot".to_string(), - "AlphabeticalBot".to_string(), - ], - }; - game_states.push(GameState::new(settings)?); - } - - let futures = game_states.into_iter().map(|gs| { - tokio::spawn(async move { - let mut current_gs = gs; - for _ in 0..3 { - sleep(Duration::from_millis(10)).await; - current_gs = current_gs.advance()?; - } - Ok::<_, anyhow::Error>(current_gs) - }) - }); - - let results = join_all(futures).await; - - for result in results { - let game_state = result??; - let observed = game_state - .observe() - .ok_or(anyhow::anyhow!("Failed to observe game state"))?; - assert_ne!(observed.current_state(), StateFunctionType::Error); - assert_ne!(observed.current_state(), StateFunctionType::GameStart); - } - - Ok(()) - } - - #[test] - fn invalid_controllers_noabort() { - let settings = GameSettings { - seed: 12345, - seat_controllers: [ - "FakeBot".to_string(), - "FakeBot".to_string(), - "FakeBot".to_string(), - "FakeBot".to_string(), - ], - }; - - let state = GameState::new(settings); - assert!( - state.is_err(), - "Game should not start with invalid controllers" - ); - } -} diff --git a/rust/src/ffi/observe.rs b/rust/src/ffi/observe.rs deleted file mode 100644 index f6a42c59..00000000 --- a/rust/src/ffi/observe.rs +++ /dev/null @@ -1,149 +0,0 @@ -use super::gamestate::RawGameState; -use std::ffi::c_int; - -/// C API constants for observed game state -pub const MAX_LIVE_HAND_SIZE: usize = 14; -pub const MAX_MELDS_PER_HAND: usize = 4; -pub const MAX_DISCARDS_PER_PLAYER: usize = 21; - -/// Type aliases for C types -pub type CPiece = c_int; - -/// C Meld Type enum -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CMeldType { - Chi = 0, - Pon = 1, - Kan = 2, - ConcealedKan = 3, -} - -/// C State Function Type enum -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CStateFunctionType { - Error = 0, - GameStart = 1, - RoundStart = 2, - Draw = 3, - PlayerHand = 4, - Pon = 5, - Chi = 6, - Kan = 7, - ConcealedKan = 8, - ConvertedKan = 9, - KanDiscard = 10, - Replacement = 11, - Riichi = 12, - Discard = 13, - Exhaust = 14, - Ron = 15, - Tsumo = 16, - RoundEnd = 17, - GameEnd = 18, -} - -/// C Meld structure -#[repr(C)] -#[derive(Debug, Clone, Copy)] -pub struct CMeld { - pub meld_type: CMeldType, - pub start: CPiece, -} - -/// C Hand structure -#[repr(C)] -#[derive(Debug, Clone, Copy)] -pub struct CHand { - pub live_pieces: [CPiece; MAX_LIVE_HAND_SIZE], - pub live_piece_count: c_int, - pub melds: [CMeld; MAX_MELDS_PER_HAND], - pub meld_count: c_int, - pub discards: [CPiece; MAX_DISCARDS_PER_PLAYER], - pub discard_count: c_int, - pub open: bool, - pub riichi: bool, - pub riichi_piece_discard: c_int, - pub riichi_round: c_int, -} - -/// C Observed Game State structure -#[repr(C)] -#[derive(Debug, Clone)] -pub struct CObservedGameState { - pub current_player: c_int, - pub turn_num: c_int, - pub round_num: c_int, - pub riichi_sticks: c_int, - pub counters: c_int, - pub last_call: c_int, - pub last_caller: c_int, - pub concealed_kan: bool, - pub seed: u64, - pub pending_piece: CPiece, - pub scores: [c_int; 4], - pub points: [c_int; 4], - pub has_ronned: [bool; 4], - pub hands: [CHand; 4], - pub prev_state: CStateFunctionType, - pub curr_state: CStateFunctionType, - pub next_state: CStateFunctionType, -} - -impl Default for CMeld { - fn default() -> Self { - Self { - meld_type: CMeldType::Chi, - start: 0, - } - } -} - -impl Default for CHand { - fn default() -> Self { - Self { - live_pieces: [0; MAX_LIVE_HAND_SIZE], - live_piece_count: 0, - melds: [CMeld::default(); MAX_MELDS_PER_HAND], - meld_count: 0, - discards: [0; MAX_DISCARDS_PER_PLAYER], - discard_count: 0, - open: false, - riichi: false, - riichi_piece_discard: 0, - riichi_round: 0, - } - } -} - -impl Default for CObservedGameState { - fn default() -> Self { - Self { - current_player: 0, - turn_num: 0, - round_num: 0, - riichi_sticks: 0, - counters: 0, - last_call: 0, - last_caller: 0, - concealed_kan: false, - seed: 0, - pending_piece: 0, - scores: [0; 4], - points: [0; 4], - has_ronned: [false; 4], - hands: [CHand::default(); 4], - prev_state: CStateFunctionType::Error, - curr_state: CStateFunctionType::Error, - next_state: CStateFunctionType::Error, - } - } -} - -// FFI function declaration -#[link(name = "mahjong")] -unsafe extern "C" { - /// Observe the current game state - pub fn ObserveGameState(state: *mut RawGameState) -> CObservedGameState; -} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index d972fd30..f526c517 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,79 +1,3 @@ -// FFI (Foreign Function Interface) for Mahjong game controller +//! Rust bindings for libmahjong -pub mod ffi; -pub mod observe; -pub mod settings; - -#[cfg(test)] -mod tests { - use crate::{ffi::gamestate::GameState, observe::StateFunctionType, settings::GameSettings}; - use std::{collections::VecDeque, fs::File, io::Write}; - - #[test] - fn can_run_match() { - let settings = GameSettings { - seed: 1, - seat_controllers: [ - "AngryDiscardoBot".to_string(), - "AngryDiscardoBot".to_string(), - "AngryDiscardoBot".to_string(), - "AngryDiscardoBot".to_string(), - ], - }; - - let mut log = File::create("test_match.log").unwrap(); - - log.write_fmt(format_args!( - "Starting game with settings: {:?}\n", - settings - )) - .unwrap(); - let mut game_state = GameState::new(settings).unwrap(); - - let mut counter = 0; - let mut history: VecDeque<_> = VecDeque::with_capacity(20); - - let mut last_observed = None; - while let Some(observed) = game_state.observe() { - log.write_fmt(format_args!( - "Turn {}: {:?} -> {:?} -> {:?}\n", - counter, observed.prev_state, observed.curr_state, observed.next_state - )) - .unwrap(); - - let should_end = observed.curr_state == StateFunctionType::GameEnd; - - if history.len() == 20 { - history.pop_front(); - } - - history.push_back(( - observed.prev_state, - observed.curr_state, - observed.next_state, - )); - - last_observed = Some(observed); - if should_end { - log.write_fmt(format_args!("Game ended at turn {}\n", counter)) - .unwrap(); - break; - } - - game_state = game_state.advance().unwrap(); - - counter += 1; - if counter > 1000 { - panic!( - "Game state did not end after 1000 turns. Observed is {:?}\nHistory: {:#?}", - last_observed, history - ); - } - } - - assert_eq!( - last_observed.unwrap().current_state(), - StateFunctionType::GameEnd - ); - } -} +pub mod types; diff --git a/rust/src/observe.rs b/rust/src/observe.rs deleted file mode 100644 index dd05e25f..00000000 --- a/rust/src/observe.rs +++ /dev/null @@ -1,279 +0,0 @@ -use crate::ffi::observe::{CHand, CMeld, CMeldType, CObservedGameState, CStateFunctionType}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MeldType { - Chi, - Pon, - Kan, - ConcealedKan, -} - -impl From for MeldType { - fn from(c_meld_type: CMeldType) -> Self { - match c_meld_type { - CMeldType::Chi => MeldType::Chi, - CMeldType::Pon => MeldType::Pon, - CMeldType::Kan => MeldType::Kan, - CMeldType::ConcealedKan => MeldType::ConcealedKan, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StateFunctionType { - Error, - GameStart, - RoundStart, - Draw, - PlayerHand, - Pon, - Chi, - Kan, - ConcealedKan, - ConvertedKan, - KanDiscard, - Replacement, - Riichi, - Discard, - Exhaust, - Ron, - Tsumo, - RoundEnd, - GameEnd, -} - -impl From for StateFunctionType { - fn from(c_state_type: CStateFunctionType) -> Self { - match c_state_type { - CStateFunctionType::Error => StateFunctionType::Error, - CStateFunctionType::GameStart => StateFunctionType::GameStart, - CStateFunctionType::RoundStart => StateFunctionType::RoundStart, - CStateFunctionType::Draw => StateFunctionType::Draw, - CStateFunctionType::PlayerHand => StateFunctionType::PlayerHand, - CStateFunctionType::Pon => StateFunctionType::Pon, - CStateFunctionType::Chi => StateFunctionType::Chi, - CStateFunctionType::Kan => StateFunctionType::Kan, - CStateFunctionType::ConcealedKan => StateFunctionType::ConcealedKan, - CStateFunctionType::ConvertedKan => StateFunctionType::ConvertedKan, - CStateFunctionType::KanDiscard => StateFunctionType::KanDiscard, - CStateFunctionType::Replacement => StateFunctionType::Replacement, - CStateFunctionType::Riichi => StateFunctionType::Riichi, - CStateFunctionType::Discard => StateFunctionType::Discard, - CStateFunctionType::Exhaust => StateFunctionType::Exhaust, - CStateFunctionType::Ron => StateFunctionType::Ron, - CStateFunctionType::Tsumo => StateFunctionType::Tsumo, - CStateFunctionType::RoundEnd => StateFunctionType::RoundEnd, - CStateFunctionType::GameEnd => StateFunctionType::GameEnd, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Meld { - pub meld_type: MeldType, - pub start: i32, -} - -impl From for Meld { - fn from(c_meld: CMeld) -> Self { - Self { - meld_type: c_meld.meld_type.into(), - start: c_meld.start, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Hand { - pub live_pieces: Vec, - pub melds: Vec, - pub discards: Vec, - pub open: bool, - pub riichi: bool, - pub riichi_piece_discard: i32, - pub riichi_round: i32, -} - -impl From for Hand { - fn from(c_hand: CHand) -> Self { - let live_pieces = c_hand.live_pieces[..c_hand.live_piece_count as usize].to_vec(); - - let melds = c_hand.melds[..c_hand.meld_count as usize] - .iter() - .map(|&meld| meld.into()) - .collect(); - - let discards = c_hand.discards[..c_hand.discard_count as usize].to_vec(); - - Self { - live_pieces, - melds, - discards, - open: c_hand.open, - riichi: c_hand.riichi, - riichi_piece_discard: c_hand.riichi_piece_discard, - riichi_round: c_hand.riichi_round, - } - } -} - -impl Hand { - /// Get the number of live pieces in the hand - pub fn live_piece_count(&self) -> usize { - self.live_pieces.len() - } - - /// Get the number of melds in the hand - pub fn meld_count(&self) -> usize { - self.melds.len() - } - - /// Get the number of discards in the hand - pub fn discard_count(&self) -> usize { - self.discards.len() - } - - /// Add a piece to the live pieces (example method showing Vec usage) - pub fn add_live_piece(&mut self, piece: i32) { - self.live_pieces.push(piece); - } - - /// Add a meld to the hand (example method showing Vec usage) - pub fn add_meld(&mut self, meld: Meld) { - self.melds.push(meld); - } - - /// Add a discard to the hand (example method showing Vec usage) - pub fn add_discard(&mut self, piece: i32) { - self.discards.push(piece); - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ObservedGameState { - pub current_player: i32, - pub turn_num: i32, - pub round_num: i32, - pub riichi_sticks: i32, - pub counters: i32, - pub last_call: i32, - pub last_caller: i32, - pub concealed_kan: bool, - pub seed: u64, - pub pending_piece: i32, - pub scores: [i32; 4], - pub points: [i32; 4], - pub has_ronned: [bool; 4], - pub hands: [Hand; 4], - pub prev_state: StateFunctionType, - pub curr_state: StateFunctionType, - pub next_state: StateFunctionType, -} - -impl From for ObservedGameState { - fn from(c_state: CObservedGameState) -> Self { - let hands = [ - c_state.hands[0].into(), - c_state.hands[1].into(), - c_state.hands[2].into(), - c_state.hands[3].into(), - ]; - - Self { - current_player: c_state.current_player, - turn_num: c_state.turn_num, - round_num: c_state.round_num, - riichi_sticks: c_state.riichi_sticks, - counters: c_state.counters, - last_call: c_state.last_call, - last_caller: c_state.last_caller, - concealed_kan: c_state.concealed_kan, - seed: c_state.seed, - pending_piece: c_state.pending_piece, - scores: c_state.scores, - points: c_state.points, - has_ronned: c_state.has_ronned, - hands, - prev_state: c_state.prev_state.into(), - curr_state: c_state.curr_state.into(), - next_state: c_state.next_state.into(), - } - } -} - -impl ObservedGameState { - /// Get the current player index - pub fn current_player(&self) -> i32 { - self.current_player - } - - /// Get the current turn number - pub fn turn_num(&self) -> i32 { - self.turn_num - } - - /// Get the current round number - pub fn round_num(&self) -> i32 { - self.round_num - } - - /// Get the number of riichi sticks on the table - pub fn riichi_sticks(&self) -> i32 { - self.riichi_sticks - } - - /// Get the honba counters - pub fn counters(&self) -> i32 { - self.counters - } - - /// Get the seed used for the game - pub fn seed(&self) -> u64 { - self.seed - } - - /// Get the current pending piece - pub fn pending_piece(&self) -> i32 { - self.pending_piece - } - - /// Get the scores for all players - pub fn scores(&self) -> &[i32; 4] { - &self.scores - } - - /// Get the points for all players - pub fn points(&self) -> &[i32; 4] { - &self.points - } - - /// Get the hands for all players - pub fn hands(&self) -> &[Hand; 4] { - &self.hands - } - - /// Get the current state function type - pub fn current_state(&self) -> StateFunctionType { - self.curr_state - } - - /// Get the previous state function type - pub fn previous_state(&self) -> StateFunctionType { - self.prev_state - } - - /// Get the next state function type - pub fn next_state(&self) -> StateFunctionType { - self.next_state - } - - /// Check if concealed kan was just performed - pub fn concealed_kan(&self) -> bool { - self.concealed_kan - } - - /// Get which players have declared ron this turn - pub fn has_ronned(&self) -> &[bool; 4] { - &self.has_ronned - } -} diff --git a/rust/src/settings.rs b/rust/src/settings.rs deleted file mode 100644 index 9a496d8a..00000000 --- a/rust/src/settings.rs +++ /dev/null @@ -1,5 +0,0 @@ -#[derive(Debug, Clone)] -pub struct GameSettings { - pub seed: u64, - pub seat_controllers: [String; 4], -} diff --git a/rust/src/types/error.rs b/rust/src/types/error.rs new file mode 100644 index 00000000..88bd1677 --- /dev/null +++ b/rust/src/types/error.rs @@ -0,0 +1,7 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum MahjongError { + #[error("Invalid piece value: {value:#04x}")] + InvalidPiece { value: u8 }, +} diff --git a/rust/src/types/mod.rs b/rust/src/types/mod.rs new file mode 100644 index 00000000..145114b7 --- /dev/null +++ b/rust/src/types/mod.rs @@ -0,0 +1,7 @@ +pub mod error; +pub mod piece; +pub mod wind; + +pub use error::MahjongError; +pub use piece::Piece; +pub use wind::Wind; diff --git a/rust/src/types/piece.rs b/rust/src/types/piece.rs new file mode 100644 index 00000000..a3001420 --- /dev/null +++ b/rust/src/types/piece.rs @@ -0,0 +1,484 @@ +use crate::types::{MahjongError, Wind}; +use bitflags::{bitflags, bitflags_match}; +use std::convert::TryFrom; +use std::fmt::{self, Display}; +use std::ops::{Add, Sub}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Suit { + Honor, + Bamboo, + Pin, + Character, +} + +impl Display for Suit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let suit = match self { + Suit::Honor => "z", + Suit::Bamboo => "s", + Suit::Pin => "p", + Suit::Character => "m", + }; + + write!(f, "{suit}") + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Piece(u8); + +impl PartialEq for Piece { + fn eq(&self, other: &Self) -> bool { + (self.0 | Piece::RedFive.bits()) == (other.0 | Piece::RedFive.bits()) + } +} + +impl Eq for Piece {} + +bitflags! { + impl Piece: u8 { + const TerminalBit = 1 << 7; + const HonorSuit = 0 << 5; + const BambooSuit = 1 << 5; + const PinSuit = 2 << 5; + const CharacterSuit = 3 << 5; + const RedFive = 1 << 4; + const Error = 0; + + // Bamboo pieces + const OneBamboo = Piece::BambooSuit.bits() | 1 | Piece::TerminalBit.bits(); + const TwoBamboo = Piece::BambooSuit.bits() | 2; + const ThreeBamboo = Piece::BambooSuit.bits() | 3; + const FourBamboo = Piece::BambooSuit.bits() | 4; + const FiveBamboo = Piece::BambooSuit.bits() | 5; + const RedFiveBamboo = Piece::BambooSuit.bits() | 5 | Piece::RedFive.bits(); + const SixBamboo = Piece::BambooSuit.bits() | 6; + const SevenBamboo = Piece::BambooSuit.bits() | 7; + const EightBamboo = Piece::BambooSuit.bits() | 8; + const NineBamboo = Piece::BambooSuit.bits() | 9 | Piece::TerminalBit.bits(); + + // Pin pieces + const OnePin = Piece::PinSuit.bits() | 1 | Piece::TerminalBit.bits(); + const TwoPin = Piece::PinSuit.bits() | 2; + const ThreePin = Piece::PinSuit.bits() | 3; + const FourPin = Piece::PinSuit.bits() | 4; + const FivePin = Piece::PinSuit.bits() | 5; + const RedFivePin = Piece::PinSuit.bits() | 5 | Piece::RedFive.bits(); + const SixPin = Piece::PinSuit.bits() | 6; + const SevenPin = Piece::PinSuit.bits() | 7; + const EightPin = Piece::PinSuit.bits() | 8; + const NinePin = Piece::PinSuit.bits() | 9 | Piece::TerminalBit.bits(); + + // Man pieces + const OneCharacter = Piece::CharacterSuit.bits() | 1 | Piece::TerminalBit.bits(); + const TwoCharacter = Piece::CharacterSuit.bits() | 2; + const ThreeCharacter = Piece::CharacterSuit.bits() | 3; + const FourCharacter = Piece::CharacterSuit.bits() | 4; + const FiveCharacter = Piece::CharacterSuit.bits() | 5; + const RedFiveCharacter = Piece::CharacterSuit.bits() | 5 | Piece::RedFive.bits(); + const SixCharacter = Piece::CharacterSuit.bits() | 6; + const SevenCharacter = Piece::CharacterSuit.bits() | 7; + const EightCharacter = Piece::CharacterSuit.bits() | 8; + const NineCharacter = Piece::CharacterSuit.bits() | 9 | Piece::TerminalBit.bits(); + + // Honor pieces + const EastWind = Piece::HonorSuit.bits() | 1; + const SouthWind = Piece::HonorSuit.bits() | 2; + const WestWind = Piece::HonorSuit.bits() | 3; + const NorthWind = Piece::HonorSuit.bits() | 4; + const RedDragon = Piece::HonorSuit.bits() | 5; + const WhiteDragon = Piece::HonorSuit.bits() | 6; + const GreenDragon = Piece::HonorSuit.bits() | 7; + } +} + +unsafe impl cxx::ExternType for Piece { + type Id = cxx::type_id!("mahjong::Piece"); + type Kind = cxx::kind::Trivial; +} + +#[cxx::bridge(namespace = "mahjong")] +mod ffi { + unsafe extern "C++" { + include!("types/piecetype.h"); + include!("types/winds.h"); + include!("piece_adapter.h"); + + type Piece = super::Piece; + type Wind = crate::types::Wind; + + #[rust_name = "is_honor"] + fn isHonor(self: &Piece) -> bool; + #[rust_name = "is_terminal"] + fn isTerminal(self: &Piece) -> bool; + #[rust_name = "is_green"] + fn isGreen(self: &Piece) -> bool; + #[rust_name = "is_red_five"] + fn isRedFive(self: &Piece) -> bool; + #[rust_name = "is_board_piece"] + fn isBoardPiece(self: &Piece) -> bool; + #[rust_name = "as_u8"] + fn toUint8_t(self: &Piece) -> u8; + fn raw_value(self: &Piece) -> u8; + #[rust_name = "get_suit_num"] + fn getSuit(self: &Piece) -> u8; + #[rust_name = "get_piece_num"] + fn getPieceNum(self: &Piece) -> u8; + + #[rust_name = "from_wind"] + #[Self = "Piece"] + fn fromWind(w: Wind) -> Piece; + + #[rust_name = "form_piece_raw"] + fn formPieceFromU8(suit: u8, number: u8, is_red_five: bool) -> Piece; + } +} + +impl fmt::Display for Piece { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(suit) = self.suit() { + let num = self.get_piece_num(); + let is_red = self.is_red_five(); + + write!(f, "{}{}{}", if is_red { "r" } else { "" }, num, suit) + } else { + write!(f, "{}?", self.as_u8()) + } + } +} + +impl Piece { + /// Returns a suit if it's a suited piece + pub fn suit(&self) -> Option { + let suit_piece = Piece(self.get_suit_num()); + bitflags_match!(suit_piece, { + Piece::HonorSuit => Some(Suit::Honor), + Piece::BambooSuit => Some(Suit::Bamboo), + Piece::PinSuit => Some(Suit::Pin), + Piece::CharacterSuit => Some(Suit::Character), + _ => None, + }) + } + + /// Form a suited piece + pub fn form_piece(suit: Suit, number: u8, is_red_five: bool) -> Result { + let suit_bits = match suit { + Suit::Honor => Piece::HonorSuit.bits(), + Suit::Bamboo => Piece::BambooSuit.bits(), + Suit::Pin => Piece::PinSuit.bits(), + Suit::Character => Piece::CharacterSuit.bits(), + }; + + let piece = ffi::form_piece_raw(suit_bits, number, is_red_five); + + if piece.raw_value() == 0 { + Err(MahjongError::InvalidPiece { value: number }) + } else { + Ok(piece) + } + } + + /// Convert to the wind a Wind piece is associated with it + pub fn to_wind(&self) -> Option { + bitflags_match!(*self, { + Piece::EastWind => Some(Wind::East), + Piece::SouthWind => Some(Wind::South), + Piece::WestWind => Some(Wind::West), + Piece::NorthWind => Some(Wind::North), + _ => None, + }) + } + + /// Returns true if the piece is a Wind piece + pub fn is_wind(&self) -> bool { + *self == Piece::EastWind + || *self == Piece::SouthWind + || *self == Piece::WestWind + || *self == Piece::NorthWind + } + + /// Returns true if the piece is a Dragon Piece + pub fn is_dragon(&self) -> bool { + *self == Piece::RedDragon || *self == Piece::WhiteDragon || *self == Piece::GreenDragon + } + + /// Returns true if the piece is a terminal or honor piece + pub fn is_terminal_or_honor(&self) -> bool { + self.is_terminal() || self.is_honor() + } + + /// If the tile is a numbered piece, will return the next one in sequence + /// + /// If the piece is the final piece in the sequence, will return `None` + pub fn next(&self) -> Option { + if self.is_honor() || self.get_piece_num() >= 9 { + None + } else { + (*self + 1).ok() + } + } + + /// If the tile is a numbered piece, will return the previous one in sequence + /// + /// If the piece is the first piece in the sequence, will return `None` + pub fn prev(&self) -> Option { + if self.is_honor() || self.get_piece_num() <= 1 { + None + } else { + (*self - 1).ok() + } + } + + /// Add to piece number within the same suit, returning None if out of bounds + pub fn add_within_suit(&self, n: u8) -> Option { + let suit = self.suit()?; + if suit == Suit::Honor { + return None; + } + + let current_num = self.get_piece_num(); + let new_num = current_num.checked_add(n)?; + if new_num > 9 { + return None; + } + + Piece::form_piece(suit, new_num, self.is_red_five()).ok() + } + + /// Subtract from piece number within the same suit, returning None if out of bounds + pub fn sub_within_suit(&self, n: u8) -> Option { + let suit = self.suit()?; + if suit == Suit::Honor { + return None; + } + + let current_num = self.get_piece_num(); + let new_num = current_num.checked_sub(n)?; + if new_num < 1 { + return None; + } + + Piece::form_piece(suit, new_num, self.is_red_five()).ok() + } +} + +impl Add for Piece { + type Output = Result; + + fn add(self, rhs: u8) -> Self::Output { + self.add_within_suit(rhs).ok_or(MahjongError::InvalidPiece { + value: self.as_u8().saturating_add(rhs), + }) + } +} + +impl Sub for Piece { + type Output = Result; + + fn sub(self, rhs: u8) -> Self::Output { + self.sub_within_suit(rhs).ok_or(MahjongError::InvalidPiece { + value: self.as_u8().saturating_sub(rhs), + }) + } +} + +impl TryFrom for Piece { + type Error = MahjongError; + + fn try_from(value: u8) -> Result { + Piece::from_bits(value).ok_or(MahjongError::InvalidPiece { value }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn piece_display() { + assert_eq!(format!("{}", Piece::OnePin), "1p"); + assert_eq!(format!("{}", Piece::RedFiveBamboo), "r5s"); + assert_eq!(format!("{}", Piece::EastWind), "1z"); + assert_eq!(format!("{}", Piece::RedDragon), "5z"); + } + + #[test] + fn piece_properties() { + let one_pin = Piece::OnePin; + let east_wind = Piece::EastWind; + let red_five = Piece::RedFiveBamboo; + + // Test honor detection + assert!(!one_pin.is_honor()); + assert!(east_wind.is_honor()); + + // Test terminal detection + assert!(one_pin.is_terminal()); + assert!(!Piece::TwoPin.is_terminal()); + assert!(Piece::NinePin.is_terminal()); + + // Test red five detection + assert!(!one_pin.is_red_five()); + assert!(red_five.is_red_five()); + + // Test suit detection + assert_eq!(one_pin.suit(), Some(Suit::Pin)); + assert_eq!(east_wind.suit(), Some(Suit::Honor)); + assert_eq!(Piece::TwoBamboo.suit(), Some(Suit::Bamboo)); + } + + #[test] + fn piece_equality() { + assert_eq!(Piece::FivePin, Piece::FivePin); + + assert_eq!(Piece::FivePin, Piece::RedFivePin); + assert_eq!(Piece::FiveBamboo, Piece::RedFiveBamboo); + } + + #[test] + fn piece_arithmetic() { + let five_pin = Piece::FivePin; + + let six_pin = (five_pin + 1).expect("Should be able to add 1 to five pin"); + assert_eq!(six_pin.get_piece_num(), 6); + assert_eq!(six_pin.suit(), Some(Suit::Pin)); + + let four_pin = (five_pin - 1).expect("Should be able to subtract 1 from five pin"); + assert_eq!(four_pin.get_piece_num(), 4); + assert_eq!(four_pin.suit(), Some(Suit::Pin)); + + let nine_pin = Piece::NinePin; + let error_piece = nine_pin + 1; + assert!(error_piece.is_err()); + + let one_pin = Piece::OnePin; + let error_piece2 = one_pin - 1; + assert!(error_piece2.is_err()); + } + + #[test] + fn piece_from_wind() { + let east_piece = Piece::from_wind(Wind::East); + assert_eq!(east_piece, Piece::EastWind); + assert!(east_piece.is_honor()); + + let south_piece = Piece::from_wind(Wind::South); + assert_eq!(south_piece, Piece::SouthWind); + + let west_piece = Piece::from_wind(Wind::West); + assert_eq!(west_piece, Piece::WestWind); + + let north_piece = Piece::from_wind(Wind::North); + assert_eq!(north_piece, Piece::NorthWind); + } + + #[test] + fn form_piece() { + let five_pin = Piece::form_piece(Suit::Pin, 5, false).expect("Should create five pin"); + assert_eq!(five_pin, Piece::FivePin); + assert_eq!(five_pin.get_piece_num(), 5); + assert!(!five_pin.is_red_five()); + + let red_five_pin = + Piece::form_piece(Suit::Pin, 5, true).expect("Should create red five pin"); + assert_eq!(red_five_pin, Piece::RedFivePin); + assert!(red_five_pin.is_red_five()); + + let one_bamboo = + Piece::form_piece(Suit::Bamboo, 1, false).expect("Should create one bamboo"); + assert_eq!(one_bamboo, Piece::OneBamboo); + assert!(one_bamboo.is_terminal()); + + let nine_char = + Piece::form_piece(Suit::Character, 9, false).expect("Should create nine character"); + assert_eq!(nine_char, Piece::NineCharacter); + assert!(nine_char.is_terminal()); + + let east_wind = Piece::form_piece(Suit::Honor, 1, false).expect("Should create east wind"); + assert_eq!(east_wind, Piece::EastWind); + assert!(east_wind.is_honor()); + + let red_dragon = + Piece::form_piece(Suit::Honor, 5, false).expect("Should create red dragon"); + assert_eq!(red_dragon, Piece::RedDragon); + assert!(red_dragon.is_honor()); + + let invalid = Piece::form_piece(Suit::Pin, 10, false); // Out of range + assert!(invalid.is_err()); + + let invalid_red_five = Piece::form_piece(Suit::Pin, 3, true); // Red five must be 5 + assert!(invalid_red_five.is_err()); + } + + #[test] + fn piece_to_wind() { + assert_eq!(Piece::EastWind.to_wind(), Some(Wind::East)); + assert_eq!(Piece::SouthWind.to_wind(), Some(Wind::South)); + assert_eq!(Piece::WestWind.to_wind(), Some(Wind::West)); + assert_eq!(Piece::NorthWind.to_wind(), Some(Wind::North)); + + assert_eq!(Piece::RedDragon.to_wind(), None); + assert_eq!(Piece::FivePin.to_wind(), None); + } + + #[test] + fn piece_is_wind() { + assert!(Piece::EastWind.is_wind()); + assert!(Piece::SouthWind.is_wind()); + assert!(Piece::WestWind.is_wind()); + assert!(Piece::NorthWind.is_wind()); + + assert!(!Piece::RedDragon.is_wind()); + assert!(!Piece::FivePin.is_wind()); + } + + #[test] + fn piece_is_dragon() { + assert!(Piece::RedDragon.is_dragon()); + assert!(Piece::WhiteDragon.is_dragon()); + assert!(Piece::GreenDragon.is_dragon()); + + assert!(!Piece::EastWind.is_dragon()); + assert!(!Piece::FivePin.is_dragon()); + } + + #[test] + fn sequence_operations() { + let five_pin = Piece::FivePin; + + assert_eq!(five_pin.next(), Some(Piece::SixPin)); + assert_eq!(five_pin.prev(), Some(Piece::FourPin)); + + assert_eq!(Piece::NinePin.next(), None); + assert_eq!(Piece::OnePin.prev(), None); + assert_eq!(Piece::EastWind.next(), None); + } + + #[test] + fn within_suit_arithmetic() { + let five_pin = Piece::FivePin; + + assert_eq!(five_pin.add_within_suit(0), Some(five_pin)); + assert_eq!(five_pin.add_within_suit(1), Some(Piece::SixPin)); + assert_eq!(five_pin.add_within_suit(2), Some(Piece::SevenPin)); + assert_eq!(five_pin.add_within_suit(4), Some(Piece::NinePin)); + assert_eq!(five_pin.add_within_suit(5), None); + + assert_eq!(five_pin.sub_within_suit(0), Some(five_pin)); + assert_eq!(five_pin.sub_within_suit(1), Some(Piece::FourPin)); + assert_eq!(five_pin.sub_within_suit(4), Some(Piece::OnePin)); + assert_eq!(five_pin.sub_within_suit(5), None); + + let eight_bamboo = Piece::EightBamboo; + let nine_bamboo = eight_bamboo.add_within_suit(1).unwrap(); + assert!(nine_bamboo.is_terminal()); + + let two_bamboo = Piece::TwoBamboo; + let one_bamboo = two_bamboo.sub_within_suit(1).unwrap(); + assert!(one_bamboo.is_terminal()); + + assert_eq!(Piece::EastWind.add_within_suit(1), None); + assert_eq!(Piece::RedDragon.sub_within_suit(1), None); + } +} diff --git a/rust/src/types/wind.rs b/rust/src/types/wind.rs new file mode 100644 index 00000000..0be16823 --- /dev/null +++ b/rust/src/types/wind.rs @@ -0,0 +1,81 @@ +use std::fmt; + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Wind { + East = 0, + South = 1, + West = 2, + North = 3, +} + +unsafe impl cxx::ExternType for Wind { + type Id = cxx::type_id!("mahjong::Wind"); + type Kind = cxx::kind::Trivial; +} + +#[cxx::bridge(namespace = "mahjong")] +mod ffi { + + unsafe extern "C++" { + include!("types/winds.h"); + + type Wind = super::Wind; + } +} + +impl fmt::Display for Wind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match *self { + Wind::East => "East", + Wind::South => "South", + Wind::West => "West", + Wind::North => "North", + }; + write!(f, "{}", s) + } +} + +impl Wind { + pub fn is_east(&self) -> bool { + matches!(*self, Wind::East) + } + + pub fn is_south(&self) -> bool { + matches!(*self, Wind::South) + } + + pub fn is_west(&self) -> bool { + matches!(*self, Wind::West) + } + + pub fn is_north(&self) -> bool { + matches!(*self, Wind::North) + } + + /// Get the next wind in clockwise order + pub fn next(&self) -> Wind { + match *self { + Wind::East => Wind::South, + Wind::South => Wind::West, + Wind::West => Wind::North, + Wind::North => Wind::East, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wind_enum() { + let east = Wind::East; + + assert_eq!(east.to_string(), "East"); + assert!(east.is_east()); + assert!(!east.is_south()); + assert_eq!(east.next(), Wind::South); + assert_eq!(Wind::North.next(), east); + } +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2b7c87ae..cace1251 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,7 +1,6 @@ set_target_properties(libmahjong PROPERTIES PREFIX "") set_target_properties(libmahjong PROPERTIES OUTPUT_NAME "libmahjong") -add_subdirectory(api) add_subdirectory(controllers) add_subdirectory(statefunctions) add_subdirectory(types) diff --git a/src/api/CMakeLists.txt b/src/api/CMakeLists.txt deleted file mode 100644 index f9ab1fed..00000000 --- a/src/api/CMakeLists.txt +++ /dev/null @@ -1,5 +0,0 @@ -target_sources(libmahjong PRIVATE - "gamestate.cc" PUBLIC FILE_SET HEADERS BASE_DIRS - "${libmahjong_SOURCE_DIR}/src" FILES - "gamestate.h" - "types.h") diff --git a/src/api/gamestate.cc b/src/api/gamestate.cc deleted file mode 100644 index b4966d0b..00000000 --- a/src/api/gamestate.cc +++ /dev/null @@ -1,189 +0,0 @@ -#include "api/gamestate.h" - -#include -#include -#include - -#include "api/types.h" -#include "controllers/controllermanager.h" -#include "statefunctions/statecontroller.h" -#include "types/gamestate.h" -#include "types/hand.h" -#include "types/settings.h" -#include "types/statefunction.h" - -namespace api { - -namespace { - -CStateFunctionType ConvertStateFunctionType(mahjong::StateFunctionType func) { - switch (func) { - case mahjong::StateFunctionType::kError: - return kError; - case mahjong::StateFunctionType::kGameStart: - return kGameStart; - case mahjong::StateFunctionType::kRoundStart: - return kRoundStart; - case mahjong::StateFunctionType::kDraw: - return kDraw; - case mahjong::StateFunctionType::kPlayerHand: - return kPlayerHand; - case mahjong::StateFunctionType::kPon: - return kPon; - case mahjong::StateFunctionType::kChi: - return kChi; - case mahjong::StateFunctionType::kKan: - return kKan; - case mahjong::StateFunctionType::kConcealedKan: - return kConcealedKan; - case mahjong::StateFunctionType::kConvertedKan: - return kConvertedKan; - case mahjong::StateFunctionType::kKanDiscard: - return kKanDiscard; - case mahjong::StateFunctionType::kReplacement: - return kReplacement; - case mahjong::StateFunctionType::kRiichi: - return kRiichi; - case mahjong::StateFunctionType::kDiscard: - return kDiscard; - case mahjong::StateFunctionType::kExhaust: - return kExhaust; - case mahjong::StateFunctionType::kRon: - return kRon; - case mahjong::StateFunctionType::kTsumo: - return kTsumo; - case mahjong::StateFunctionType::kRoundEnd: - return kRoundEnd; - case mahjong::StateFunctionType::kGameEnd: - return kGameEnd; - } - - // This should never be reached (unless we forget to add a new state function) - return kError; -} - -mahjong::GameSettings convertGameSettings(const CGameSettings* settings) { - mahjong::GameSettings cpp_settings; - - cpp_settings.seed = settings->seed; - - // Char arrays to vector - for (int i = 0; i < settings->num_controllers; i++) { - if (settings->seat_controllers[i]) { - cpp_settings.seatControllers.emplace_back(settings->seat_controllers[i]); - } - } - - return cpp_settings; -} -} // namespace - -extern "C" { - -int StartGame(const CGameSettings* settings, bool async) { - const mahjong::GameSettings cpp_settings = convertGameSettings(settings); - return mahjong::StartGame(cpp_settings, async); -} - -void ExitGame(int game) { - mahjong::ExitGame(game); -} - -bool IsValidGameController(const char* controller) { - return mahjong::ControllerManager::Instance() - .GetAvailableControllersMap() - .contains(controller); -} - -mahjong::GameState* InitGameState(const CGameSettings* settings) { - const mahjong::GameSettings cpp_settings = convertGameSettings(settings); - // Check controllers ahead of time as C++ throws will not be handled correctly - // by all library consumers (rust) - for (const auto& controller : cpp_settings.seatControllers) { - if (!IsValidGameController(controller.c_str())) { - return nullptr; - } - } - - std::unique_ptr state = - mahjong::InitGameState(cpp_settings); - return state.release(); -} - -mahjong::GameState* AdvanceGameState(mahjong::GameState* state) { - return mahjong::AdvanceGameState(std::unique_ptr(state)) - .release(); -} - -CObservedGameState ObserveGameState(mahjong::GameState* state) { - CObservedGameState observed = {}; - - if (!state) { - return observed; - } - - // Straightforward copies - observed.currentPlayer = state->currentPlayer; - observed.turnNum = state->turnNum; - observed.roundNum = state->roundNum; - observed.riichiSticks = state->riichiSticks; - observed.counters = state->counters; - observed.lastCall = state->lastCall; - observed.lastCaller = state->lastCaller; - observed.concealedKan = state->concealedKan; - observed.seed = state->seed; - observed.pendingPiece = static_cast(state->pendingPiece.toUint8_t()); - - // State function enums - observed.prevState = ConvertStateFunctionType(state->prevState); - observed.currState = ConvertStateFunctionType(state->currState); - observed.nextState = ConvertStateFunctionType(state->nextState); - - // Player data - for (int i = 0; i < 4; i++) { - observed.scores[i] = state->hands[i].score; - observed.points[i] = state->hands[i].points; - observed.hasRonned[i] = state->hands[i].hasRonned; - - // Convert Hand to CHand - const auto& cpp_hand = state->hands[i]; - CHand& c_hand = observed.hands[i]; - - // Live pieces - const int live_piece_count = static_cast(cpp_hand.live_count); - c_hand.livePieceCount = std::min(live_piece_count, kMaxLiveHandSize); - for (int j = 0; j < c_hand.livePieceCount; j++) { - c_hand.livePieces[j] = static_cast(cpp_hand.live[j].toUint8_t()); - } - - // Melds - const int meld_count = cpp_hand.meld_count; - c_hand.meldCount = std::min(meld_count, kMaxMeldsPerHand); - for (int j = 0; j < c_hand.meldCount; j++) { - c_hand.melds[j].type = static_cast(cpp_hand.melds[j].type); - c_hand.melds[j].start = - static_cast(cpp_hand.melds[j].start.toUint8_t()); - } - - // Discards - const auto& discards = cpp_hand.discards; - c_hand.discardCount = cpp_hand.discards_count; - for (auto j = 0; j < c_hand.discardCount; j++) { - c_hand.discards[j] = static_cast(discards[j].toUint8_t()); - } - - // Hand properties - c_hand.open = cpp_hand.open; - c_hand.riichi = cpp_hand.riichi; - c_hand.riichiPieceDiscard = static_cast(cpp_hand.riichiPieceDiscard); - c_hand.riichiRound = cpp_hand.riichiRound; - } - - return observed; -} - -void FreeGameState(mahjong::GameState* state) { - delete state; -} -} -} // namespace api diff --git a/src/api/gamestate.h b/src/api/gamestate.h deleted file mode 100644 index a0a36d3a..00000000 --- a/src/api/gamestate.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "api/types.h" - -namespace mahjong { -struct GameState; -} // namespace mahjong - -namespace api { - -extern "C" { - -#include - -int StartGame(const CGameSettings* settings, bool async); -void ExitGame(int game); -bool IsValidGameController(const char* controller); -mahjong::GameState* InitGameState(const CGameSettings* settings); -mahjong::GameState* AdvanceGameState(mahjong::GameState* state); -CObservedGameState ObserveGameState(mahjong::GameState* state); -void FreeGameState(mahjong::GameState* state); -} -} // namespace api diff --git a/src/api/types.h b/src/api/types.h deleted file mode 100644 index 2683306f..00000000 --- a/src/api/types.h +++ /dev/null @@ -1,100 +0,0 @@ -#pragma once - -#include - -namespace api { - -extern "C" { - -static const int kMaxLiveHandSize = 14; // Maximum 14 pieces (13 + 1 drawn) -static const int kMaxMeldsPerHand = 4; // Maximum 4 melds possible -static const int kMaxDiscardsPerPlayer = - 27; // Flow interruption from calls can allow the max discards to go up - // to 27. - -using CPiece = int; -using CWind = int; - -struct CEvent { - int type; - int player; - CPiece piece; - bool decision; -}; - -struct CGameSettings { - uint64_t seed; - const char* seat_controllers[4]; - int num_controllers = 0; -}; - -enum CMeldType { - kMeldChi = 0, - kMeldPon = 1, - kMeldKan = 2, - kMeldConcealedKan = 3 -}; - -// C API version of StateFunctionType enum -enum CStateFunctionType { - kError = 0, - kGameStart = 1, - kRoundStart = 2, - kDraw = 3, - kPlayerHand = 4, - kPon = 5, - kChi = 6, - kKan = 7, - kConcealedKan = 8, - kConvertedKan = 9, - kKanDiscard = 10, - kReplacement = 11, - kRiichi = 12, - kDiscard = 13, - kExhaust = 14, - kRon = 15, - kTsumo = 16, - kRoundEnd = 17, - kGameEnd = 18 -}; - -struct CMeld { - CMeldType type; - CPiece start; -}; - -struct CHand { - CPiece livePieces[kMaxLiveHandSize]; // Live pieces - int livePieceCount; // Number of live pieces - CMeld melds[kMaxMeldsPerHand]; // Melds - int meldCount; // Number of melds - CPiece discards[kMaxDiscardsPerPlayer]; // Discards - int discardCount; // Number of discards - bool open; // Whether hand is open - bool riichi; // Whether player declared riichi - int riichiPieceDiscard; // Index of riichi discard piece - int riichiRound; // Round when riichi was declared -}; - -struct CObservedGameState { - int currentPlayer; // Whose turn it is - int turnNum; // Turn counter within current round - int roundNum; // Current round number - int riichiSticks; // Number of riichi sticks on the table - int counters; // Honba counters - int lastCall; // Turn number when last call (chi/pon/kan) was made - int lastCaller; // Player index who made the last call - bool concealedKan; // Whether concealed kan was just performed (affects ron - // rules) - uint64_t seed; // Random seed used for wall generation and shuffling - CPiece pendingPiece; // Current piece being processed - int scores[4]; // Temporary round score changes - int points[4]; // Player points - bool hasRonned[4]; // Which players have declared ron this turn - CHand hands[4]; // Player hands with live pieces, melds, and discards - CStateFunctionType prevState; // Previous state function - CStateFunctionType currState; // Current state function - CStateFunctionType nextState; // Next state function -}; -} -} // namespace api diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 87b18f15..bbae16ba 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -78,8 +78,6 @@ package_add_test( "yakus/fullflush.test.cc" "yakus/blessingofman.test.cc") -package_add_test(api "api/game_control.test.cc") - package_add_test( rules "rules/basic_rules.test.cc" diff --git a/tests/api/game_control.test.cc b/tests/api/game_control.test.cc deleted file mode 100644 index 28aa622f..00000000 --- a/tests/api/game_control.test.cc +++ /dev/null @@ -1,234 +0,0 @@ -#include - -#include -#include -#include - -#include "api/gamestate.h" -#include "api/types.h" -#include "types/gamestate.h" -#include "types/hand.h" -#include "types/piecetype.h" - -namespace { - -const api::CGameSettings kDefaultSettings{ - .seed = 12345, - .seat_controllers = {"AngryDiscardoBot", "AngryDiscardoBot", - "AngryDiscardoBot", "AngryDiscardoBot"}, - .num_controllers = 4, -}; - -TEST(Api, InitGameState) { - const api::CGameSettings settings = kDefaultSettings; - - mahjong::GameState* state = api::InitGameState(&settings); - EXPECT_NE(state, nullptr); - api::FreeGameState(state); -} - -TEST(Api, SettingsConversion) { - const api::CGameSettings settings = kDefaultSettings; - - mahjong::GameState* state = api::InitGameState(&settings); - EXPECT_NE(state, nullptr); - EXPECT_EQ(state->seed, settings.seed); - EXPECT_EQ(state->controllers.size(), settings.num_controllers); - for (uint64_t i = 0; i < state->controllers.size(); ++i) { - EXPECT_EQ(state->controllers[i]->Name(), settings.seat_controllers[i]); - } - api::FreeGameState(state); -} - -TEST(Api, AdvanceGameState) { - const api::CGameSettings settings = kDefaultSettings; - - mahjong::GameState* state = api::InitGameState(&settings); - EXPECT_NE(state->seed, 0); - - state = api::AdvanceGameState(state); - EXPECT_NE(state, nullptr); - api::FreeGameState(state); -} - -TEST(Api, ObserveGameState) { - const api::CGameSettings settings = kDefaultSettings; - - mahjong::GameState* state = api::InitGameState(&settings); - EXPECT_NE(state, nullptr); - - api::CObservedGameState const observed = api::ObserveGameState(state); - - // Check basic fields are copied - EXPECT_EQ(observed.currentPlayer, state->currentPlayer); - EXPECT_EQ(observed.turnNum, state->turnNum); - EXPECT_EQ(observed.roundNum, state->roundNum); - EXPECT_EQ(observed.riichiSticks, state->riichiSticks); - EXPECT_EQ(observed.counters, state->counters); - EXPECT_EQ(observed.lastCall, state->lastCall); - EXPECT_EQ(observed.lastCaller, state->lastCaller); - EXPECT_EQ(observed.concealedKan, state->concealedKan); - EXPECT_EQ(observed.seed, state->seed); - - // Check pending piece conversion - EXPECT_EQ(observed.pendingPiece, - static_cast(state->pendingPiece.toUint8_t())); - - // Check state function name, the rest will be in unknown positions - // until we advance the game state - EXPECT_EQ(observed.nextState, - api::CStateFunctionType::kGameStart); // Initial state should be - // GameStart - - // Check arrays are properly sized and initialized - for (int i = 0; i < 4; i++) { - EXPECT_EQ(observed.scores[i], state->hands[i].score); - EXPECT_EQ(observed.points[i], state->hands[i].points); - EXPECT_EQ(observed.hasRonned[i], state->hands[i].hasRonned); - } - - api::FreeGameState(state); -} - -TEST(Api, ObserveGameStateAfterAdvancement) { - const api::CGameSettings settings = kDefaultSettings; - - mahjong::GameState* state = api::InitGameState(&settings); - EXPECT_NE(state, nullptr); - - // Advance the game state a few times - state = api::AdvanceGameState(state); - EXPECT_NE(state, nullptr); - - state = api::AdvanceGameState(state); - EXPECT_NE(state, nullptr); - - api::CObservedGameState const observed = api::ObserveGameState(state); - - // Verify the observation reflects the advanced state - EXPECT_EQ(observed.currentPlayer, state->currentPlayer); - EXPECT_EQ(observed.turnNum, state->turnNum); - EXPECT_EQ(observed.roundNum, state->roundNum); - EXPECT_EQ(observed.seed, state->seed); - - // After advancement, some fields should have changed from initial values - // (exact values depend on game logic, but we can check they're copied - // correctly) - EXPECT_EQ(observed.riichiSticks, state->riichiSticks); - EXPECT_EQ(observed.counters, state->counters); - - // Check state function names are valid - EXPECT_NE(observed.prevState, api::CStateFunctionType::kError); - EXPECT_NE(observed.currState, api::CStateFunctionType::kError); - EXPECT_NE(observed.nextState, api::CStateFunctionType::kError); - - // Check points are copied - for (int i = 0; i < 4; i++) { - EXPECT_EQ(observed.points[i], state->hands[i].points); - } - - api::FreeGameState(state); -} - -TEST(Api, ObserveGameStateHandsAndDiscards) { - const api::CGameSettings settings = kDefaultSettings; - - mahjong::GameState* state = api::InitGameState(&settings); - EXPECT_NE(state, nullptr); - - // Advance to get some hands dealt - state = api::AdvanceGameState(state); - EXPECT_NE(state, nullptr); - - api::CObservedGameState const observed = api::ObserveGameState(state); - - // Check that hands are copied (after round start, players should have hands) - for (int player = 0; player < 4; player++) { - const api::CHand& c_hand = observed.hands[player]; - const mahjong::Hand& cpp_hand = state->hands[player]; - - // Check live pieces - EXPECT_EQ(c_hand.livePieceCount, static_cast(cpp_hand.live_count)); - for (int piece = 0; piece < api::kMaxLiveHandSize; piece++) { - if (piece < c_hand.livePieceCount) { - EXPECT_EQ(c_hand.livePieces[piece], - static_cast(cpp_hand.live[piece].toUint8_t())); - } else { - // Should be filled with error pieces - EXPECT_EQ(c_hand.livePieces[piece], - static_cast(mahjong::Piece::Type::kError)); - } - } - - // Check melds - EXPECT_EQ(c_hand.meldCount, cpp_hand.meld_count); - for (int meld = 0; meld < c_hand.meldCount; meld++) { - EXPECT_EQ(static_cast(c_hand.melds[meld].type), - static_cast(cpp_hand.melds[meld].type)); - EXPECT_EQ( - c_hand.melds[meld].start, - static_cast(cpp_hand.melds[meld].start.toUint8_t())); - } - - // Check discards using CHand structure - EXPECT_EQ(c_hand.discardCount, static_cast(cpp_hand.discards_count)); - for (int discard = 0; discard < c_hand.discardCount; discard++) { - EXPECT_EQ( - c_hand.discards[discard], - static_cast(cpp_hand.discards[discard].toUint8_t())); - } - - // Check hand properties - EXPECT_EQ(c_hand.open, cpp_hand.open); - EXPECT_EQ(c_hand.riichi, cpp_hand.riichi); - EXPECT_EQ(c_hand.riichiPieceDiscard, - static_cast(cpp_hand.riichiPieceDiscard)); - EXPECT_EQ(c_hand.riichiRound, cpp_hand.riichiRound); - } - - api::FreeGameState(state); -} - -TEST(Api, ObserveGameStateStateFunctions) { - const api::CGameSettings settings = kDefaultSettings; - - mahjong::GameState* state = api::InitGameState(&settings); - EXPECT_NE(state, nullptr); - - api::CObservedGameState observed = api::ObserveGameState(state); - - // Initial state should have GameStart as next state - EXPECT_EQ(observed.nextState, api::CStateFunctionType::kGameStart); - - // Advance and check state transitions - state = api::AdvanceGameState(state); - observed = api::ObserveGameState(state); - - // After GameStart, next should be RoundStart - EXPECT_EQ(observed.nextState, api::CStateFunctionType::kRoundStart); - EXPECT_EQ(observed.currState, api::CStateFunctionType::kGameStart); - - // Clean up - api::FreeGameState(state); -} - -TEST(Api, GameControllerValidation) { - const api::CGameSettings settings = kDefaultSettings; - - // Check valid controllers - for (const auto& controller : settings.seat_controllers) { - EXPECT_TRUE(api::IsValidGameController(controller)); - } - - // Check invalid controller - EXPECT_FALSE(api::IsValidGameController("InvalidController")); - - // If an invalid controller is used, InitGameState should return nullptr - api::CGameSettings invalid_settings = settings; - invalid_settings.seat_controllers[0] = "InvalidController"; - mahjong::GameState* state = api::InitGameState(&invalid_settings); - EXPECT_EQ(state, nullptr); - api::FreeGameState(state); -} - -} // namespace