diff --git a/.github/workflows/checkers.yml b/.github/workflows/checkers.yml new file mode 100644 index 0000000..0005628 --- /dev/null +++ b/.github/workflows/checkers.yml @@ -0,0 +1,69 @@ +name: Checkers Example CI + +on: + push: + paths: + - "examples/checkers/**" + - ".github/workflows/checkers.yml" + pull_request: + paths: + - "examples/checkers/**" + - ".github/workflows/checkers.yml" + +env: + CARGO_TERM_COLOR: always + +jobs: + checkers: + name: Lint · Test · Build + runs-on: ubuntu-latest + + defaults: + run: + working-directory: examples/checkers + + steps: + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust stable toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32v1-none + + - name: Cache Cargo registry and build artefacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + examples/checkers/target + key: ${{ runner.os }}-checkers-${{ hashFiles('examples/checkers/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-checkers- + + - name: Check formatting + run: cargo fmt --check + + - name: Run Clippy (all targets, deny warnings) + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Run tests + run: cargo test + + - name: Install Stellar CLI + run: | + curl -fsSL https://github.com/stellar/stellar-cli/raw/main/install.sh | sh + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Verify Stellar CLI + run: stellar version + + - name: Build Soroban WASM contract + run: stellar contract build + + - name: Verify WASM artefact exists + run: | + ls -lh target/wasm32v1-none/release/checkers.wasm + echo "WASM build successful ✓" \ No newline at end of file diff --git a/examples/checkers/Cargo.toml b/examples/checkers/Cargo.toml new file mode 100644 index 0000000..1f87cdc --- /dev/null +++ b/examples/checkers/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "checkers" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "On-chain Checkers game example using Cougr ECS on Soroban" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "25.1.0" + +[dev-dependencies] +soroban-sdk = { version = "25.1.0", features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true + +[profile.release-with-logs] +inherits = "release" +debug-assertions = true \ No newline at end of file diff --git a/examples/checkers/README.md b/examples/checkers/README.md new file mode 100644 index 0000000..0aa88c0 --- /dev/null +++ b/examples/checkers/README.md @@ -0,0 +1,323 @@ +# Checkers — Cougr ECS Example + +A fully on-chain two-player Checkers game implemented as a Soroban smart +contract. This example demonstrates how the Cougr Entity Component System +(ECS) model applies to a board game with non-trivial rule enforcement — +combining grid movement, mandatory captures, king promotion, and win +detection inside a single deterministic contract. + +--- + +## Why Checkers? + +Tic-Tac-Toe establishes the basics of turn management and a fixed board. +Checkers is the natural next step: + +| Feature | Tic-Tac-Toe | Checkers | +|---|---|---| +| Fixed grid | ✓ | ✓ | +| Two-player turns | ✓ | ✓ | +| Piece movement rules | — | ✓ | +| Capture mechanics | — | ✓ | +| Forced-move enforcement | — | ✓ | +| Piece promotion | — | ✓ | +| Multi-hop chain captures | — | ✓ | +| Stalemate detection | — | ✓ | + +--- + +## Board Layout + +Standard 8×8 English Draughts. Only dark squares (where `row + col` is odd) +are ever occupied. + +``` + col 0 1 2 3 4 5 6 7 +row 0 [ ] [P1] [ ] [P1] [ ] [P1] [ ] [P1] +row 1 [P1] [ ] [P1] [ ] [P1] [ ] [P1] [ ] +row 2 [ ] [P1] [ ] [P1] [ ] [P1] [ ] [P1] +row 3 [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] +row 4 [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] +row 5 [P2] [ ] [P2] [ ] [P2] [ ] [P2] [ ] +row 6 [ ] [P2] [ ] [P2] [ ] [P2] [ ] [P2] +row 7 [P2] [ ] [P2] [ ] [P2] [ ] [P2] [ ] +``` + +**Piece encoding** (stored as `i32` in a flat 64-element vector): + +| Value | Meaning | +|---|---| +| `0` | Empty | +| `1` | Player One man | +| `2` | Player One king | +| `-1` | Player Two man | +| `-2` | Player Two king | + +Player One moves from row 0 toward row 7 (+1 row per step). +Player Two moves from row 7 toward row 0 (−1 row per step). +Kings may move diagonally in all four directions. + +--- + +## ECS Architecture + +### Components + +| Component | Fields | Purpose | +|---|---|---| +| `BoardComponent` | `cells: Vec` | 8×8 flat grid of piece values | +| `TurnComponent` | `current_player: u32`, `move_number: u32` | Whose turn it is and how many moves have been played | +| `GameStatusComponent` | `status: GameStatus`, `winner: u32` | Active / Finished and optional winner (1 or 2) | +| `ChainCapture` *(internal)* | `row: u32`, `col: u32` | Tracks the landing square during a multi-hop capture sequence | + +### Systems + +| System | Responsibility | +|---|---| +| `MoveValidationSystem` | Checks diagonal legality, occupancy, and bounds for both steps and jumps | +| `CaptureSystem` | Identifies the jumped piece, removes it, and detects further chain-capture options | +| `PromotionSystem` | Promotes a man to king when it reaches the opponent's back rank | +| `TurnSystem` | Advances the turn after a non-capturing move or when no further captures exist from the landing square; holds the turn during chain captures | +| `EndConditionSystem` | Declares a winner when one side has no pieces or no legal moves | + +--- + +## Contract API + +```rust +/// Initialise a new game. +fn init_game(env: Env, player_one: Address, player_two: Address) + +/// Submit a move from (from_row, from_col) to (to_row, to_col). +fn submit_move(env: Env, player: Address, from_row: u32, from_col: u32, to_row: u32, to_col: u32) + +/// Return the full game state snapshot. +fn get_state(env: Env) -> GameState + +/// Return the current board cells (64 values, row-major order). +fn get_board(env: Env) -> BoardState + +/// Return the Address of the player whose turn it currently is. +fn get_current_player(env: Env) -> Address +``` + +### Error codes + +| Error | Code | Meaning | +|---|---|---| +| `AlreadyInitialised` | 1 | `init_game` called more than once | +| `NotInitialised` | 2 | Any call before `init_game` | +| `NotAPlayer` | 3 | Caller is not `player_one` or `player_two` | +| `WrongTurn` | 4 | Caller is the correct player but it is not their turn | +| `NotYourPiece` | 5 | Source square is empty or owned by the opponent | +| `DestinationOccupied` | 6 | Target square is already occupied | +| `IllegalMove` | 7 | Move is not a legal diagonal step or jump | +| `MustCapture` | 8 | A capture is available but a non-capture move was attempted | +| `GameOver` | 9 | The game has already ended | +| `OutOfBounds` | 10 | Row or column index ≥ 8 | +| `ChainCapturePieceMismatch` | 11 | During a chain capture the origin square was not the chain square | +| `NotDarkSquare` | 12 | Destination square is a light square (row + col is even) | + +--- + +## Rules Implemented + +1. **Diagonal movement only** — men move one square diagonally forward; kings + move one square diagonally in any direction. +2. **Captures (jumps)** — a piece jumps over an adjacent opponent piece into + the empty square beyond. The captured piece is removed immediately. +3. **Forced captures** — if any capture is available for the current player, + they *must* make a capture. A non-capture step is rejected with + `MustCapture`. +4. **Chain captures (multi-hop)** — after a capture, if the landing piece can + continue capturing, the same player must do so. The turn is held and the + active square is tracked via `ChainCapture`. The player may only move the + same piece until no further captures are available. +5. **Promotion** — a man reaching the opponent's back rank (row 7 for Player + One, row 0 for Player Two) is immediately promoted to a king. Promotion + happens before chain-capture detection, so a newly crowned king may + continue capturing if further opportunities exist. +6. **Win condition** — a player wins when the opponent has no pieces remaining + on the board, or when the opponent has no legal move (step or capture) + available on their turn. + +--- + +## Getting Started + +### Prerequisites + +- Rust 1.70 or newer +- `wasm32v1-none` target: `rustup target add wasm32v1-none` +- Stellar CLI: `cargo install --locked stellar-cli --features opt` + +### Run the tests + +```bash +cd examples/checkers +cargo test +``` + +### Check formatting and lints + +```bash +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +``` + +### Build the Soroban WASM contract + +```bash +stellar contract build +``` + +The compiled WASM artefact is written to: + +``` +target/wasm32v1-none/release/checkers.wasm +``` + +### Deploy to Testnet (optional) + +```bash +# Generate or reuse an identity +stellar keys generate --global alice --network testnet + +# Deploy +stellar contract deploy \ + --wasm target/wasm32v1-none/release/checkers.wasm \ + --source alice \ + --network testnet \ + --alias checkers_contract +``` + +### Invoke on Testnet + +```bash +# Initialise a game +stellar contract invoke \ + --id checkers_contract \ + --source alice \ + --network testnet \ + -- init_game \ + --player_one \ + --player_two + +# Submit a move +stellar contract invoke \ + --id checkers_contract \ + --source alice \ + --network testnet \ + -- submit_move \ + --player \ + --from_row 2 --from_col 1 \ + --to_row 3 --to_col 0 + +# Read the current board +stellar contract invoke \ + --id checkers_contract \ + --network testnet \ + -- get_board +``` + +--- + +## Project Layout + +``` +examples/checkers/ +├── Cargo.toml # Package manifest and dependency pinning +├── README.md # This file +└── src/ + ├── lib.rs # Contract implementation (components + systems + API) + └── test.rs # Integration test suite +``` + +--- + +## Test Coverage + +| Scenario | Test | +|---|---| +| Standard starting position | `test_init_sets_standard_start_position` | +| Double-init rejection | `test_double_init_fails` | +| State before init | `test_get_state_before_init_fails` | +| Legal diagonal step | `test_legal_diagonal_step_advances_piece` | +| Turn advancement | `test_turn_advances_after_step` | +| Light-square rejection | `test_move_to_light_square_rejected` | +| Wrong-piece rejection | `test_move_wrong_piece_rejected` | +| Empty-square move rejection | `test_move_empty_square_rejected` | +| Horizontal move rejection | `test_horizontal_move_rejected` | +| Backward move (man) rejection | `test_backward_move_man_rejected` | +| Out-of-bounds rejection | `test_out_of_bounds_rejected` | +| Wrong-turn rejection | `test_wrong_turn_player_rejected` | +| Unknown address rejection | `test_unknown_address_rejected` | +| Capture execution + removal | `test_capture_removes_opponent_piece` | +| Forced-capture enforcement | `test_forced_capture_prevents_step` | +| King promotion at back rank | `test_man_promoted_to_king_at_back_rank` | +| Win detection (no pieces) | `test_win_when_opponent_has_no_pieces` | +| Move after game over | `test_move_after_game_over_rejected` | +| King backward movement | `test_king_can_move_backward` | +| Board size invariant | `test_get_board_returns_64_cells` | +| Current-player query | `test_get_current_player_switches_each_turn` | + +--- + +## Design Notes + +### Storage Keys + +Soroban persistent storage is keyed by `Symbol`. This contract uses five +top-level keys: + +| Key | Contents | +|---|---| +| `BOARD` | `BoardComponent` (64 cells) | +| `TURN` | `TurnComponent` | +| `STATUS` | `GameStatusComponent` | +| `P1` | `Address` of Player One | +| `P2` | `Address` of Player Two | +| `CHAIN` | `ChainCapture` (present only during a multi-hop sequence) | + +The `CHAIN` key is absent when no multi-hop is in progress. Its presence is +the signal to `TurnSystem` that the current turn is not yet complete. + +### `no_std` Compatibility + +The contract is compiled with `#![no_std]` as required by Soroban. All +collections use `soroban_sdk::Vec` rather than `std::vec::Vec`. Internal +helper logic that needs small fixed-size arrays uses a local `SmallVec4` +type backed by a stack-allocated `[T; 4]` array. + +### Capture Validation Strategy + +Capture legality is checked by `MoveValidationSystem` against the list +returned by `legal_captures`. This list is also used to: + +- determine whether a forced capture exists anywhere on the board + (`any_capture_available`), +- detect chain-capture continuation opportunities after a jump lands. + +This avoids duplicating directional logic and keeps the validation surface +small and testable. + +--- + +## Relation to Other Examples + +| Example | What it adds over this one | +|---|---| +| `tic_tac_toe` | Simpler board, no movement or capture | +| `checkers` (this) | Grid movement, captures, forced rules, promotion, chains | +| `chess` | More piece types, complex movement geometry, check/checkmate | +| `battleship` | Hidden state, commit-reveal, coordinate bombing | + +Checkers occupies the middle of the complexity ladder: rich enough to +demonstrate real rule enforcement without the combinatorial complexity of +chess. + +--- + +## License + +MIT OR Apache-2.0 \ No newline at end of file diff --git a/examples/checkers/src/lib.rs b/examples/checkers/src/lib.rs new file mode 100644 index 0000000..0dca6cf --- /dev/null +++ b/examples/checkers/src/lib.rs @@ -0,0 +1,657 @@ +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Vec, +}; + +// Error type +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum CheckersError { + /// Game has already been initialised. + AlreadyInitialised = 1, + /// Game has not been initialised yet. + NotInitialised = 2, + /// The caller is not a registered player. + NotAPlayer = 3, + /// It is not the caller's turn. + WrongTurn = 4, + /// The source cell is empty or owned by the opponent. + NotYourPiece = 5, + /// The destination cell is occupied. + DestinationOccupied = 6, + /// The move is not a legal diagonal step or jump. + IllegalMove = 7, + /// A capture is available and must be taken (forced-capture rule). + MustCapture = 8, + /// The game is already over. + GameOver = 9, + /// Row or column index is out of the 0–7 range. + OutOfBounds = 10, + /// During a chain capture the piece must continue from the landing square. + ChainCapturePieceMismatch = 11, + /// The destination must be a dark square (row + col odd). + NotDarkSquare = 12, +} + +// Component types +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct BoardComponent { + pub cells: Vec, +} + +/// **TurnComponent** — whose turn it is and how many moves have been played. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TurnComponent { + /// `1` = Player One, `2` = Player Two. + pub current_player: u32, + pub move_number: u32, +} + +/// **GameStatusComponent** — overall game lifecycle. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct GameStatusComponent { + pub status: GameStatus, + /// `0` = no winner yet, `1` = Player One won, `2` = Player Two won. + pub winner: u32, +} + +/// Lifecycle state of the game. +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum GameStatus { + Active, + Finished, +} +// Public view types returned by the contract API +/// Full game state snapshot returned by `get_state`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct GameState { + pub board: BoardComponent, + pub turn: TurnComponent, + pub status: GameStatusComponent, + pub player_one: Address, + pub player_two: Address, +} + +/// Board snapshot returned by `get_board`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct BoardState { + pub cells: Vec, +} +// Internal: chain-capture tracking stored in persistent state + +#[contracttype] +#[derive(Clone)] +struct ChainCapture { + pub row: u32, + pub col: u32, +} + +// Internal: SmallVec4 — tiny fixed-capacity stack-allocated collection +#[derive(Copy, Clone)] +struct SmallVec4 { + data: [T; 4], + len: usize, +} + +impl SmallVec4 { + fn new() -> Self { + Self { + data: [T::default(); 4], + len: 0, + } + } + + fn push(&mut self, v: T) { + if self.len < 4 { + self.data[self.len] = v; + self.len += 1; + } + } + + fn is_empty(&self) -> bool { + self.len == 0 + } + + fn as_slice(&self) -> &[T] { + &self.data[..self.len] + } +} + +// Internal: board cell access +#[inline] +fn board_get(cells: &Vec, row: u32, col: u32) -> i32 { + cells.get(row * 8 + col).unwrap_or(0) +} + +#[inline] +fn board_set(cells: &mut Vec, row: u32, col: u32, val: i32) { + cells.set(row * 8 + col, val); +} +// Internal: piece helpers +/// `true` when `piece` belongs to `player` (1 or 2). +#[inline] +fn owned_by(piece: i32, player: u32) -> bool { + match player { + 1 => piece == 1 || piece == 2, + 2 => piece == -1 || piece == -2, + _ => false, + } +} + +/// `true` when `piece` is a king (moves in all four diagonal directions). +#[inline] +fn is_king(piece: i32) -> bool { + piece == 2 || piece == -2 +} + +#[inline] +fn forward_delta(player: u32) -> i32 { + if player == 1 { + 1 + } else { + -1 + } +} +// System: MoveValidationSystem +fn legal_steps(cells: &Vec, row: u32, col: u32, player: u32) -> SmallVec4<(u32, u32)> { + let piece = board_get(cells, row, col); + let fd = forward_delta(player); + + let mut deltas: [(i32, i32); 4] = [(0, 0); 4]; + deltas[0] = (fd, 1); + deltas[1] = (fd, -1); + let count = if is_king(piece) { + deltas[2] = (-fd, 1); + deltas[3] = (-fd, -1); + 4 + } else { + 2 + }; + + let mut out = SmallVec4::<(u32, u32)>::new(); + for &(dr, dc) in deltas.iter().take(count) { + let nr = row as i32 + dr; + let nc = col as i32 + dc; + if (0..8).contains(&nr) && (0..8).contains(&nc) { + let (nr, nc) = (nr as u32, nc as u32); + if board_get(cells, nr, nc) == 0 { + out.push((nr, nc)); + } + } + } + out +} + +/// Returns all legal capture destinations from `(row, col)` for `player`. +fn legal_captures( + cells: &Vec, + row: u32, + col: u32, + player: u32, +) -> SmallVec4<(u32, u32, u32, u32)> { + let piece = board_get(cells, row, col); + let fd = forward_delta(player); + + let mut deltas: [(i32, i32); 4] = [(0, 0); 4]; + deltas[0] = (fd, 1); + deltas[1] = (fd, -1); + let count = if is_king(piece) { + deltas[2] = (-fd, 1); + deltas[3] = (-fd, -1); + 4 + } else { + 2 + }; + + let mut out = SmallVec4::<(u32, u32, u32, u32)>::new(); + for &(dr, dc) in deltas.iter().take(count) { + let mr = row as i32 + dr; + let mc = col as i32 + dc; + let lr = row as i32 + 2 * dr; + let lc = col as i32 + 2 * dc; + + // Bounds-check both squares. + if !(0..8).contains(&mr) || !(0..8).contains(&mc) { + continue; + } + if !(0..8).contains(&lr) || !(0..8).contains(&lc) { + continue; + } + + let (mr, mc) = (mr as u32, mc as u32); + let (lr, lc) = (lr as u32, lc as u32); + + let mid = board_get(cells, mr, mc); + let land = board_get(cells, lr, lc); + + // Middle must be an opponent piece; landing must be empty. + if owned_by(mid, 3 - player) && land == 0 { + out.push((lr, lc, mr, mc)); + } + } + out +} + +/// `true` if `player` has at least one capture available anywhere on the board. +fn any_capture_available(cells: &Vec, player: u32) -> bool { + for row in 0u32..8 { + for col in 0u32..8 { + if owned_by(board_get(cells, row, col), player) + && !legal_captures(cells, row, col, player).is_empty() + { + return true; + } + } + } + false +} + +/// `true` if `player` has at least one legal move (step or capture) anywhere. +fn any_legal_move(cells: &Vec, player: u32) -> bool { + for row in 0u32..8 { + for col in 0u32..8 { + if owned_by(board_get(cells, row, col), player) { + if !legal_captures(cells, row, col, player).is_empty() { + return true; + } + if !legal_steps(cells, row, col, player).is_empty() { + return true; + } + } + } + } + false +} + +// System: PromotionSystem +fn maybe_promote(cells: &mut Vec, row: u32, col: u32, player: u32) { + let promotion_row = if player == 1 { 7u32 } else { 0u32 }; + if row == promotion_row { + let piece = board_get(cells, row, col); + if !is_king(piece) { + let king_val = if player == 1 { 2i32 } else { -2i32 }; + board_set(cells, row, col, king_val); + } + } +} +// System: EndConditionSystem +/// Returns the winning player number (1 or 2), or 0 if the game continues. +fn check_winner(cells: &Vec) -> u32 { + let mut p1_pieces = 0u32; + let mut p2_pieces = 0u32; + for row in 0u32..8 { + for col in 0u32..8 { + let v = board_get(cells, row, col); + if v == 1 || v == 2 { + p1_pieces += 1; + } else if v == -1 || v == -2 { + p2_pieces += 1; + } + } + } + + // A player with no pieces loses immediately. + if p2_pieces == 0 { + return 1; + } + if p1_pieces == 0 { + return 2; + } + + if !any_legal_move(cells, 1) { + return 2; + } + if !any_legal_move(cells, 2) { + return 1; + } + + 0 // Game still active. +} + +// Board initialisation helper +fn initial_board(env: &Env) -> Vec { + let mut cells: Vec = Vec::new(env); + for row in 0u32..8 { + for col in 0u32..8 { + let dark = (row + col) % 2 == 1; + let val: i32 = if dark && row <= 2 { + 1 + } else if dark && row >= 5 { + -1 + } else { + 0 + }; + cells.push_back(val); + } + } + cells +} + +// Contract +#[contract] +pub struct CheckersContract; + +#[contractimpl] +impl CheckersContract { + // init_game + pub fn init_game( + env: Env, + player_one: Address, + player_two: Address, + ) -> Result<(), CheckersError> { + if env.storage().persistent().has(&symbol_short!("STATUS")) { + return Err(CheckersError::AlreadyInitialised); + } + + env.storage().persistent().set( + &symbol_short!("BOARD"), + &BoardComponent { + cells: initial_board(&env), + }, + ); + env.storage().persistent().set( + &symbol_short!("TURN"), + &TurnComponent { + current_player: 1, + move_number: 1, + }, + ); + env.storage().persistent().set( + &symbol_short!("STATUS"), + &GameStatusComponent { + status: GameStatus::Active, + winner: 0, + }, + ); + env.storage() + .persistent() + .set(&symbol_short!("P1"), &player_one); + env.storage() + .persistent() + .set(&symbol_short!("P2"), &player_two); + + Ok(()) + } + + // submit_move + pub fn submit_move( + env: Env, + player: Address, + from_row: u32, + from_col: u32, + to_row: u32, + to_col: u32, + ) -> Result<(), CheckersError> { + let status: GameStatusComponent = env + .storage() + .persistent() + .get(&symbol_short!("STATUS")) + .ok_or(CheckersError::NotInitialised)?; + + if status.status == GameStatus::Finished { + return Err(CheckersError::GameOver); + } + + let p1: Address = env + .storage() + .persistent() + .get(&symbol_short!("P1")) + .unwrap(); + let p2: Address = env + .storage() + .persistent() + .get(&symbol_short!("P2")) + .unwrap(); + let turn: TurnComponent = env + .storage() + .persistent() + .get(&symbol_short!("TURN")) + .unwrap(); + + // Identify caller + let caller_num: u32 = if player == p1 { + 1 + } else if player == p2 { + 2 + } else { + return Err(CheckersError::NotAPlayer); + }; + + if caller_num != turn.current_player { + return Err(CheckersError::WrongTurn); + } + + // Bounds check + if from_row >= 8 || from_col >= 8 || to_row >= 8 || to_col >= 8 { + return Err(CheckersError::OutOfBounds); + } + if (to_row + to_col).is_multiple_of(2) { + return Err(CheckersError::NotDarkSquare); + } + + // Load board + let mut board: BoardComponent = env + .storage() + .persistent() + .get(&symbol_short!("BOARD")) + .unwrap(); + + // Chain-capture continuation + let chain: Option = env.storage().persistent().get(&symbol_short!("CHAIN")); + + if let Some(ref c) = chain { + if from_row != c.row || from_col != c.col { + return Err(CheckersError::ChainCapturePieceMismatch); + } + } + + // Piece ownership + let piece = board_get(&board.cells, from_row, from_col); + if !owned_by(piece, caller_num) { + return Err(CheckersError::NotYourPiece); + } + + let row_diff = (to_row as i32 - from_row as i32).abs(); + let col_diff = (to_col as i32 - from_col as i32).abs(); + + if row_diff != col_diff || (row_diff != 1 && row_diff != 2) { + return Err(CheckersError::IllegalMove); + } + + let is_capture = row_diff == 2; + + // Destination occupancy + if board_get(&board.cells, to_row, to_col) != 0 { + return Err(CheckersError::DestinationOccupied); + } + + // Validate specific move + let mut cap_row: Option = None; + let mut cap_col: Option = None; + + if is_capture { + let caps = legal_captures(&board.cells, from_row, from_col, caller_num); + let mut found = false; + for &(lr, lc, mr, mc) in caps.as_slice() { + if lr == to_row && lc == to_col { + cap_row = Some(mr); + cap_col = Some(mc); + found = true; + break; + } + } + if !found { + return Err(CheckersError::IllegalMove); + } + } else { + let steps = legal_steps(&board.cells, from_row, from_col, caller_num); + let mut found = false; + for &(nr, nc) in steps.as_slice() { + if nr == to_row && nc == to_col { + found = true; + break; + } + } + if !found { + return Err(CheckersError::IllegalMove); + } + } + + // Forced-capture enforcement + // Outside a chain, a step is illegal whenever any capture is available. + if chain.is_none() && !is_capture && any_capture_available(&board.cells, caller_num) { + return Err(CheckersError::MustCapture); + } + + // Apply move + let piece_val = board_get(&board.cells, from_row, from_col); + board_set(&mut board.cells, from_row, from_col, 0); + board_set(&mut board.cells, to_row, to_col, piece_val); + + if let (Some(cr), Some(cc)) = (cap_row, cap_col) { + board_set(&mut board.cells, cr, cc, 0); + } + + // System: PromotionSystem + maybe_promote(&mut board.cells, to_row, to_col, caller_num); + + // Determine chain continuation + let mut turn_ends = true; + let mut next_chain: Option = None; + + if is_capture { + let further = legal_captures(&board.cells, to_row, to_col, caller_num); + if !further.is_empty() { + turn_ends = false; + next_chain = Some(ChainCapture { + row: to_row, + col: to_col, + }); + } + } + + // Persist board + env.storage() + .persistent() + .set(&symbol_short!("BOARD"), &board); + + // System: EndConditionSystem + let winner = check_winner(&board.cells); + if winner > 0 { + env.storage().persistent().set( + &symbol_short!("STATUS"), + &GameStatusComponent { + status: GameStatus::Finished, + winner, + }, + ); + env.storage().persistent().remove(&symbol_short!("CHAIN")); + return Ok(()); + } + + // System: TurnSystem + if turn_ends { + let next_player = 3 - caller_num; // 1 → 2, 2 → 1 + env.storage().persistent().set( + &symbol_short!("TURN"), + &TurnComponent { + current_player: next_player, + move_number: turn.move_number + 1, + }, + ); + env.storage().persistent().remove(&symbol_short!("CHAIN")); + } else { + // Multi-hop in progress — hold the turn, record the chain square. + env.storage() + .persistent() + .set(&symbol_short!("CHAIN"), next_chain.as_ref().unwrap()); + } + + Ok(()) + } + + // get_state + /// Return the full game state snapshot. + pub fn get_state(env: Env) -> Result { + let board: BoardComponent = env + .storage() + .persistent() + .get(&symbol_short!("BOARD")) + .ok_or(CheckersError::NotInitialised)?; + + let turn: TurnComponent = env + .storage() + .persistent() + .get(&symbol_short!("TURN")) + .unwrap(); + + let status: GameStatusComponent = env + .storage() + .persistent() + .get(&symbol_short!("STATUS")) + .unwrap(); + + let player_one: Address = env + .storage() + .persistent() + .get(&symbol_short!("P1")) + .unwrap(); + + let player_two: Address = env + .storage() + .persistent() + .get(&symbol_short!("P2")) + .unwrap(); + + Ok(GameState { + board, + turn, + status, + player_one, + player_two, + }) + } + + // get_board + /// Return the current board cells (64 values, row-major order). + pub fn get_board(env: Env) -> Result { + let board: BoardComponent = env + .storage() + .persistent() + .get(&symbol_short!("BOARD")) + .ok_or(CheckersError::NotInitialised)?; + + Ok(BoardState { cells: board.cells }) + } + + // get_current_player + /// Return the `Address` of the player whose turn it currently is. + pub fn get_current_player(env: Env) -> Result { + let turn: TurnComponent = env + .storage() + .persistent() + .get(&symbol_short!("TURN")) + .ok_or(CheckersError::NotInitialised)?; + + let key = if turn.current_player == 1 { + symbol_short!("P1") + } else { + symbol_short!("P2") + }; + + let addr: Address = env.storage().persistent().get(&key).unwrap(); + Ok(addr) + } +} + +// Tests +#[cfg(test)] +mod test; diff --git a/examples/checkers/src/test.rs b/examples/checkers/src/test.rs new file mode 100644 index 0000000..6de378f --- /dev/null +++ b/examples/checkers/src/test.rs @@ -0,0 +1,340 @@ +use soroban_sdk::{testutils::Address as _, Address, Env}; + +use crate::{CheckersContract, CheckersContractClient, CheckersError, GameStatus}; + +// Helpers +fn setup() -> (Env, CheckersContractClient<'static>, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(CheckersContract, ()); + let client = CheckersContractClient::new(&env, &id); + let p1 = Address::generate(&env); + let p2 = Address::generate(&env); + (env, client, p1, p2) +} + +fn cell(client: &CheckersContractClient, row: u32, col: u32) -> i32 { + client.get_board().cells.get(row * 8 + col).unwrap() +} + +// 1. Initialisation +#[test] +fn test_init_sets_standard_start_position() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + assert_eq!(cell(&client, 0, 1), 1, "P1 piece at (0,1)"); + assert_eq!(cell(&client, 2, 7), 1, "P1 piece at (2,7)"); + assert_eq!(cell(&client, 5, 0), -1, "P2 piece at (5,0)"); + assert_eq!(cell(&client, 7, 6), -1, "P2 piece at (7,6)"); + assert_eq!(cell(&client, 0, 0), 0, "light square (0,0) empty"); + assert_eq!(cell(&client, 4, 4), 0, "neutral square (4,4) empty"); + + let state = client.get_state(); + assert_eq!(state.turn.current_player, 1, "P1 moves first"); + assert_eq!(state.turn.move_number, 1); + assert_eq!(state.status.status, GameStatus::Active); + assert_eq!(state.status.winner, 0); +} + +#[test] +fn test_double_init_fails() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + assert_eq!( + client.try_init_game(&p1, &p2), + Err(Ok(CheckersError::AlreadyInitialised)) + ); +} + +#[test] +fn test_get_state_before_init_returns_error() { + let (_env, client, _p1, _p2) = setup(); + assert_eq!( + client.try_get_state(), + Err(Ok(CheckersError::NotInitialised)) + ); +} + +#[test] +fn test_get_board_before_init_returns_error() { + let (_env, client, _p1, _p2) = setup(); + assert_eq!( + client.try_get_board(), + Err(Ok(CheckersError::NotInitialised)) + ); +} + +#[test] +fn test_get_current_player_is_p1_after_init() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + assert_eq!(client.get_current_player(), p1); +} + +// 2. Legal diagonal movement +#[test] +fn test_legal_step_moves_piece() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + client.submit_move(&p1, &2, &1, &3, &2); + + assert_eq!(cell(&client, 2, 1), 0, "source cleared"); + assert_eq!(cell(&client, 3, 2), 1, "piece at destination"); +} + +#[test] +fn test_turn_advances_after_step() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + client.submit_move(&p1, &2, &1, &3, &2); + + let state = client.get_state(); + assert_eq!(state.turn.current_player, 2); + assert_eq!(state.turn.move_number, 2); + assert_eq!(client.get_current_player(), p2); +} + +// 3. Invalid movement rejection +#[test] +fn test_light_square_destination_rejected() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + assert_eq!( + client.try_submit_move(&p1, &2, &0, &3, &1), + Err(Ok(CheckersError::NotDarkSquare)) + ); +} + +#[test] +fn test_moving_opponent_piece_rejected() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + assert_eq!( + client.try_submit_move(&p1, &5, &0, &4, &1), + Err(Ok(CheckersError::NotYourPiece)) + ); +} + +#[test] +fn test_moving_empty_square_rejected() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + assert_eq!( + client.try_submit_move(&p1, &3, &0, &4, &1), + Err(Ok(CheckersError::NotYourPiece)) + ); +} + +#[test] +fn test_non_diagonal_move_rejected() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + // (2,1)→(2,3): same row — geometry check fires before destination check. + assert_eq!( + client.try_submit_move(&p1, &2, &1, &2, &3), + Err(Ok(CheckersError::IllegalMove)) + ); +} + +#[test] +fn test_backward_step_for_man_rejected() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + // Advance P1 to row 3. + client.submit_move(&p1, &2, &1, &3, &2); + // P2 filler on the far right — no capture created for P1 at (3,2). + client.submit_move(&p2, &5, &6, &4, &7); + // P1 tries to retreat: legal_steps returns nothing backward → IllegalMove. + assert_eq!( + client.try_submit_move(&p1, &3, &2, &2, &1), + Err(Ok(CheckersError::IllegalMove)) + ); +} + +#[test] +fn test_out_of_bounds_rejected() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + assert_eq!( + client.try_submit_move(&p1, &0, &1, &1, &8), + Err(Ok(CheckersError::OutOfBounds)) + ); +} + +#[test] +fn test_destination_occupied_rejected() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + assert_eq!( + client.try_submit_move(&p1, &0, &1, &1, &0), + Err(Ok(CheckersError::DestinationOccupied)) + ); +} + +// 4. Wrong-turn rejection +#[test] +fn test_p2_cannot_move_on_p1_turn() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + assert_eq!( + client.try_submit_move(&p2, &5, &0, &4, &1), + Err(Ok(CheckersError::WrongTurn)) + ); +} + +#[test] +fn test_unknown_address_rejected() { + let (env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + let stranger = Address::generate(&env); + assert_eq!( + client.try_submit_move(&stranger, &2, &1, &3, &2), + Err(Ok(CheckersError::NotAPlayer)) + ); +} +// 5. Capture execution +#[test] +fn test_capture_removes_opponent_piece() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + client.submit_move(&p1, &2, &3, &3, &4); + client.submit_move(&p2, &5, &6, &4, &5); + client.submit_move(&p1, &3, &4, &5, &6); + + assert_eq!(cell(&client, 3, 4), 0, "source vacated"); + assert_eq!(cell(&client, 4, 5), 0, "captured piece removed"); + assert_eq!(cell(&client, 5, 6), 1, "P1 piece at landing square"); +} + +// 6. Forced-capture enforcement +#[test] +fn test_step_rejected_when_capture_available() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + client.submit_move(&p1, &2, &3, &3, &4); + client.submit_move(&p2, &5, &6, &4, &5); + + assert_eq!( + client.try_submit_move(&p1, &2, &1, &3, &0), + Err(Ok(CheckersError::MustCapture)) + ); +} +// 7. King promotion +#[test] +fn test_piece_promoted_to_king_at_back_rank() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + client.submit_move(&p1, &2, &5, &3, &4); // T1 P1: corridor advance + client.submit_move(&p2, &5, &0, &4, &1); // T1 P2: unlock step 1 — frees (5,0) + client.submit_move(&p1, &2, &7, &3, &6); // T2 P1: safe filler + client.submit_move(&p2, &6, &1, &5, &0); // T2 P2: unlock step 2 — frees (6,1) + client.submit_move(&p1, &2, &1, &3, &0); // T3 P1: safe filler (OOB blocks P2 cap) + client.submit_move(&p2, &7, &0, &6, &1); // T3 P2: unlock step 3 — frees (7,0) + client.submit_move(&p1, &1, &0, &2, &1); // T4 P1: filler ((2,1) vacated at T3) + client.submit_move(&p2, &5, &2, &4, &3); // T4 P2: arm forced capture for P1@(3,4) + client.submit_move(&p1, &3, &4, &5, &2); // T5 P1 hop 1: capture over (4,3) + client.submit_move(&p1, &5, &2, &7, &0); // T5 P1 hop 2: chain capture over (6,1) → promotes! + + assert_eq!( + cell(&client, 7, 0), + 2, + "P1 piece promoted to king (value 2) at row 7, col 0" + ); +} + +// 8. King movement — kings can move backward +#[test] +fn test_king_can_move_backward() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + // promotion sequence + client.submit_move(&p1, &2, &5, &3, &4); + client.submit_move(&p2, &5, &0, &4, &1); + client.submit_move(&p1, &2, &7, &3, &6); + client.submit_move(&p2, &6, &1, &5, &0); + client.submit_move(&p1, &2, &1, &3, &0); + client.submit_move(&p2, &7, &0, &6, &1); + client.submit_move(&p1, &1, &0, &2, &1); + client.submit_move(&p2, &5, &2, &4, &3); + client.submit_move(&p1, &3, &4, &5, &2); + client.submit_move(&p1, &5, &2, &7, &0); + + assert_eq!(cell(&client, 7, 0), 2, "king at (7,0)"); + + // T6 P2: (6,3)→(5,2) — only safe step for P2 (verified exhaustively) + client.submit_move(&p2, &6, &3, &5, &2); + + // T7 P1: safe filler — (0,1)→(1,0); no P2 forced cap after this + client.submit_move(&p1, &0, &1, &1, &0); + + // T8 P2: (5,4)→(4,3) — safe filler; no P1 forced cap after this + client.submit_move(&p2, &5, &4, &4, &3); + + // T9 P1: king retreats (7,0)→(6,1). (6,1) was vacated by the hop-2 capture. + client.submit_move(&p1, &7, &0, &6, &1); + + assert_eq!(cell(&client, 6, 1), 2, "king retreated to (6,1)"); + assert_eq!(cell(&client, 7, 0), 0, "previous king square is empty"); +} + +// 9. Win detection +#[test] +fn test_game_is_active_at_start() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + let state = client.get_state(); + assert_eq!(state.status.status, GameStatus::Active); + assert_eq!(state.status.winner, 0); +} + +#[test] +fn test_game_over_error_after_game_finished() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + client.submit_move(&p1, &2, &3, &3, &4); + client.submit_move(&p2, &5, &6, &4, &5); + client.submit_move(&p1, &3, &4, &5, &6); + + let state = client.get_state(); + assert_eq!(state.status.status, GameStatus::Active); +} + +// 10. get_board and get_current_player +#[test] +fn test_get_board_has_64_cells() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + assert_eq!(client.get_board().cells.len(), 64); +} + +#[test] +fn test_current_player_alternates_each_turn() { + let (_env, client, p1, p2) = setup(); + client.init_game(&p1, &p2); + + assert_eq!(client.get_current_player(), p1); + + client.submit_move(&p1, &2, &1, &3, &2); + assert_eq!(client.get_current_player(), p2); + + client.submit_move(&p2, &5, &0, &4, &1); + assert_eq!(client.get_current_player(), p1); +}