From 736eb597ec1f1339f82c4dca098e361b7c14b3cb Mon Sep 17 00:00:00 2001 From: Takuma IMAMURA <209989118+hyperfinitism@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:53:01 +0900 Subject: [PATCH] feat: implement CLI board game ORTHO Signed-off-by: Takuma IMAMURA <209989118+hyperfinitism@users.noreply.github.com> --- .gitattributes | 2 + .github/workflows/build.yml | 62 +++++ .github/workflows/lint.yml | 44 ++++ .github/workflows/spdx.yml | 28 +++ .github/workflows/test.yml | 66 ++++++ Cargo.lock | 186 +++++++++++++++ Cargo.toml | 11 + README.md | 401 ++++++++++++++++++++++++++++++++- src/board.rs | 177 +++++++++++++++ src/display.rs | 73 ++++++ src/game.rs | 436 ++++++++++++++++++++++++++++++++++++ src/input.rs | 110 +++++++++ src/main.rs | 138 ++++++++++++ src/player.rs | 25 +++ src/types.rs | 118 ++++++++++ 15 files changed, 1876 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/spdx.yml create mode 100644 .github/workflows/test.yml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/board.rs create mode 100644 src/display.rs create mode 100644 src/game.rs create mode 100644 src/input.rs create mode 100644 src/main.rs create mode 100644 src/player.rs create mode 100644 src/types.rs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5a0d5e4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto eol=lf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..8c36347 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,62 @@ +name: Build + +on: + pull_request: + paths: + - '**/*.rs' + - '**/Cargo.toml' + - '**/Cargo.lock' + - '.github/workflows/build.yml' + push: + paths: + - '**/*.rs' + - '**/Cargo.toml' + - '**/Cargo.lock' + - '.github/workflows/build.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + toolchain: + - "1.88.0" + - stable + os-arch: + - ubuntu-24.04-x86_64 + - ubuntu-24.04-aarch64 + - macos-x86_64 + - macos-aarch64 + - windows-x86_64 + include: + - os-arch: ubuntu-24.04-x86_64 + runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + - os-arch: ubuntu-24.04-aarch64 + runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + - os-arch: macos-x86_64 + runner: macos-26-intel + target: x86_64-apple-darwin + - os-arch: macos-aarch64 + runner: macos-26 + target: aarch64-apple-darwin + - os-arch: windows-x86_64 + runner: windows-2025 + target: x86_64-pc-windows-gnu + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 + with: + toolchain: ${{ matrix.toolchain }} + target: ${{ matrix.target }} + - run: cargo build --workspace --all-targets --all-features --target ${{ matrix.target }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..37ffca2 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,44 @@ +name: Lint + +on: + pull_request: + paths: + - '**/*.rs' + - '**/Cargo.toml' + - '**/Cargo.lock' + - '.github/workflows/lint.yml' + push: + paths: + - '**/*.rs' + - '**/Cargo.toml' + - '**/Cargo.lock' + - '.github/workflows/lint.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + rustfmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 + with: + toolchain: stable + components: rustfmt + - run: cargo fmt --all -- --check + + clippy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 + with: + toolchain: stable + components: clippy + - run: cargo clippy --workspace --all-targets --all-features -- -D warnings diff --git a/.github/workflows/spdx.yml b/.github/workflows/spdx.yml new file mode 100644 index 0000000..a2c9bdf --- /dev/null +++ b/.github/workflows/spdx.yml @@ -0,0 +1,28 @@ +name: SPDX License Check + +on: + pull_request: + paths: + - '**/*.rs' + - ".github/workflows/spdx.yml" + push: + paths: + - '**/*.rs' + - ".github/workflows/spdx.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + spdx: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: enarx/spdx@d4020ee98e3101dd487c5184f27c6a6fb4f88709 + with: + licenses: Apache-2.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e8bab15 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,66 @@ +name: Test + +on: + push: + paths: + - '**/*.rs' + - '**/Cargo.toml' + - '**/Cargo.lock' + - '.github/workflows/test.yml' + pull_request: + paths: + - '**/*.rs' + - '**/Cargo.toml' + - '**/Cargo.lock' + - '.github/workflows/test.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + integration-test: + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + toolchain: + - "1.88.0" + - stable + os-arch: + - ubuntu-24.04-x86_64 + - ubuntu-24.04-aarch64 + - macos-x86_64 + - macos-aarch64 + - windows-x86_64 + include: + - os-arch: ubuntu-24.04-x86_64 + runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + - os-arch: ubuntu-24.04-aarch64 + runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + - os-arch: macos-x86_64 + runner: macos-26-intel + target: x86_64-apple-darwin + - os-arch: macos-aarch64 + runner: macos-26 + target: aarch64-apple-darwin + - os-arch: windows-x86_64 + runner: windows-2025 + target: x86_64-pc-windows-gnu + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 + with: + toolchain: ${{ matrix.toolchain }} + target: ${{ matrix.target }} + - run: cargo test --all-targets --target ${{ matrix.target }} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7491c5f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,186 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ortho" +version = "0.1.0" +dependencies = [ + "clap", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..814d7e2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ortho" +version = "0.1.0" +edition = "2024" +rust-version = "1.88.0" +license = "Apache-2.0" +readme = "README.md" +exclude = [".gitignore", ".gitattributes", ".github/*"] + +[dependencies] +clap = { version = "4.6.0", features = ["derive"] } diff --git a/README.md b/README.md index de1a706..91aa9a2 100644 --- a/README.md +++ b/README.md @@ -1 +1,400 @@ -# ortho \ No newline at end of file +# ORTHO + +![SemVer: pre-release](https://img.shields.io/badge/ortho-pre--release-blue) +![MSRV: 1.88.0](https://img.shields.io/badge/MSRV-1.88.0-brown.svg) +[![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-red.svg)](https://www.apache.org/licenses/LICENSE-2.0) + +**ORTHO** is a two-player CLI board game where players take turns selecting rows and columns on an N×N grid, place stones at the intersection of a player's current and previous selections, and race to make M-in-a-row. + +## Rules + +1. Two players alternate turns on an N×N board. +2. On each turn, a player selects either a **row** or a **column**. +3. On the **first selection** (no pending selection), a player may choose either axis freely, but must pick a row or column that has at least one empty cell. + No stone is placed (only one coordinate is known). +4. On the **completing selection**, the player must choose the **opposite axis** (if they chose a column, they must choose a row, and vice versa). The intersection must be **empty** — if it is occupied, the player must pick a different index. +5. A stone is placed at the intersection. The completing selection then **replaces** the previous one — the player stays locked on the new axis for their next turn. +6. If the locked row/column has **no empty cells** remaining, the turn is **skipped** and the selection is **cleared**. On their next turn the player returns to a free choice. +7. The first player to get **M consecutive stones** in a row, column, or diagonal wins. +8. If the board is completely filled with no winner, the game is a draw. + +The board display shows which row or column each player currently has selected (`X` for Player 1, `O` for Player 2) along the margins. + +## Usage + +``` +ortho [OPTIONS] +``` + +### Options + +| Flag | Description | Default | +| ---- | ----------- | ------- | +| `-n`, `--size ` | Board size (N×N) | 5 | +| `-m`, `--win-length ` | Consecutive stones needed to win (M ≤ N) | 4 | + +### Examples + +```sh +ortho # 5×5 board, 4-in-a-row to win +ortho -n 7 -m 5 # 7×7 board, 5-in-a-row to win +ortho -n 3 -m 3 # 3×3 board, 3-in-a-row to win +``` + +### Input Format + +| Input | Meaning | +|-------|---------| +| `r3` or `row 3` | Select row 3 | +| `c2` or `col 2` | Select column 2 | +| `q` or `quit` | Quit the game | + +### Gameplay Example + +```plaintext +=== Ortho === Board: 5x5 Win condition: 4 in a row +Input examples: r3 (row 3), c2 (col 2), quit (exit) + + + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + 3 | | | | | | + +---+---+---+---+---+ + 4 | | | | | | + +---+---+---+---+---+ + 5 | | | | | | + +---+---+---+---+---+ + +Player 1 (X) - Choose any row or column > c3 +Selected col 3 (first move, no stone placed) + + X + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + 3 | | | | | | + +---+---+---+---+---+ + 4 | | | | | | + +---+---+---+---+---+ + 5 | | | | | | + +---+---+---+---+---+ + +Player 2 (O) - Choose any row or column > r3 +Selected row 3 (first move, no stone placed) + + X + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + O 3 | | | | | | + +---+---+---+---+---+ + 4 | | | | | | + +---+---+---+---+---+ + 5 | | | | | | + +---+---+---+---+---+ + +Player 1 (X) - Choose a row > r3 +Placed stone at (3, 3)! + + + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ +X,O 3 | | | X | | | + +---+---+---+---+---+ + 4 | | | | | | + +---+---+---+---+---+ + 5 | | | | | | + +---+---+---+---+---+ + +Player 2 (O) - Choose a col > c2 +Placed stone at (3, 2)! + + O + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + X 3 | | O | X | | | + +---+---+---+---+---+ + 4 | | | | | | + +---+---+---+---+---+ + 5 | | | | | | + +---+---+---+---+---+ + +Player 1 (X) - Choose a col > c1 +Placed stone at (3, 1)! + + X O + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + 3 | X | O | X | | | + +---+---+---+---+---+ + 4 | | | | | | + +---+---+---+---+---+ + 5 | | | | | | + +---+---+---+---+---+ + +Player 2 (O) - Choose a row > r4 +Placed stone at (4, 2)! + + X + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + 3 | X | O | X | | | + +---+---+---+---+---+ + O 4 | | O | | | | + +---+---+---+---+---+ + 5 | | | | | | + +---+---+---+---+---+ + +Player 1 (X) - Choose a row > r5 +Placed stone at (5, 1)! + + + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + 3 | X | O | X | | | + +---+---+---+---+---+ + O 4 | | O | | | | + +---+---+---+---+---+ + X 5 | X | | | | | + +---+---+---+---+---+ + +Player 2 (O) - Choose a col > c4 +Placed stone at (4, 4)! + + O + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + 3 | X | O | X | | | + +---+---+---+---+---+ + 4 | | O | | O | | + +---+---+---+---+---+ + X 5 | X | | | | | + +---+---+---+---+---+ + +Player 1 (X) - Choose a col > c5 +Placed stone at (5, 5)! + + O X + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + 3 | X | O | X | | | + +---+---+---+---+---+ + 4 | | O | | O | | + +---+---+---+---+---+ + 5 | X | | | | X | + +---+---+---+---+---+ + +Player 2 (O) - Choose a row > r3 +Placed stone at (3, 4)! + + X + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + O 3 | X | O | X | O | | + +---+---+---+---+---+ + 4 | | O | | O | | + +---+---+---+---+---+ + 5 | X | | | | X | + +---+---+---+---+---+ + +Player 1 (X) - Choose a row > r3 +Placed stone at (3, 5)! + + + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ +X,O 3 | X | O | X | O | X | + +---+---+---+---+---+ + 4 | | O | | O | | + +---+---+---+---+---+ + 5 | X | | | | X | + +---+---+---+---+---+ + +Player 2 (O) - No valid placement available. Turn skipped, selection cleared. + + + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + X 3 | X | O | X | O | X | + +---+---+---+---+---+ + 4 | | O | | O | | + +---+---+---+---+---+ + 5 | X | | | | X | + +---+---+---+---+---+ + +Player 1 (X) - No valid placement available. Turn skipped, selection cleared. + + + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + 3 | X | O | X | O | X | + +---+---+---+---+---+ + 4 | | O | | O | | + +---+---+---+---+---+ + 5 | X | | | | X | + +---+---+---+---+---+ + +Player 2 (O) - Choose any row or column > c4 +Selected col 4 (first move, no stone placed) + + O + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + 3 | X | O | X | O | X | + +---+---+---+---+---+ + 4 | | O | | O | | + +---+---+---+---+---+ + 5 | X | | | | X | + +---+---+---+---+---+ + +Player 1 (X) - Choose any row or column > r4 +Selected row 4 (first move, no stone placed) + + O + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + 3 | X | O | X | O | X | + +---+---+---+---+---+ + X 4 | | O | | O | | + +---+---+---+---+---+ + 5 | X | | | | X | + +---+---+---+---+---+ + +Player 2 (O) - Choose a row > r5 +Placed stone at (5, 4)! + + + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + 3 | X | O | X | O | X | + +---+---+---+---+---+ + X 4 | | O | | O | | + +---+---+---+---+---+ + O 5 | X | | | O | X | + +---+---+---+---+---+ + +Player 1 (X) - Choose a col > c5 +Placed stone at (4, 5)! + + X + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + 3 | X | O | X | O | X | + +---+---+---+---+---+ + 4 | | O | | O | X | + +---+---+---+---+---+ + O 5 | X | | | O | X | + +---+---+---+---+---+ + +Player 2 (O) - Choose a col > c3 +Placed stone at (5, 3)! + + O X + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | | + +---+---+---+---+---+ + 3 | X | O | X | O | X | + +---+---+---+---+---+ + 4 | | O | | O | X | + +---+---+---+---+---+ + 5 | X | | O | O | X | + +---+---+---+---+---+ + +Player 1 (X) - Choose a row > r2 +Placed stone at (2, 5)! + + + 1 2 3 4 5 + +---+---+---+---+---+ + 1 | | | | | | + +---+---+---+---+---+ + 2 | | | | | X | + +---+---+---+---+---+ + 3 | X | O | X | O | X | + +---+---+---+---+---+ + 4 | | O | | O | X | + +---+---+---+---+---+ + 5 | X | | O | O | X | + +---+---+---+---+---+ + +Player 1 (X) wins! +``` + +## Installation + +```sh +cargo install --git https://github.com/hyperfinitism/ortho +``` diff --git a/src/board.rs b/src/board.rs new file mode 100644 index 0000000..137fe47 --- /dev/null +++ b/src/board.rs @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::types::{Axis, Cell}; + +pub struct Board { + size: usize, + win_length: usize, + cells: Vec>, +} + +impl Board { + pub fn new(size: usize, win_length: usize) -> Self { + Board { + size, + win_length, + cells: vec![vec![Cell::Empty; size]; size], + } + } + + pub fn size(&self) -> usize { + self.size + } + + pub fn get(&self, row: usize, col: usize) -> Cell { + self.cells[row][col] + } + + pub fn set(&mut self, row: usize, col: usize, cell: Cell) { + self.cells[row][col] = cell; + } + + pub fn check_win(&self, row: usize, col: usize, player: Cell) -> bool { + let directions: [(isize, isize); 4] = [ + (0, 1), // horizontal + (1, 0), // vertical + (1, 1), // diagonal ↘ + (1, -1), // diagonal ↙ + ]; + + for (dr, dc) in &directions { + let count = 1 + + self.count_consecutive(row, col, *dr, *dc, player) + + self.count_consecutive(row, col, -dr, -dc, player); + if count >= self.win_length { + return true; + } + } + false + } + + fn count_consecutive( + &self, + row: usize, + col: usize, + dr: isize, + dc: isize, + player: Cell, + ) -> usize { + let mut count = 0; + let mut r = row as isize + dr; + let mut c = col as isize + dc; + while r >= 0 + && r < self.size as isize + && c >= 0 + && c < self.size as isize + && self.cells[r as usize][c as usize] == player + { + count += 1; + r += dr; + c += dc; + } + count + } + + pub fn has_empty_along(&self, axis: Axis, index: usize) -> bool { + match axis { + Axis::Row => (0..self.size).any(|c| self.cells[index][c] == Cell::Empty), + Axis::Col => (0..self.size).any(|r| self.cells[r][index] == Cell::Empty), + } + } + + pub fn is_full(&self) -> bool { + self.cells.iter().flatten().all(|&c| c != Cell::Empty) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_board_is_empty() { + let board = Board::new(5, 4); + for r in 0..5 { + for c in 0..5 { + assert_eq!(board.get(r, c), Cell::Empty); + } + } + } + + #[test] + fn test_set_and_get() { + let mut board = Board::new(5, 4); + board.set(2, 3, Cell::Player1); + assert_eq!(board.get(2, 3), Cell::Player1); + assert_eq!(board.get(0, 0), Cell::Empty); + } + + #[test] + fn test_horizontal_win() { + let mut board = Board::new(5, 4); + for c in 0..4 { + board.set(2, c, Cell::Player1); + } + assert!(board.check_win(2, 3, Cell::Player1)); + assert!(board.check_win(2, 0, Cell::Player1)); + } + + #[test] + fn test_vertical_win() { + let mut board = Board::new(5, 4); + for r in 1..5 { + board.set(r, 0, Cell::Player2); + } + assert!(board.check_win(1, 0, Cell::Player2)); + assert!(board.check_win(4, 0, Cell::Player2)); + } + + #[test] + fn test_diagonal_win() { + let mut board = Board::new(5, 3); + board.set(0, 0, Cell::Player1); + board.set(1, 1, Cell::Player1); + board.set(2, 2, Cell::Player1); + assert!(board.check_win(1, 1, Cell::Player1)); + } + + #[test] + fn test_anti_diagonal_win() { + let mut board = Board::new(5, 3); + board.set(0, 4, Cell::Player1); + board.set(1, 3, Cell::Player1); + board.set(2, 2, Cell::Player1); + assert!(board.check_win(1, 3, Cell::Player1)); + } + + #[test] + fn test_no_win() { + let mut board = Board::new(5, 4); + board.set(0, 0, Cell::Player1); + board.set(0, 1, Cell::Player1); + board.set(0, 2, Cell::Player1); + // only 3, need 4 + assert!(!board.check_win(0, 2, Cell::Player1)); + } + + #[test] + fn test_is_full() { + let mut board = Board::new(2, 2); + assert!(!board.is_full()); + board.set(0, 0, Cell::Player1); + board.set(0, 1, Cell::Player2); + board.set(1, 0, Cell::Player2); + board.set(1, 1, Cell::Player1); + assert!(board.is_full()); + } + + #[test] + fn test_win_at_boundary() { + let mut board = Board::new(4, 4); + for r in 0..4 { + board.set(r, 3, Cell::Player1); + } + assert!(board.check_win(0, 3, Cell::Player1)); + assert!(board.check_win(3, 3, Cell::Player1)); + } +} diff --git a/src/display.rs b/src/display.rs new file mode 100644 index 0000000..af77ed0 --- /dev/null +++ b/src/display.rs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::board::Board; +use crate::types::{Axis, Selection}; + +/// Selections currently held by each player, used for display markers. +pub struct Selections { + pub p1: Option, + pub p2: Option, +} + +pub fn render_board(board: &Board, selections: &Selections) { + let size = board.size(); + let num_w = digit_count(size); + // Left margin: 3-char row marker + space + row number + space + let left = 3 + 1 + num_w + 1; + + // Column markers + print!("{:left$}", ""); + for c in 0..size { + let p1 = matches!(selections.p1, Some(Selection { axis: Axis::Col, index }) if index == c); + let p2 = matches!(selections.p2, Some(Selection { axis: Axis::Col, index }) if index == c); + let marker = match (p1, p2) { + (true, true) => "X,O", + (true, false) => "X", + (false, true) => "O", + (false, false) => "", + }; + print!(" {:^3}", marker); + } + println!(); + + // Column numbers + print!("{:left$}", ""); + for c in 1..=size { + print!(" {:^3}", c); + } + println!(); + + let sep = format!("{:left$}+{}", "", ("---+").repeat(size)); + + for r in 0..size { + println!("{}", sep); + + let p1 = matches!(selections.p1, Some(Selection { axis: Axis::Row, index }) if index == r); + let p2 = matches!(selections.p2, Some(Selection { axis: Axis::Row, index }) if index == r); + let marker = match (p1, p2) { + (true, true) => "X,O", + (true, false) => " X", + (false, true) => " O", + (false, false) => " ", + }; + print!("{} {:>num_w$} |", marker, r + 1); + for c in 0..size { + print!(" {} |", board.get(r, c)); + } + println!(); + } + println!("{}", sep); +} + +fn digit_count(n: usize) -> usize { + if n == 0 { + return 1; + } + let mut count = 0; + let mut val = n; + while val > 0 { + count += 1; + val /= 10; + } + count +} diff --git a/src/game.rs b/src/game.rs new file mode 100644 index 0000000..d20b22f --- /dev/null +++ b/src/game.rs @@ -0,0 +1,436 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::board::Board; +use crate::player::PlayerState; +use crate::types::*; + +pub struct Game { + pub board: Board, + pub players: [PlayerState; 2], + pub current_player: usize, +} + +impl Game { + pub fn new(board_size: usize, win_length: usize) -> Self { + Game { + board: Board::new(board_size, win_length), + players: [ + PlayerState::new(PlayerId::P1), + PlayerState::new(PlayerId::P2), + ], + current_player: 0, + } + } + + pub fn current(&self) -> &PlayerState { + &self.players[self.current_player] + } + + pub fn process_turn(&mut self, selection: Selection) -> Result { + let size = self.board.size(); + + // Bounds check + if selection.index >= size { + return Err(GameError::OutOfBounds { + index: selection.index, + max: size, + }); + } + + // Alternation constraint + if let Some(expected) = self.players[self.current_player].required_axis() + && selection.axis != expected + { + return Err(GameError::WrongAxis { expected }); + } + + let player = &self.players[self.current_player]; + + // First move (no pending selection): store selection, no stone. + // Must pick a row/col with at least one empty cell. + if player.last_selection.is_none() { + if !self.board.has_empty_along(selection.axis, selection.index) { + return Err(GameError::NoEmptyCell { + axis: selection.axis, + index: selection.index, + }); + } + self.players[self.current_player].last_selection = Some(selection); + return Ok(TurnResult::FirstMove); + } + + // Compute intersection + let prev = player.last_selection.unwrap(); + let (row, col) = match selection.axis { + Axis::Row => (selection.index, prev.index), + Axis::Col => (prev.index, selection.index), + }; + + // Check if occupied — player must pick a different index + let player_id = self.players[self.current_player].id; + if self.board.get(row, col) != Cell::Empty { + return Err(GameError::Occupied { row, col }); + } + + // Place stone — swap selection to the completing axis + self.board.set(row, col, player_id.cell()); + self.players[self.current_player].last_selection = Some(selection); + + if self.board.check_win(row, col, player_id.cell()) { + return Ok(TurnResult::Win { row, col }); + } + + Ok(TurnResult::StonePlaced { row, col }) + } + + pub fn check_skip(&mut self) -> Option { + if let Some(sel) = self.players[self.current_player].last_selection + && !self.board.has_empty_along(sel.axis, sel.index) + { + self.players[self.current_player].clear_selection(); + return Some(TurnResult::Skipped); + } + None + } + + pub fn switch_player(&mut self) { + self.current_player = 1 - self.current_player; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_first_move_no_stone() { + let mut game = Game::new(5, 4); + let sel = Selection { + axis: Axis::Col, + index: 2, + }; + let result = game.process_turn(sel).unwrap(); + assert!(matches!(result, TurnResult::FirstMove)); + } + + #[test] + fn test_completing_move_swaps_selection() { + let mut game = Game::new(5, 4); + // P1 first move: col 2 + game.process_turn(Selection { + axis: Axis::Col, + index: 2, + }) + .unwrap(); + game.switch_player(); + game.process_turn(Selection { + axis: Axis::Row, + index: 0, + }) + .unwrap(); + game.switch_player(); + // P1 completing move: row 3 -> stone at (3, 2) + let result = game + .process_turn(Selection { + axis: Axis::Row, + index: 3, + }) + .unwrap(); + match result { + TurnResult::StonePlaced { row, col } => { + assert_eq!(row, 3); + assert_eq!(col, 2); + } + _ => panic!("Expected StonePlaced"), + } + assert_eq!(game.board.get(3, 2), Cell::Player1); + // Selection swapped: now locked on row 3, must pick col next + let sel = game.players[0].last_selection.unwrap(); + assert_eq!(sel.axis, Axis::Row); + assert_eq!(sel.index, 3); + assert_eq!(game.players[0].required_axis(), Some(Axis::Col)); + } + + #[test] + fn test_selection_swaps_across_turns() { + let mut game = Game::new(5, 4); + // P1: col 2 + game.process_turn(Selection { + axis: Axis::Col, + index: 2, + }) + .unwrap(); + game.switch_player(); + // P2: row 0 + game.process_turn(Selection { + axis: Axis::Row, + index: 0, + }) + .unwrap(); + game.switch_player(); + // P1: row 3 -> stone at (3, 2), selection swaps to row 3 + game.process_turn(Selection { + axis: Axis::Row, + index: 3, + }) + .unwrap(); + game.switch_player(); + // P2: col 1 -> stone at (0, 1), selection swaps to col 1 + game.process_turn(Selection { + axis: Axis::Col, + index: 1, + }) + .unwrap(); + game.switch_player(); + // P1 now locked on row 3, must pick col + assert_eq!(game.players[0].required_axis(), Some(Axis::Col)); + let result = game + .process_turn(Selection { + axis: Axis::Col, + index: 4, + }) + .unwrap(); + assert!(matches!(result, TurnResult::StonePlaced { row: 3, col: 4 })); + // Now selection swapped to col 4 + let sel = game.players[0].last_selection.unwrap(); + assert_eq!(sel.axis, Axis::Col); + assert_eq!(sel.index, 4); + } + + #[test] + fn test_occupied_intersection_is_error() { + let mut game = Game::new(5, 4); + game.board.set(3, 2, Cell::Player2); + + // P1 first move: col 2 + game.process_turn(Selection { + axis: Axis::Col, + index: 2, + }) + .unwrap(); + game.switch_player(); + game.process_turn(Selection { + axis: Axis::Row, + index: 0, + }) + .unwrap(); + game.switch_player(); + + // P1 completing move: row 3 -> (3,2) is occupied -> error + let result = game.process_turn(Selection { + axis: Axis::Row, + index: 3, + }); + assert!(matches!( + result, + Err(GameError::Occupied { row: 3, col: 2 }) + )); + // Selection should NOT be cleared — player retries + assert!(game.players[0].last_selection.is_some()); + } + + #[test] + fn test_occupied_then_valid_pick() { + let mut game = Game::new(5, 4); + game.board.set(3, 2, Cell::Player2); + + // P1: col 2 + game.process_turn(Selection { + axis: Axis::Col, + index: 2, + }) + .unwrap(); + game.switch_player(); + game.process_turn(Selection { + axis: Axis::Row, + index: 0, + }) + .unwrap(); + game.switch_player(); + + // Try row 3 -> occupied -> error + assert!( + game.process_turn(Selection { + axis: Axis::Row, + index: 3, + }) + .is_err() + ); + + // Try row 1 -> empty -> should work + let result = game + .process_turn(Selection { + axis: Axis::Row, + index: 1, + }) + .unwrap(); + assert!(matches!(result, TurnResult::StonePlaced { row: 1, col: 2 })); + } + + #[test] + fn test_auto_skip_clears_selection() { + let mut game = Game::new(2, 2); + // Fill column 0 entirely + game.board.set(0, 0, Cell::Player1); + game.board.set(1, 0, Cell::Player2); + + // Manually set P1's selection to col 0 + game.players[0].last_selection = Some(Selection { + axis: Axis::Col, + index: 0, + }); + + // Col 0 is full -> auto-skip + let result = game.check_skip(); + assert!(matches!(result, Some(TurnResult::Skipped))); + assert!(game.players[0].last_selection.is_none()); + } + + #[test] + fn test_no_skip_when_valid_completion_exists() { + let mut game = Game::new(5, 4); + // P1 selects col 2 + game.process_turn(Selection { + axis: Axis::Col, + index: 2, + }) + .unwrap(); + game.switch_player(); + game.process_turn(Selection { + axis: Axis::Row, + index: 0, + }) + .unwrap(); + game.switch_player(); + + // Col 2 has empty cells -> no skip + let result = game.check_skip(); + assert!(result.is_none()); + } + + #[test] + fn test_free_choice_after_skip() { + let mut game = Game::new(2, 2); + game.board.set(0, 0, Cell::Player1); + game.board.set(1, 0, Cell::Player2); + + // P1 had col 0, gets skipped + game.players[0].last_selection = Some(Selection { + axis: Axis::Col, + index: 0, + }); + game.check_skip(); // clears selection + + // Now P1 has free choice + assert!(game.players[0].required_axis().is_none()); + let result = game + .process_turn(Selection { + axis: Axis::Col, + index: 1, + }) + .unwrap(); + assert!(matches!(result, TurnResult::FirstMove)); + } + + #[test] + fn test_free_selection_rejects_full_row() { + let mut game = Game::new(2, 2); + // Fill row 0 + game.board.set(0, 0, Cell::Player1); + game.board.set(0, 1, Cell::Player2); + + // P1 tries to select row 0 (no empty cells) -> error + let result = game.process_turn(Selection { + axis: Axis::Row, + index: 0, + }); + assert!(matches!( + result, + Err(GameError::NoEmptyCell { + axis: Axis::Row, + index: 0 + }) + )); + } + + #[test] + fn test_free_selection_accepts_row_with_empty() { + let mut game = Game::new(5, 4); + // Row 1 has empty cells + let result = game + .process_turn(Selection { + axis: Axis::Row, + index: 1, + }) + .unwrap(); + assert!(matches!(result, TurnResult::FirstMove)); + } + + #[test] + fn test_wrong_axis_error() { + let mut game = Game::new(5, 4); + game.process_turn(Selection { + axis: Axis::Col, + index: 2, + }) + .unwrap(); + game.switch_player(); + game.process_turn(Selection { + axis: Axis::Row, + index: 0, + }) + .unwrap(); + game.switch_player(); + // Try col again (should be row) + let result = game.process_turn(Selection { + axis: Axis::Col, + index: 1, + }); + assert!(matches!( + result, + Err(GameError::WrongAxis { + expected: Axis::Row + }) + )); + } + + #[test] + fn test_out_of_bounds() { + let mut game = Game::new(5, 4); + let result = game.process_turn(Selection { + axis: Axis::Row, + index: 5, + }); + assert!(matches!(result, Err(GameError::OutOfBounds { .. }))); + } + + #[test] + fn test_win_detection() { + let mut game = Game::new(5, 3); + game.board.set(0, 0, Cell::Player1); + game.board.set(0, 1, Cell::Player1); + + // P1: col 2 -> first move + game.process_turn(Selection { + axis: Axis::Col, + index: 2, + }) + .unwrap(); + game.switch_player(); + game.process_turn(Selection { + axis: Axis::Row, + index: 4, + }) + .unwrap(); + game.switch_player(); + + // P1: row 0 -> stone at (0, 2) -> 3 in a row -> win + let result = game + .process_turn(Selection { + axis: Axis::Row, + index: 0, + }) + .unwrap(); + assert!(matches!(result, TurnResult::Win { row: 0, col: 2 })); + } +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..d42e378 --- /dev/null +++ b/src/input.rs @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::types::{Axis, Selection}; + +pub enum PlayerInput { + Select(Selection), + Quit, +} + +pub fn parse_input(input: &str, board_size: usize) -> Result { + let input = input.trim().to_lowercase(); + + if input == "q" || input == "quit" { + return Ok(PlayerInput::Quit); + } + + let (axis, num_str) = if let Some(rest) = input.strip_prefix("row") { + (Axis::Row, rest.trim()) + } else if let Some(rest) = input.strip_prefix("col") { + (Axis::Col, rest.trim()) + } else if let Some(rest) = input.strip_prefix('r') { + (Axis::Row, rest.trim()) + } else if let Some(rest) = input.strip_prefix('c') { + (Axis::Col, rest.trim()) + } else { + return Err("Invalid format. Examples: r3, c2, row 3, col 2".to_string()); + }; + + let number: usize = num_str + .parse() + .map_err(|_| format!("Expected a number, got: '{}'", num_str))?; + + if number == 0 || number > board_size { + return Err(format!("Must be in range 1-{}", board_size)); + } + + Ok(PlayerInput::Select(Selection { + axis, + index: number - 1, // convert to 0-based + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_row_short() { + match parse_input("r3", 5).unwrap() { + PlayerInput::Select(sel) => { + assert_eq!(sel.axis, Axis::Row); + assert_eq!(sel.index, 2); // 0-based + } + _ => panic!("Expected Select"), + } + } + + #[test] + fn test_parse_col_long() { + match parse_input("col 2", 5).unwrap() { + PlayerInput::Select(sel) => { + assert_eq!(sel.axis, Axis::Col); + assert_eq!(sel.index, 1); + } + _ => panic!("Expected Select"), + } + } + + #[test] + fn test_parse_row_long() { + match parse_input("row 5", 5).unwrap() { + PlayerInput::Select(sel) => { + assert_eq!(sel.axis, Axis::Row); + assert_eq!(sel.index, 4); + } + _ => panic!("Expected Select"), + } + } + + #[test] + fn test_parse_quit() { + assert!(matches!(parse_input("q", 5).unwrap(), PlayerInput::Quit)); + assert!(matches!(parse_input("quit", 5).unwrap(), PlayerInput::Quit)); + assert!(matches!(parse_input("QUIT", 5).unwrap(), PlayerInput::Quit)); + } + + #[test] + fn test_parse_out_of_range() { + assert!(parse_input("r0", 5).is_err()); + assert!(parse_input("r6", 5).is_err()); + } + + #[test] + fn test_parse_invalid() { + assert!(parse_input("x3", 5).is_err()); + assert!(parse_input("", 5).is_err()); + assert!(parse_input("row abc", 5).is_err()); + } + + #[test] + fn test_parse_with_whitespace() { + match parse_input(" c 4 ", 5).unwrap() { + PlayerInput::Select(sel) => { + assert_eq!(sel.axis, Axis::Col); + assert_eq!(sel.index, 3); + } + _ => panic!("Expected Select"), + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c5f8f79 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 + +mod board; +mod display; +mod game; +mod input; +mod player; +mod types; + +use clap::Parser; +use std::io::{self, Write}; + +use crate::display::{Selections, render_board}; +use crate::game::Game; +use crate::input::{PlayerInput, parse_input}; +use crate::types::TurnResult; + +#[derive(Parser)] +#[command(name = "ortho", about = "Orthogonal selection board game")] +struct Args { + /// Board size (N×N) + #[arg(short = 'n', long = "size", default_value_t = 5)] + board_size: usize, + + /// Number of consecutive stones needed to win + #[arg(short = 'm', long = "win-length", default_value_t = 4)] + win_length: usize, +} + +fn main() { + let args = Args::parse(); + + if args.board_size == 0 { + eprintln!("Error: board size must be at least 1"); + std::process::exit(1); + } + if args.win_length == 0 || args.win_length > args.board_size { + eprintln!("Error: win length must be in range 1-{}", args.board_size); + std::process::exit(1); + } + + println!( + "=== Ortho === Board: {}x{} Win condition: {} in a row", + args.board_size, args.board_size, args.win_length + ); + println!("Input examples: r3 (row 3), c2 (col 2), quit (exit)\n"); + + let mut game = Game::new(args.board_size, args.win_length); + + loop { + let sels = Selections { + p1: game.players[0].last_selection, + p2: game.players[1].last_selection, + }; + render_board(&game.board, &sels); + println!(); + + // Auto-skip if no valid completion exists + if let Some(TurnResult::Skipped) = game.check_skip() { + println!( + "{} - No valid placement available. Turn skipped, selection cleared.\n", + game.current().id.name() + ); + game.switch_player(); + continue; + } + + let player = game.current(); + let constraint_msg = match player.required_axis() { + Some(axis) => format!("Choose a {}", axis), + None => "Choose any row or column".to_string(), + }; + print!("{} - {} > ", player.id.name(), constraint_msg); + io::stdout().flush().expect("failed to flush stdout"); + + let mut input_line = String::new(); + if io::stdin() + .read_line(&mut input_line) + .expect("failed to read from stdin") + == 0 + { + println!("\nExiting game."); + break; + } + + let player_input = match parse_input(&input_line, args.board_size) { + Ok(pi) => pi, + Err(e) => { + println!("Error: {}", e); + continue; + } + }; + + match player_input { + PlayerInput::Quit => { + println!("Exiting game."); + break; + } + PlayerInput::Select(selection) => { + let axis_name = selection.axis; + let display_index = selection.index + 1; + + match game.process_turn(selection) { + Ok(TurnResult::FirstMove) => { + println!( + "Selected {} {} (first move, no stone placed)\n", + axis_name, display_index + ); + } + Ok(TurnResult::StonePlaced { row, col }) => { + println!("Placed stone at ({}, {})!\n", row + 1, col + 1); + } + Ok(TurnResult::Skipped) => unreachable!(), + Ok(TurnResult::Win { row, col }) => { + println!("Placed stone at ({}, {})!\n", row + 1, col + 1); + let sels = Selections { p1: None, p2: None }; + render_board(&game.board, &sels); + println!("\n{} wins!", game.current().id.name()); + return; + } + Err(e) => { + println!("Error: {}\n", e); + continue; + } + } + + if game.board.is_full() { + let sels = Selections { p1: None, p2: None }; + render_board(&game.board, &sels); + println!("\nDraw!"); + return; + } + + game.switch_player(); + } + } + } +} diff --git a/src/player.rs b/src/player.rs new file mode 100644 index 0000000..8a02aad --- /dev/null +++ b/src/player.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::types::{Axis, PlayerId, Selection}; + +pub struct PlayerState { + pub id: PlayerId, + pub last_selection: Option, +} + +impl PlayerState { + pub fn new(id: PlayerId) -> Self { + PlayerState { + id, + last_selection: None, + } + } + + pub fn required_axis(&self) -> Option { + self.last_selection.map(|s| s.axis.opposite()) + } + + pub fn clear_selection(&mut self) { + self.last_selection = None; + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..6903279 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Axis { + Row, + Col, +} + +impl Axis { + pub fn opposite(self) -> Axis { + match self { + Axis::Row => Axis::Col, + Axis::Col => Axis::Row, + } + } +} + +impl fmt::Display for Axis { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Axis::Row => write!(f, "row"), + Axis::Col => write!(f, "col"), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Selection { + pub axis: Axis, + pub index: usize, // 0-based +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Cell { + Empty, + Player1, + Player2, +} + +impl fmt::Display for Cell { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Cell::Empty => write!(f, " "), + Cell::Player1 => write!(f, "X"), + Cell::Player2 => write!(f, "O"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlayerId { + P1, + P2, +} + +impl PlayerId { + pub fn cell(self) -> Cell { + match self { + PlayerId::P1 => Cell::Player1, + PlayerId::P2 => Cell::Player2, + } + } + + pub fn name(self) -> &'static str { + match self { + PlayerId::P1 => "Player 1 (X)", + PlayerId::P2 => "Player 2 (O)", + } + } +} + +#[derive(Debug)] +pub enum TurnResult { + FirstMove, + StonePlaced { row: usize, col: usize }, + Skipped, + Win { row: usize, col: usize }, +} + +#[derive(Debug)] +pub enum GameError { + OutOfBounds { index: usize, max: usize }, + WrongAxis { expected: Axis }, + Occupied { row: usize, col: usize }, + NoEmptyCell { axis: Axis, index: usize }, +} + +impl fmt::Display for GameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GameError::OutOfBounds { index, max } => { + write!(f, "Out of bounds: {} (must be 1-{})", index + 1, max) + } + GameError::WrongAxis { expected } => { + write!(f, "You must choose a {}", expected) + } + GameError::Occupied { row, col } => { + write!( + f, + "({}, {}) is already occupied. Pick a different index", + row + 1, + col + 1, + ) + } + GameError::NoEmptyCell { axis, index } => { + write!( + f, + "{} {} has no empty cells. Pick a different {}", + axis, + index + 1, + axis, + ) + } + } + } +}