Compare commits

10 Commits
Author SHA1 Message Date
GarandPLG 851d3410dd Replace named Ratatui colors with RGB values
Project tests / check (push) Successful in 3m38s
Project tests / nix (push) Successful in 9m16s
Project tests / linux (push) Successful in 2m15s
Project tests / aur (push) Successful in 8m29s
Update docs, increase test count to 107, adjust flake.lock, and modify
rendering logic to use explicit Color::Rgb throughout the codebase.
2026-10-26 13:21:25 +01:00
GarandPLG b1f548e3d3 Consolidate CI into single Project tests workflow
Project tests / check (push) Successful in 3m44s
Project tests / nix (push) Successful in 10m41s
Project tests / linux (push) Successful in 3m8s
Project tests / aur (push) Successful in 11m27s
- Delete obsolete .gitea/workflows/nix.yml and rust.yml.
- Rename packages.yml to tests.yml.
- Add jobs check, nix, linux, aur with proper needs ordering.
- Update docs for new unified workflow.
- Remove bash and docker from flake.nix buildInputs.
2026-10-24 20:12:14 +02:00
GarandPLG d293f95174 Add support structures and stress mechanics
Nix package / build (push) Successful in 8m38s
Linux package builds (test) / linux (push) Successful in 2m19s
Linux package builds (test) / aur (push) Successful in 8m29s
Rust checks / check (push) Successful in 4m3s
Introduce support structures (roof supports) and a stress system that
propagates from bases through tunnels, causing collapses when stress
reaches 100 %.  Add auto‑support feature with toggleable tasks, new
keybindings (Tasks, AutoSupports, Stress), and a support‑building tab.
Implement collapsed tunnels, support work progress, and related UI
rendering (stress view, auto‑support indicators).  Extend board resize
logic, update game state structs, and adjust keybinding actions.  Update
documentation (README, AGENTS, CODE_MAP, PROGRESS, TESTING) to reflect
new features.  Add flake.nix dependencies (bash, gitea‑actions‑runner,
docker).  Add extensive tests for tunnel stress, support building,
auto‑supports, and related mechanics.
2026-10-24 19:33:38 +02:00
GarandPLG 110d4c411a Add tab navigation for building and recruitment UI
Nix package / build (push) Successful in 7m59s
Linux package builds (test) / linux (push) Successful in 2m29s
Linux package builds (test) / aur (push) Successful in 7m18s
Rust checks / check (push) Successful in 3m21s
- Add selection indices and option lists to SkirmishState.
- Implement `cycle_popup_option` and expose Left/Right actions in
  keybindings.
- Introduce `cancel_focused_tasks` in BoardState for Delete handling.
- CellWidget now displays recruitment progress on Base structures.
- Update README and documentation to describe new tabs and arrow
  navigation.
- Adjust test counts and add extensive task‑cancellation tests.
2026-10-23 17:53:46 +02:00
GarandPLG 6c1141a711 Use b for building and r for recruitment
Nix package / build (push) Successful in 8m22s
Linux package builds (test) / linux (push) Successful in 2m17s
Rust checks / check (push) Successful in 3m20s
Linux package builds (test) / aur (push) Successful in 7m34s
- Replace Ctrl+Space with `b` and Alt+Space with `r` in README and
  documentation.
- Remove the old Alt+Space recruitment keybinding and adjust key
  handling code.
- Revamp skirmish view to display colored lines and updated status
  messages.
- Increase test suite to 83 tests and update test expectations for new
  shortcuts.
- Update flake.lock with the latest revision and hash.
2026-10-23 11:29:25 +02:00
GarandPLG 73f544d5d6 Update formatting to use 4‑space indentation
Nix package / build (push) Successful in 8m44s
Linux package builds (test) / linux (push) Successful in 2m25s
Linux package builds (test) / aur (push) Successful in 7m41s
Rust checks / check (push) Successful in 3m39s
- Adjust rustfmt.toml to set tab_spaces = 4.
- Reformat imports, struct definitions, function bodies, and match arms
  across the project to match the new style (aligned fields, added
  spacing, etc.).
- Ensure all code complies with the updated rustfmt settings.
2026-10-19 17:43:43 +02:00
GarandPLG a981663152 Rename iron to metal, add mining and recruitment
Nix package / build (push) Successful in 9m39s
Rust checks / check (push) Successful in 3m44s
Linux package builds (test) / linux (push) Successful in 2m22s
Linux package builds (test) / aur (push) Successful in 7m21s
- Updated CLI, README and documentation to use `--starting-metal` with
  `--starting-iron` retained as an alias.
- Introduced `Resources` struct and constants for wood delivery and mine
  production.
- Added new structures `Mine` and `MineConstruction` with cost,
  production
  and cancel logic.
- Implemented building view for constructing mines and recruitment view
  for spawning miners.
- Extended keybindings with `Building` and `Recruitment` actions and
  updated
  action availability logic.
- Modified game state, board, units and UI widgets to handle metal
  resources
  and new construction mechanics.
- Updated tests and increased total test count to 82, covering
  resources,
  construction, recruitment and context‑aware keybindings.
2026-10-19 17:41:22 +02:00
GarandPLG 305cc2c4a2 Merge pull request 'Test packaging' (#2) from test_packaging into main
Linux package builds (test) / aur (push) Successful in 5m50s
Rust checks / check (push) Successful in 2m38s
Nix package / build (push) Successful in 7m43s
Linux package builds (test) / linux (push) Failing after 22s
Reviewed-on: #2
2026-10-19 15:56:30 +02:00
GarandPLG 276646d011 Add Node.js to Arch CI dependencies
Rust checks / check (push) Successful in 2m37s
Nix package / build (push) Successful in 6m38s
Linux package builds (test) / linux (push) Successful in 2m16s
Linux package builds (test) / aur (push) Successful in 6m3s
Nix package / build (pull_request) Successful in 7m51s
Linux package builds (test) / linux (pull_request) Successful in 2m15s
Linux package builds (test) / aur (pull_request) Successful in 6m15s
Rust checks / check (pull_request) Successful in 2m53s
Install nodejs in the Arch build container and verify its version
Update docs to note Node.js as a CI dependency for the AUR job
2026-10-19 15:25:25 +02:00
GarandPLG c3dbfe435a Add Linux packaging workflow and related scripts
Nix package / build (push) Successful in 7m20s
Linux package builds (test) / linux (push) Successful in 3m17s
Linux package builds (test) / aur (push) Failing after 1m2s
Rust checks / check (push) Successful in 2m42s
Introduce a new CI workflow `.gitea/workflows/packages.yml` that builds
deb, rpm, AppImage, Flatpak and AUR packages on push and PR events.
Add packaging helpers (`build.sh`, `stage.sh`, spec file, PKGBUILD,
desktop entry, SVG icon) and supporting assets under `packaging/`.
Update `.gitignore` to ignore the generated `dist` directory.
Expand documentation (CODE_MAP, AGENTS, PROGRESS, TESTING and new
PACKAGING.md) to describe the packaging process and its limitations.
Adjust `src/app/threads/audio.rs` and `tests/audio.rs` imports to
support loading audio files from installed package layouts.
2026-10-19 15:04:36 +02:00
89 changed files with 8501 additions and 3765 deletions

No files matched your search

-23
View File
@@ -1,23 +0,0 @@
name: Nix package
on:
push:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
container: node:22-bookworm
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Install runner prerequisites
run: |
apt-get update
apt-get install -y --no-install-recommends sudo
- name: Install Nix
uses: https://github.com/cachix/install-nix-action@v31
with:
enable_kvm: false
- name: Build default.nix package
run: nix build .#default --no-update-lock-file --no-link --print-out-paths --print-build-logs
-58
View File
@@ -1,58 +0,0 @@
name: Rust checks
on:
push:
pull_request:
jobs:
check:
runs-on: ubuntu-latest
container: node:22-bookworm
env:
RUST_VERSION: 1.99.1
CARGO_HOME: /root/.cargo
RUSTUP_HOME: /root/.rustup
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Install build dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl build-essential pkg-config libasound2-dev zstd
- name: Rust toolchain cache
uses: https://github.com/actions/cache@v4
with:
path: |
/root/.rustup
/root/.cargo/bin
key: rust-toolchain-v1-linux-x64-${{ env.RUST_VERSION }}
- name: Install Rust
run: |
if [ ! -x "$CARGO_HOME/bin/rustup" ]; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh
sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain none --no-modify-path
fi
"$CARGO_HOME/bin/rustup" toolchain install "$RUST_VERSION" --profile minimal --component rustfmt --component clippy
"$CARGO_HOME/bin/rustup" default "$RUST_VERSION"
echo "$CARGO_HOME/bin" >> "$GITHUB_PATH"
- name: Cargo cache
uses: https://github.com/actions/cache@v4
with:
path: |
/root/.cargo/registry
/root/.cargo/git
target
key: cargo-v1-linux-x64-${{ env.RUST_VERSION }}-${{ hashFiles('Cargo.lock') }}-${{ hashFiles('Cargo.toml', 'src/**', 'tests/**') }}
restore-keys: |
cargo-v1-linux-x64-${{ env.RUST_VERSION }}-${{ hashFiles('Cargo.lock') }}-
cargo-v1-linux-x64-${{ env.RUST_VERSION }}-
- name: Formatting
run: cargo fmt --all -- --check
- name: Compilation
run: cargo check --all-targets --locked
- name: Tests
run: cargo test --all-targets --locked
- name: Release tests
run: cargo test --all-targets --release --locked
- name: Clippy
run: cargo clippy --all-targets --locked
+184
View File
@@ -0,0 +1,184 @@
name: Project tests
on:
push:
pull_request:
jobs:
check:
runs-on: ubuntu-latest
container: node:22-bookworm
env:
RUST_VERSION: 1.99.1
CARGO_HOME: /root/.cargo
RUSTUP_HOME: /root/.rustup
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Install build dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl build-essential pkg-config libasound2-dev zstd
- name: Rust toolchain cache
uses: https://github.com/actions/cache@v4
with:
path: |
/root/.rustup
/root/.cargo/bin
key: rust-toolchain-v1-linux-x64-${{ env.RUST_VERSION }}
- name: Install Rust
run: |
if [ ! -x "$CARGO_HOME/bin/rustup" ]; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh
sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain none --no-modify-path
fi
"$CARGO_HOME/bin/rustup" toolchain install "$RUST_VERSION" --profile minimal --component rustfmt --component clippy
"$CARGO_HOME/bin/rustup" default "$RUST_VERSION"
echo "$CARGO_HOME/bin" >> "$GITHUB_PATH"
- name: Cargo cache
uses: https://github.com/actions/cache@v4
with:
path: |
/root/.cargo/registry
/root/.cargo/git
target
key: cargo-v1-linux-x64-${{ env.RUST_VERSION }}-${{ hashFiles('Cargo.lock') }}-${{ hashFiles('Cargo.toml', 'src/**', 'tests/**') }}
restore-keys: |
cargo-v1-linux-x64-${{ env.RUST_VERSION }}-${{ hashFiles('Cargo.lock') }}-
cargo-v1-linux-x64-${{ env.RUST_VERSION }}-
- name: Formatting
run: cargo fmt --all -- --check
- name: Compilation
run: cargo check --all-targets --locked
- name: Tests
run: cargo test --all-targets --locked
- name: Release tests
run: cargo test --all-targets --release --locked
- name: Clippy
run: cargo clippy --all-targets --locked
nix:
needs: check
runs-on: ubuntu-latest
container: node:22-bookworm
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Install runner prerequisites
run: |
apt-get update
apt-get install -y --no-install-recommends sudo
- name: Install Nix
uses: https://github.com/cachix/install-nix-action@v31
with:
enable_kvm: false
- name: Build default.nix package
run: nix build .#default --no-update-lock-file --no-link --print-out-paths --print-build-logs
linux:
needs: nix
runs-on: ubuntu-latest
container: node:22-bookworm
env:
CARGO_HOME: /root/.cargo
RUSTUP_HOME: /root/.rustup
RUST_VERSION: 1.99.1
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Install packaging dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl build-essential pkg-config libasound2-dev python3 rpm cpio desktop-file-utils squashfs-tools p7zip-full file flatpak ostree zstd
- name: Rust toolchain cache
uses: https://github.com/actions/cache@v4
with:
path: |
/root/.rustup
/root/.cargo/bin
key: rust-toolchain-v1-linux-x64-${{ env.RUST_VERSION }}
- name: Install Rust
run: |
if [ ! -x "$CARGO_HOME/bin/rustup" ]; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh
sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain none --no-modify-path
fi
"$CARGO_HOME/bin/rustup" toolchain install "$RUST_VERSION" --profile minimal --component rustfmt --component clippy
"$CARGO_HOME/bin/rustup" default "$RUST_VERSION"
echo "$CARGO_HOME/bin" >> "$GITHUB_PATH"
- name: Cargo cache
uses: https://github.com/actions/cache@v4
with:
path: |
/root/.cargo/registry
/root/.cargo/git
target
key: packages-v1-linux-x64-${{ env.RUST_VERSION }}-${{ hashFiles('Cargo.lock') }}-${{ hashFiles('Cargo.toml', 'src/**', 'tests/**') }}
restore-keys: |
packages-v1-linux-x64-${{ env.RUST_VERSION }}-${{ hashFiles('Cargo.lock') }}-
- name: Compile Linux binary
id: compile
run: cargo build --release --locked
- name: Build deb
if: ${{ !cancelled() && steps.compile.outcome == 'success' }}
run: bash packaging/build.sh deb
- name: Build rpm
if: ${{ !cancelled() && steps.compile.outcome == 'success' }}
run: bash packaging/build.sh rpm
- name: Build AppImage
if: ${{ !cancelled() && steps.compile.outcome == 'success' }}
run: bash packaging/build.sh appimage
- name: Build Flatpak
if: ${{ !cancelled() && steps.compile.outcome == 'success' }}
run: bash packaging/build.sh flatpak
- name: Save test packages
if: ${{ !cancelled() }}
uses: https://github.com/actions/upload-artifact@v3
with:
name: linux-packages-x86_64
path: dist/*
if-no-files-found: error
retention-days: 7
aur:
needs: linux
runs-on: ubuntu-latest
container: archlinux:base-devel
steps:
- name: Install Arch dependencies
run: |
pacman -Syu --noconfirm --needed git nodejs rust cargo pkgconf alsa-lib python zstd
node --version
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Build AUR recipe from this commit
run: |
version=$(python -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["package"]["version"])')
recipe_version=$(bash -c 'source packaging/aur/PKGBUILD; printf "%s" "$pkgver"')
test "$version" = "$recipe_version"
mkdir -p /tmp/aur-build
cp packaging/aur/PKGBUILD /tmp/aur-build/
tar --exclude=.git --exclude=target --exclude=dist --transform="s,^\./,war-in-tunnels-$version/," -czf "/tmp/aur-build/war-in-tunnels-$version.tar.gz" .
cd /tmp/aur-build
checksum=$(sha256sum "war-in-tunnels-$version.tar.gz" | cut -d ' ' -f 1)
sed -i "s/sha256sums=('SKIP')/sha256sums=('$checksum')/" PKGBUILD
useradd -m builder
chown -R builder:builder /tmp/aur-build
runuser -u builder -- bash -c 'cd /tmp/aur-build && makepkg --cleanbuild --noconfirm && makepkg --printsrcinfo > .SRCINFO'
pacman -U --noconfirm ./*.pkg.tar.zst
cd /tmp
war-in-tunnels --version
- name: Save Arch test package and recipe
uses: https://github.com/actions/upload-artifact@v3
with:
name: arch-package-x86_64
path: |
/tmp/aur-build/*.pkg.tar.zst
/tmp/aur-build/PKGBUILD
/tmp/aur-build/.SRCINFO
/tmp/aur-build/*.tar.gz
if-no-files-found: error
retention-days: 7
+1
View File
@@ -1,4 +1,5 @@
/target
/result
/dist
war_in_tunnels.log
+50 -2
View File
@@ -63,7 +63,7 @@ Gra korzysta z biblioteki **clap** – wszystkie opcje są dostępne jako flagi.
| `-z` | `--zoom-level` | `Default` | Poziom przybliżenia UI. |
| `-p` | `--perk-deck` | `Silesian` | Zestaw perków. |
| `-w` | `--starting-wood` | `50` | Początkowa ilość drewna (1‑150). |
| `-i` | `--starting-iron` | `25` | Początkowa ilość żelaza (1‑150). |
| `-i` | `--starting-metal` | `25` | Początkowa ilość metalu (1‑150); stara nazwa `--starting-iron` nadal działa jako alias. |
| `-L` | `--supply-limit` | `99` | Limit jednostek na mapie (49‑149). |
| `-x` | `--xp-modifier` | `1.0` | Mnożnik doświadczenia (0.5‑2.0). |
| `-P` | `--skill-points-limit` | `120` | Górny limit punktów umiejętności (90‑690). |
@@ -89,7 +89,7 @@ cargo run -- -h
## Kopanie
W potyczce gracz zaczyna z jednym górnikiem `Miner` w swojej bazie. Ustaw kursor na jednostce (z początkowej pozycji wystarczy strzałka w lewo), naciśnij spację, wytycz ścieżkę strzałkami i zatwierdź spacją. Backspace cofa ostatni krok, Delete anuluje rysowanie.
W potyczce gracz zaczyna z jednym górnikiem `Miner` w swojej bazie. Ustaw kursor na jednostce (z początkowej pozycji wystarczy strzałka w lewo), naciśnij spację, wytycz ścieżkę strzałkami i zatwierdź spacją. Backspace cofa ostatni krok, Delete lub Esc anuluje rysowanie.
Górnik wykonuje zadania kolejno. Kamień stopniowo traci durability; po wykopaniu zmienia się w tunel, do którego jednostka wchodzi w tym samym ticku. Pełna kratka kamienia zajmuje około 6 sekund. Procent pozostałej wytrzymałości widać na uszkodzonej kratce; Tab otwiera panel z dokładną wartością dla pola pod kursorem. Przejście do istniejącego tunelu lub niezajętej bazy trwa około 3 sekund na kratkę. Znacznik jednostki ma kolor właściciela: niebieski dla gracza, czerwony dla przeciwnika.
@@ -101,6 +101,52 @@ W kolejce pod Tab każde zadanie pokazuje `Step` (postęp bieżącej kratki) ora
Możesz zaznaczyć pracującego górnika ponownie: rysowanie kolejnego zadania zacznie się na końcu jego dotychczasowej kolejki. Podczas rysowania wybrana jednostka wstrzymuje pracę. Zajęte pole lub złoże rudy zatrzymuje wykonywanie ścieżki; ten typ zadania kopie tylko kamień.
## Zasoby i budowanie
Okna budowania (`b`) i rekrutacji (`r`) mają zakładki przełączane strzałkami ←/→. Zaznaczona pozycja jest wyróżniona jasną magentą. Obecnie dostępne są Mine i Support w budowaniu oraz Miner w rekrutacji; strzałki w tych oknach nie przesuwają kursora mapy. Enter wykonuje dostępną akcję, a ponowne b/r zamyka odpowiednie okno.
Wood i Metal mają rzeczywisty stan w magazynie każdego gracza, inicjalizowany z `--starting-wood` i `--starting-metal`. Nagłówek pokazuje bieżące zasoby. Gracz otrzymuje 5 Wood co około 5 sekund symulacji; metal nie przyrasta bez kopalni. Złoża Ore są początkowo neutralne.
Ustaw kursor na neutralnym Ore i otwórz widok budowania przez `b`. Jeśli na jednej z czterech sąsiednich kratek stoi własny Miner z uprawnieniem budowania i masz 20 Wood oraz 10 Metal, Enter rozpoczyna budowę Mine. Koszt jest pobierany od razu, a budowa trwa około 10 sekund. Panel pozostaje otwarty i pokazuje postęp; procent widać też na kratce. Po odejściu budowniczego budowa zatrzymuje się do jego powrotu. Nie można budować zdalnie na rudzie bez sąsiedniego własnego budowniczego.
Delete w panelu budowy anuluje trwającą budowę, przywraca pierwotne neutralne Ore i zwraca cały koszt. b jedynie zamyka panel — budowa nadal postępuje, jeżeli budowniczy jest obecny. Niedokończona kopalnia nie produkuje metalu i nie przyjmuje jednostek. Na początkowej mapie złoże obok bazy spełnia warunek sąsiedztwa; kopalnie nie pojawiają się automatycznie.
Mine wydobywa 1 Metal co około sekundę, aż do wyczerpania złoża. Możesz wysłać do własnej kopalni górnika zwykłą trasą zatwierdzaną spacją. Wejście trwa około 3 sekund jako Moving; górnik pozostaje na kratce kopalni i zwiększa wydobycie do 2 Metal/s. Po jego wyjściu bonus znika. W kopalni mieści się jedna jednostka; obcy górnicy nie mogą do niej wejść. Panel Tab pokazuje aktualne tempo wydobycia.
Wspólna reguła zwykłych budynków wymaga pustego kamienia sąsiadującego z tunelem lub własną bazą oraz sąsiedniego własnego budowniczego. Mine jest wyjątkiem: wymaga rudy zamiast kamienia i nie potrzebuje połączenia z tunelem/bazą. Innych typów budynków nie można jeszcze stawiać.
Legenda klawiszy zależy od trybu: na mapie pokazuje nawigację i dostępne zaznaczenie własnego górnika; podczas rysowania trasy — ruch, zatwierdzanie i anulowanie; w widoku budowania — rozpoczęcie lub anulowanie budowy; w rekrutacji — dodawanie górników do kolejki. Globalne skróty wyjścia i muzyki nadal działają. W zwykłym trybie Esc wraca do menu.
## Stres tuneli i podpory
Stres rośnie o 20 punktów procentowych na każdą kratkę najkrótszej połączonej trasy od bazy: 20%, 40%, 60%, 80%. Liczą się połączenia góra/dół/lewo/prawo, nie odległość w linii prostej. Podpora ustala stres swojego tunelu na 20% i stabilizuje sąsiednią sieć we wszystkich czterech kierunkach. Gotowe kopalnie przepuszczają połączenie; bazy są punktami odniesienia dla obu stron.
- `b` → strzałkami wybierz **Support** → `Enter`: rozpoczyna około 3-sekundową budowę podpory w tunelu za **10 Wood + 2 Metal**. Własny Miner musi stać na tej kratce lub obok niej.
- `Delete` w zakładce Support rozpoczyna około 3-sekundowe usuwanie podpory bez zwrotu zasobów. Do końca pracy podpora nadal stabilizuje tunel; dopiero potem są przeliczane stres i zawalenia.
- Przy **100%** tunel zawala się, a odcięte, niestabilne odcinki również ulegają zawaleniu. Wszystkie zasypane jednostki giną wraz ze swoimi kolejkami.
- Zawalony tunel (`#`) ma **750 durability**, jest nieprzechodni i można go ponownie wykopać. Zwykły kamień ma 1000, a tunel 500 durability.
- `s` przełącza widok stresu: ukrywa inne oznaczenia kratek, pokazuje procenty i kolory od zielonego do czerwonego; kursor zachowuje dotychczasowe wyróżnienie. W najmniejszym zoomie gotowe 100% jest pokazane jako `100`.
Najpierw zaznacz własnego Minera przez `Space`. Dopiero wtedy widoczny i dostępny jest `t`, który otwiera **boczny panel wyboru zadania**, obok planszy. Strzałkami ←/→ wybierz **Digging** albo **Moving**; `Enter` zatwierdza wybór i wraca do wytyczania trasy, `t` zamyka panel. Digging przechodzi po tunelach i kopie przeszkody; Moving pozwala wytyczać trasę tylko po polach przechodnich i czeka, jeśli droga później się zawali. `a` przełącza automatyczne podpory dla Digging (domyślnie włączone); działa też podczas wytyczania takiej trasy. Ustawienie jest zapisywane w nowych zadaniach, nie zmienia już zakolejkowanych. Spacja pozostaje skrótem do zwykłego wytyczania trasy z kopaniem.
Automat stawia podporę na ostatniej bezpiecznej kratce 80% dopiero, gdy kolejny wykop wymaga zabezpieczenia. Na prostej trasie pierwsza podpora wypada cztery kratki od bazy, następne co trzy kratki; niepotrzebna podpora na końcu trasy nie powstaje. Każda kosztuje rzeczywiste zasoby właściciela. **Brak środków lub wyłączenie automatu może doprowadzić do śmierci górnika przy kolejnym wykopie.** Wykonane tunele i uszkodzenia pozostają po anulowaniu zadania.
Budowniczy na czas pracy nad podporą wstrzymuje swój ruch i kopanie. Postęp jest widoczny na kratce i w panelach; bez dostępnego budowniczego praca pauzuje. Automat rozpoczyna budowę przed niebezpiecznym wykopem i czeka na ukończenie podpory. W mapie stresu zwykły kamień zachowuje normalny wygląd i nie pokazuje procentu stresu.
## Odwoływanie zadań jednostki
Pozostałe ścieżki zadań własnych jednostek są podświetlane kolorem LightMagenta, a pole ścieżki pod kursorem — Magenta. Podświetlenie obejmuje wszystkie zadania w kolejce i znika wraz z wykonaniem lub odwołaniem odcinków.
Ustaw kursor na własnej jednostce i otwórz szczegóły przez Tab. Backspace odwołuje ostatnie zadanie (także aktywne, jeśli zostało tylko jedno). Delete odwołuje całą kolejkę i zatrzymuje jednostkę. Przerwany ruch pozostawia jednostkę na obecnej kratce; wykonane tunele i uszkodzenia kamienia pozostają na mapie. Podczas wytyczania trasy te klawisze nadal cofają i anulują wyłącznie rysowaną trasę. W oknie budowania Delete nadal anuluje budowę.
## Rekrutacja
Na kratce bazy widać cyjanowy procent bieżącej rekrutacji. Wskaźnik pozostaje przy 100%, gdy gotowa jednostka czeka na wolne miejsce, i znika po opróżnieniu kolejki. W najmniejszym zoomie gotowość pokazuje `100` bez znaku procentu, aby zmieścić się w kratce.
Ustaw kursor na własnym budynku Base i naciśnij `r`. Enter dodaje jednego Minera do kolejki za **10 Wood i 15 Metal**, pobierane od razu. Każda rekrutacja trwa około **10 sekund**. Panel pokazuje liczbę zamówionych górników (włącznie z aktualnie rekrutowanym), procent bieżącej rekrutacji i powód oczekiwania. `r` zamyka panel bez kasowania kolejki. Liczbę i postęp widać też pod Tab.
Pierwszy górnik może zostać wyszkolony do 100%, nawet jeśli baza jest zajęta. Gotowy czeka, aż kratka będzie wolna, i pojawia się w bazie bez nadpisywania stojącej tam jednostki. Następna rekrutacja pozostaje na 0%, dopóki poprzedni zrekrutowany górnik nie opuści bazy. Wyprowadź go zwykłą trasą, aby wznowić kolejkę. Każda baza ma niezależną kolejkę; nowe jednostki otrzymują właściciela bazy i pełne uprawnienia Minera.
## Dźwięk
Ścieżki dźwiękowe znajdują się w folderze `soundtracks`. Biblioteka **rodio** odtwarza je w oddzielnym wątku. Dostępne flagi pozwalają wybrać konkretną ścieżkę, wyciszyć lub całkowicie wyłączyć dźwięk.
@@ -119,3 +165,5 @@ Polecenia lokalne i szczegóły środowiska opisano w [docs/TESTING.md](docs/TES
Projekt jest udostępniany na licencji [MIT](LICENSE).
Copyright (c) 2026 GarandPLG.
Budowanie otwiera `b`, rekrutację `r`; zwiększanie głośności: `+`, zmniejszanie: `-`, wyciszenie: `m`.
+13 -4
View File
@@ -11,6 +11,8 @@ Instrukcje i notatki dla agentów przechowujemy w `docs/`. Ten plik jest punktem
## Narzędzia i weryfikacja
Flake uruchamiany z repozytorium Git pomija pliki nieśledzone, nawet gdy Cargo widzi je lokalnie. Przy błędach Nix „file not found for module” sprawdź git status i git ls-files. Nowe źródła i testy można uwidocznić przez git add -N -- <pliki> (intent-to-add, bez dodawania treści do stagingu ani tworzenia commita). Sama zmiana src = ./. w default.nix nie przywróci plików pominiętych na wejściu flake.
Nie wolno zakończyć zadania, jeżeli kompilator Rust zgłasza błędy uniemożliwiające kompilację. Przed zakończeniem uruchom `nix develop -c cargo check --all-targets`; jeśli wystąpią błędy kompilacji, usuń je i powtórz kontrolę aż do uzyskania sukcesu.
Wszystkie narzędzia Rust uruchamiaj przez `nix develop -c` z katalogu repozytorium:
@@ -23,16 +25,20 @@ nix develop -c cargo run -- --help
nix develop -c cargo run -- --disable-audio --view skirmish
```
Ostatnie polecenie wymaga interaktywnego terminala. `shellHook` sam wykonuje `cargo run --locked -- -h`, więc wejście do środowiska także kompiluje program i drukuje pomoc. Przy kontrolach używaj `--locked`, aby nie aktualizować `Cargo.lock`. Formatowanie stosuj wyłącznie do uzgodnionych zmian; nie używaj automatycznego `clippy --fix` do porządkowania całego repozytorium.
Ostatnie polecenie wymaga interaktywnego terminala. `shellHook` drukuje dostępne polecenia, bez automatycznej kompilacji. Przy kontrolach używaj `--locked`, aby nie aktualizować `Cargo.lock`. Formatowanie stosuj wyłącznie do uzgodnionych zmian; nie używaj automatycznego `clippy --fix` do porządkowania całego repozytorium.
Stan bazowy po konfiguracji lintów: 45 testów i formatowanie przechodzą; Clippy zgłasza 50 ostrzeżeń dla biblioteki. `clippy::unused_unit` jest dozwolony ze względu na wymagane jawne `-> ()`. Zakres i polecenia opisano w [TESTING.md](TESTING.md). Nie traktuj tego jako potwierdzenia działania pełnej rozgrywki. Nowa nietrywialna logika wymaga małego testu regresji. Nie dodawaj zależności ani warstw abstrakcji bez potrzeby.
Aktualny zestaw obejmuje 107 testów. Clippy ma istniejące ostrzeżenia dla biblioteki. `clippy::unused_unit` jest dozwolony ze względu na wymagane jawne `-> ()`. Zakres i polecenia opisano w [TESTING.md](TESTING.md). Nie traktuj tego jako potwierdzenia działania pełnej rozgrywki. Nowa nietrywialna logika wymaga małego testu regresji. Nie dodawaj zależności ani warstw abstrakcji bez potrzeby.
`Cargo.toml` wymaga Rust 1.88 lub nowszego, zabrania `unsafe` we własnym kodzie i ignorowania wyników `must_use`, a w release włącza kontrolę przepełnień. CI Gitea w `.gitea/workflows/rust.yml` sprawdza formatowanie, kompilację, testy debug/release i Clippy bez Nix, przez Rust 1.99.1 instalowany rustup; lokalnie nadal używaj Nix. Oddzielny `.gitea/workflows/nix.yml` buduje `.#default` z `default.nix`, z testami pakietu i kontrolą instalacji. Nie dodawaj `-D warnings` przed uporządkowaniem istniejących ostrzeżeń.
`Cargo.toml` wymaga Rust 1.88 lub nowszego, zabrania `unsafe` we własnym kodzie i ignorowania wyników `must_use`, a w release włącza kontrolę przepełnień. CI Gitea to jeden workflow `Project tests` w `.gitea/workflows/tests.yml`, uruchamiany po pushu i dla pull requestów. Zależności `needs` ustalają kolejność `check → nix → linux → aur`; błąd joba pomija zależne joby. `check` sprawdza formatowanie, kompilację, testy debug/release i Clippy bez Nix, przez Rust 1.99.1 instalowany rustup; lokalnie nadal używaj Nix. Job `nix` buduje `.#default` z `default.nix`, z testami pakietu i kontrolą instalacji. Nie dodawaj `-D warnings` przed uporządkowaniem istniejących ostrzeżeń.
Audio wymaga ALSA i urządzenia odtwarzania; do pracy nad TUI używaj `--disable-audio`. `--mute` nadal uruchamia wątek audio. Ścieżki zasobów i pakowanie sprawdzaj osobno przy zmianach audio/Nix. Rozróżniaj lokalny build Cargo, pakiet `.#develop` i domyślny pakiet Nix.
Joby `linux` i `aur` w `.gitea/workflows/tests.yml` testują paczki Linux x86_64 bez Nix: deb, rpm, AppImage, Flatpak oraz przepis AUR budowany przez makepkg. Nie publikują wydań ani tagów. Przepisy, layout i ograniczenia opisano w [PACKAGING.md](PACKAGING.md); zmiany pakowania muszą zachować muzykę obok binarki i działanie poza katalogiem repozytorium.
## Konwencje kodu
Mechanikę tuneli sprawdzaj przez tests/tunnel_stress.rs. Pełna symulacja przechodzi przez SkirmishState::tick_update, który rozlicza Excavation, środki na automatyczne podpory i śmiertelne kaskady. Ręczne podpory zmieniaj przez build_support/remove_support; surowa flaga Tunnel::set_support jest API modelu i sama nie rozlicza zasobów ani zawaleń. Digging/Moving muszą zachować auto_supports przy konwersji; jawnie wybrany Moving (dig_if_blocked=false) nie może zacząć kopać. Render stresu nie może nadpisywać modelu komórki.
- Formatowanie określa `rustfmt.toml`: styl Rust 2024, wcięcia po 2 spacje, szerokość docelowa 100 znaków i zakończenia LF. Stosuj `nix develop -c cargo fmt --all`; kontroluj przez `nix develop -c cargo fmt --all -- --check`.
- Jawne adnotacje typów są OBOWIĄZKOWE w nowym i zmienianym kodzie: przy zmiennych i stałych, polach struktur, parametrach funkcji i domknięć oraz typach zwracanych (także `-> ()` dla funkcji bez wyniku). Nie polegaj na inferencji typu tam, gdzie można podać typ jawnie. Dotyczy to również testów. Jest to zasada jawnego typowania; rzutowania wartości (`as`, `From`, `TryFrom`) stosuj tylko wtedy, gdy potrzebna jest rzeczywista konwersja.
- Rust 2024, jedna biblioteka i jeden binarny punkt wejścia; bez workspace.
@@ -40,4 +46,7 @@ Audio wymaga ALSA i urządzenia odtwarzania; do pracy nad TUI używaj `--disable
- Współrzędne planszy to `(row, col)`, indeksowanie `cells[row][col]`; rozmiar mapy i rozmiar widocznego obszaru to różne wartości.
- `CellWidget` przechowuje także dane gry. Zmiana modelu komórki dotyka planszy, jednostek, struktur i panelu bocznego.
- Lista skrótów w widokach oraz tabela `KEYBINDINGS` muszą odpowiadać obsłudze akcji.
- `advance_turn` jest wywoływane przez tick potyczki. Zachowaj jedną aktualizację na jednostkę na tick, aktualność jej współrzędnych, kolejność zadań i ochronę zajętych pól. Regresje sprawdzaj przez `tests/digging.rs`.
- Konteksty mapy, trasy i budowania określa `available_skirmish_actions`. Globalne wyjście i muzyka działają niezależnie od legendy. b otwiera budowanie dla wskazanego pola; Enter wymaga poprawnego miejsca, sąsiedniego własnego budowniczego i środków. Delete anuluje własną budowę i zwraca koszt, b tylko zamyka panel.
- Bieżące zasoby są w SkirmishState, nie w ustawieniach CLI. Gameplay rozpoczyna budowę przez SkirmishState::start_mine_construction, aby objąć ją opłatą; BoardState::can_build_at/start_mine_construction obsługują samą walidację terenu i zmianę struktury. MineConstruction zachowuje oryginalne Ore i postępuje tylko przy sąsiednim własnym budowniczym. Mine zachowuje rezerwę Ore i kredytuje wyłącznie właściciela. Unit::get_can_build deleguje uprawnienia przez Units i Option. Ruch do gotowej własnej Mine korzysta ze Structures::can_enter i pozostaje Moving. Górnik na kratce kopalni podwaja wydobycie; nie twórz odrębnej kopii obsadzonej jednostki. Zmiany sprawdzaj przez tests/resources.rs, tests/context_keybindings.rs i tests/construction.rs.
- Rekrutację opłacaj przez SkirmishState::recruit_miner, nie bezpośrednie BaseBuilding::queue_miner. r otwiera panel tylko przy własnej bazie i poza innym trybem modalnym. Pierwsza rekrutacja może dojść do 100% na zajętej bazie, ale nie może nadpisać jednostki; kolejne stoją na 0% do wyjścia poprzedniego rekruta. BaseBuilding zachowuje licznik, postęp i waiting_for_departure; BoardState uruchamia spawn po ruchu istniejących jednostek. Sprawdzaj tests/recruitment.rs.
- Tick potyczki najpierw rozlicza 3-sekundowe prace podpór i uruchamia automat przed niebezpiecznym wykopem, następnie używa advance_turn_with_events i rozstrzyga zawalenia; advance_turn to sam krok jednostek. Zachowaj jedną aktualizację na jednostkę na tick, aktualność jej współrzędnych, kolejność zadań i ochronę zajętych pól. Regresje sprawdzaj przez `tests/digging.rs`.
+45 -8
View File
@@ -1,6 +1,6 @@
# Mapa kodu i przepływu
Migawka: 2026-10-18. Projekt `war_in_tunnels` 0.1.0 to prototyp terminalowego RTS. Działają menu, plansza, nawigacja, zlecanie i wykonywanie kopania oraz audio. Zegar obsługuje zadania jednostek; ekonomia i walka pozostają poza zakresem obecnej symulacji.
Migawka: 2026-10-19. Projekt `war_in_tunnels` 0.1.0 to prototyp terminalowego RTS. Działają menu, plansza, nawigacja, zlecanie i wykonywanie kopania, magazyny Wood/Metal, dostawy drewna, budowanie Mine, obsadzanie kopalni górnikiem, rekrutacja Miner w Base i audio. Walka i pełna ekonomia pozostają poza zakresem obecnej symulacji.
## Moduły i odpowiedzialność
@@ -17,13 +17,16 @@ Wszystkie ścieżki poniżej są względem katalogu głównego repozytorium.
| Plansza | `src/app/states/skirmish_states/board.rs` | Tworzenie mapy, kamera, zoom, focus, zaznaczenia, zlecanie i wykonywanie zadań |
| Współrzędne | `focused_cell.rs`, `offset.rs`, `marked_cells.rs`, `zoom_level.rs` w `skirmish_states/` | Granice kursora i przewijania, ścieżka zaznaczenia, rozmiar komórek |
| Obiekty gry | `skirmish_states/structures/`, `units/`, `tasks/` | Traity `Structure`, `Unit`, `Task` i delegujące enumy `Structures`, `Units`, `Tasks` |
| Zasoby | `skirmish_states/resources.rs`, `states/skirmish.rs`, `structures/mine.rs` | Osobne magazyny gracza i przeciwnika, pasywne Wood i wydobycie metalu z ograniczonego złoża |
| Rekrutacja | `structures/base.rs`, `units/miner.rs`, `states/skirmish.rs`, `board.rs` | Koszt górnika, niezależne kolejki baz i bezpieczne pojawianie się jednostek po ruchu |
| Widgety | `src/app/widgets/` | Plansza, komórka, panel boczny i legenda klawiszy |
| Pomocniczy layout | `src/app/helpers/` | Obszar komórek, tytuły bloków i tekst opcji menu |
| Wątki | `src/app/threads/{events,tick,audio}.rs` | Crossterm, zegar 33 ms, Rodio i komendy audio |
| Środowisko | `Cargo.toml`, `flake.nix`, `default.nix`, `flake.lock` | Zależności, toolchain Fenix, ALSA, pakowanie przez Naersk lub buildRustPackage |
| Formatowanie | `rustfmt.toml` | Rustfmt, styl 2024, wcięcia po 2 spacje, docelowo 100 znaków i zakończenia LF |
| Kontrole CI | `.gitea/workflows/rust.yml` | Gitea Actions w `node:22-bookworm`: Rust 1.99.1 przez rustup, cache toolchaina i Cargo, formatowanie, kompilacja, testy debug/release i Clippy |
| Budowa paczki Nix | `.gitea/workflows/nix.yml` | Oddzielny workflow buduje `.#default`, czyli `default.nix` przez nixpkgs przypięte w `flake.lock`, z testami i kontrolą instalacji pakietu |
| Kontrole CI | `.gitea/workflows/tests.yml` | Workflow `Project tests` po pushu i dla pull requestów; kolejność przez `needs`: `check → nix → linux → aur`. Job `check` w `node:22-bookworm`: Rust 1.99.1 przez rustup, cache toolchaina i Cargo, formatowanie, kompilacja, testy debug/release i Clippy |
| Budowa paczki Nix | `.gitea/workflows/tests.yml` (job `nix`) | Po sukcesie `check` buduje `.#default`, czyli `default.nix` przez nixpkgs przypięte w `flake.lock`, z testami i kontrolą instalacji pakietu |
| Paczki Linux | `.gitea/workflows/tests.yml` (joby `linux`, `aur`), `packaging/` | Po sukcesie `nix`: testowe deb, rpm, AppImage i Flatpak w `linux`, następnie makepkg dla AUR w `aur`; bez wydań i tagów; szczegóły w `docs/PACKAGING.md` |
| Licencja | `LICENSE`, `Cargo.toml`, `default.nix` | MIT, Copyright (c) 2026 GarandPLG |
| Zasoby | `soundtracks/default/`, `ui_draft/` | Dwa pliki OGG i tekstowe szkice ekranów |
@@ -53,9 +56,9 @@ flowchart TD
LOOP -->|exit lub błąd| RESTORE[ratatui::restore]
```
Kanały to standardowe `std::sync::mpsc`. Stan gry mutuje główny wątek. Każdy obrót pętli najpierw rysuje, następnie próbuje odebrać po jednym zdarzeniu z każdego kanału. Aktualizacja potyczki odbywa się niezależnie od wybranego ekranu. `tick_update` wykonuje `board.advance_turn()` i zwiększa `turn_counter`.
Kanały to standardowe `std::sync::mpsc`. Stan gry mutuje główny wątek. Każdy obrót pętli najpierw rysuje, następnie próbuje odebrać po jednym zdarzeniu z każdego kanału. Aktualizacja potyczki odbywa się niezależnie od wybranego ekranu. `tick_update` wykonuje kroki jednostek przez advance_turn_with_events, rozlicza podpory i zawalenia, rekrutację, budowę oraz zasoby, a następnie zwiększa licznik czasu.
`GameStates::new` tworzy wszystkie stany jednocześnie. `SettingsState` przechowuje parametry z CLI; samo istnienie parametrów ekonomii, XP i trybu gry nie oznacza zaimplementowanych mechanik. `View` zawiera MainMenu, Skirmish, PerkDecks i SkillsConfig. Dwa ostatnie używają `default_view` z napisem „Work in progress”; ustawienia nie mają osobnego wariantu `View`.
`GameStates::new` tworzy wszystkie stany jednocześnie. `SettingsState` przechowuje parametry z CLI; `SkirmishState` ma odrębne bieżące `player_resources` i `enemy_resources`, inicjalizowane z starting_wood/starting_metal. Nagłówek TUI odczytuje te magazyny zamiast ustawień. `--starting-iron` jest aliasem nowego `--starting-metal`; XP, supply i tryb gry nadal nie mają pełnej symulacji. `View` zawiera MainMenu, Skirmish, PerkDecks i SkillsConfig. Dwa ostatnie używają `default_view` z napisem „Work in progress”; ustawienia nie mają osobnego wariantu `View`.
## Renderowanie i dane
@@ -76,10 +79,28 @@ flowchart LR
`BoardState.cells: Vec<Vec<CellWidget>>` łączy model i prezentację: komórka zawiera strukturę, opcjonalną jednostkę, pozycję i flagi UI. `BoardWidget` wybiera widoczne komórki przez offsety i layout; `CellWidget` odpowiada za ich tekst i styl. Panel boczny prezentuje dane zaznaczenia. Zoom wpływa na wielkość komórek i liczbę widocznych wierszy/kolumn; resize i przełączanie panelu zmieniają granice offsetów.
Mapa powstaje deterministycznie: kamień, baza gracza w `(map_height / 2, 2)`, baza przeciwnika w `(map_height / 2, map_width - 3)`, sąsiadujące złoża i po jednym górniku w bazie. CLI domyślnie wybiera mapę 42 × 11; dopuszczalne zakresy to szerokość 36–108 i wysokość 11–39. Dostępne struktury to Base, Ore, Stone, Tunnel; jedyna jednostka to Miner. Zadania to Digging i Moving, implementujące wspólny trait Task.
Mapa powstaje deterministycznie: kamień, baza gracza w `(map_height / 2, 2)`, baza przeciwnika w `(map_height / 2, map_width - 3)`, sąsiadujące neutralne złoża i po jednym górniku w bazie. Kopalnie nie są rozmieszczane na starcie. CLI domyślnie wybiera mapę 42 × 11; dopuszczalne zakresy to szerokość 36–108 i wysokość 11–39. Struktury to Base, Ore, Stone, Tunnel, MineConstruction, Mine i CollapsedTunnel; jedyna jednostka to Miner. Zadania to Digging i Moving, implementujące wspólny trait Task.
`Resources` przechowuje Wood i Metal jako u32; dodawanie saturuje zamiast przepełniać licznik. `tick_update` po zadaniach jednostek dodaje graczowi 5 Wood co 152 ticki (5016 ms). Co 31 ticków (1023 ms) kopalnie wydobywają po 1 Metal lub 2 Metal, jeśli na ich kratce stoi własny Miner. `Mine::accepts_unit` określa możliwość obsadzenia, a `production_per_cycle` ogranicza wydobycie pozostałą zawartością złoża. Rzeczywiście uzyskana ilość jest odejmowana z Ore i dopisywana właścicielowi. Wyczerpane złoże pozostaje z ilością zero. SidePanelWidget pokazuje tempo i aktywny bonus Miner. Wartości i okresy określają stałe w resources.rs oraz Mine::METAL_PER_CYCLE/STAFFED_METAL_PER_CYCLE.
`BoardState::can_build_at` sprawdza granice, pustą kratkę, własnego sąsiedniego budowniczego (cztery kierunki) i BuildingTerrain. Zwykłe budynki wymagają kamienia połączonego krawędzią z tunelem lub własną bazą; Mine wymaga neutralnego, niewyczerpanego Ore i omija wyłącznie warunek połączenia, nigdy obecność budowniczego. `Unit::get_can_build` ma domyślnie false, Miner zwraca true, a Units i Option delegują uprawnienie. Gameplay wywołuje SkirmishState::start_mine_construction: sprawdza zasoby, ponownie waliduje teren i pobiera Mine::COST (20 Wood, 10 Metal). Niedobór dowolnego zasobu nie zmienia planszy ani magazynu. BoardState odpowiada za warstwę terenu, nie opłaty. Nie ma jeszcze innych budowanych obiektów.
MineConstruction zachowuje gotowy model Mine z oryginalnym Ore oraz licznik budowy. Po ruchu jednostek BoardState::advance_construction nalicza tick tylko przy sąsiednim własnym budowniczym; odejście pauzuje budowę, powrót wznawia. Po 304 tickach (10032 ms, około 10 sekund) struktura staje się Mine. Do tego czasu nie można do niej wejść ani wydobywać. Procenty są na kratce, w panelu Tab i widoku budowania. SkirmishState::cancel_mine_construction dopuszcza tylko własny aktywny plac, przywraca oryginalne neutralne Ore i jednokrotnie zwraca koszt; zakończonej kopalni nie da się w ten sposób sprzedać. Resources::spend sprawdza oba zasoby przed odjęciem.
`available_skirmish_actions` jest wspólne dla obsługi wejścia i legendy. Na mapie spacja jest dostępna przy własnym górniku, a zoom zależy od poziomu. Rysowanie trasy ukrywa zoom, przewijanie i budowanie; Esc anuluje trasę. b otwiera SkirmishState::building_view dla wskazanego pola. Poprawne pole z funduszami udostępnia Enter/Build Mine; panel pozostaje otwarty z procentami i Delete/Cancel construction dla własnej trwającej budowy. b tylko zamyka panel, nie anuluje budowy. Muzyka i wyjście są globalne. Widget legendy stosuje opisy zależne od kontekstu i szerokości kolumn proporcjonalne do tekstu, zachowując stałą wysokość stopki i geometrię mapy.
`Structures::can_enter` jest wspólną regułą dla planowania trasy i jej wykonywania: baza i tunel pozostają przechodnie; Mine przyjmuje własnego Minera, jeżeli kratka nie jest zajęta. Wejście do kopalni jest Moving (około 3 sekund), również gdy zadanie wcześniej miało typ Digging. Obsadzony górnik pozostaje jedyną jednostką na kratce, można go zaznaczyć i wyprowadzić zwykłą trasą. Bonus wylicza się z bieżącej obecności jednostki po ruchu; nie ma osobnej kopii pracownika ani osobnego zadania pracy.
BaseBuilding przechowuje queued_miners, recruitment_ticks i waiting_for_departure. Ponieważ kolejka obsługuje wyłącznie identycznych Minerów, wystarcza licznik pozycji zamiast listy obiektów. SkirmishState::recruit_miner sprawdza typ i właściciela bazy, granice, pojemność licznika i MinerUnit::RECRUIT_COST (10 Wood, 15 Metal), po czym opłaca dodanie pozycji. BoardState::advance_recruitment jest wywoływane po ruchu jednostek, przed budowaniem i wydobyciem. Pierwszy Miner rekrutuje się przez 304 ticki (10032 ms), nawet na zajętej bazie; przy 100% czeka na wolne pole. Spawn tworzy jednostkę z właścicielem i współrzędnymi bazy oraz pustą kolejką zadań. Po spawnie waiting_for_departure blokuje odliczanie kolejnych pozycji, dopóki baza jest zajęta. Nigdy nie nadpisujemy obecnej jednostki. Każda baza ma osobną kolejkę; przeciwnik korzysta z własnych zasobów.
r jest dostępne na własnej Base poza rysowaniem trasy i widokiem budowania. SkirmishState::recruitment_view otwiera modal z kosztem, liczbą pozycji, procentem i statusem Recruiting/Ready/Paused. Enter dodaje Minera tylko przy wystarczających zasobach. r zamyka panel, zachowując kolejkę; ruch mapy i otwarcie innego modalu są wtedy zablokowane. Opisy w legendzie zależą od trybu, a postęp i liczba pozycji są też pod Tab.
## Klawisze i zlecanie kopania
Okna budowania i rekrutacji mają Tabs oraz osobne indeksy selected_building/selected_recruit w SkirmishState. Listy BUILDING_OPTIONS/RECRUITMENT_OPTIONS zawierają obecnie Mine i Support / Miner. cycle_popup_option zawija wybór strzałkami Left/Right w obrębie aktywnego okna, bez ruchu planszy. Przy dodawaniu kolejnego typu trzeba uzupełnić listę oraz powiązać szczegóły, walidację i akcje Enter/Delete z jego indeksem; obecna implementacja akcji obsługuje tylko istniejące typy.
CellWidget pokazuje cyjanowy postęp BaseBuilding::recruitment_progress na środku kratki, gdy queued_miners > 0. Wskaźnik obejmuje 0% i oczekiwanie przy 100%; po opróżnieniu kolejki znika. W najmniejszym zoomie 100 nie ma sufiksu %, aby zmieścić trzy znaki. Rekrutacja ma pierwszeństwo przed procentem ruchu jednostki opuszczającej bazę; w zwykłym i dużym zoomie zachowany jest znacznik jednostki.
1. `handle_ct_events` odbiera zdarzenie Crossterm i przekazuje je kanałem do `App`.
2. `handle_keybindings` wybiera obsługę menu, potyczki albo domyślną.
3. `event_to_action` porównuje kod, rodzaj i modyfikatory z `KEYBINDINGS`, następnie obsługuje znak wildcard.
@@ -100,11 +121,27 @@ Postęp bieżącego kroku jest udostępniany przez `MovingTask::get_movement_pro
`Task::get_progress()` oznacza postęp bieżącej kratki, a `get_total_progress()` uwzględnia wykonane kratki i bieżący etap. Oba typy zadań zapamiętują początkową długość trasy w `total_steps`; `from_digging` i `from_moving` zachowują ją przy zmianie typu. `MinerUnit::get_tasks_formatted()` prezentuje każde zadanie w dwóch wierszach: trasa oraz `Step: …% | Total: …%`. Wartość całkowita jest liczona według liczby kratek, nie szacowanego czasu pozostałego do końca.
Gdy panel Tab jest otwarty, fokus wskazuje własną jednostkę z zadaniami i nie jest rysowana trasa, legenda udostępnia Backspace/Cancel last task oraz Delete/Cancel all tasks. BoardState::cancel_focused_tasks usuwa ostatnie zadanie albo czyści kolejkę; waliduje właściciela, niepustą kolejkę i brak rysowania. Usunięcie zadania usuwa również jego timer. Teren i położenie jednostki pozostają bez zmian. Nie usuwamy zadań ze środka trasy, ponieważ dalsze odcinki zależą od końca poprzednich. Modal budowania zachowuje Delete do anulowania budowy.
BoardWidget podczas renderowania zbiera pozostałe współrzędne zadań własnych jednostek do HashSet. W widocznych kopiach CellWidget ustawia marked dla tych pól, korzystając z pary RGB magenta i obsługi zoomu. Skupione pole gotowej trasy poza wytyczaniem zadania pozostaje żółte; magenta fokusu obowiązuje podczas wytyczania. Nie modyfikuje flag modelu ani rysowanej trasy. Zakończenie lub anulowanie zadania usuwa podświetlenie przy kolejnym renderowaniu; trasy przeciwnika nie są pokazywane.
## Stres, podpory i zawalenia
Panel zadań ma zakładki TASK_OPTIONS: Digging/Moving i indeks selected_task. Digging zachowuje dotychczasowy podział trasy na odcinki kopania i ruchu. Wybór Moving ustawia BoardState::movement_only na czas rysowania: kursor nie przekracza nieprzechodnich pól, a zadania MovingTask otrzymują dig_if_blocked=false, więc nie zamieniają się w kopanie po późniejszym zawaleniu drogi. Domyślne zadania ruchu w ramach trasy kopania zachowują konwersję. Zatwierdzenie/anulowanie czyści movement_only; spacja rozpoczynająca nową trasę wybiera domyślne kopanie. Zamknięcie panelu t i przejście Enter do rysowania odtwarzają rozmiar planszy zależny od Tab.
`Tunnel` przechowuje roof_support i stres. Podpora daje `get_level() == 'T'`, kosztuje Tunnel::SUPPORT_COST (10 Wood, 2 Metal). `SkirmishState::can_build_support/build_support/remove_support` sprawdzają teren, własnego budowniczego na polu lub obok, zasoby i aktualny stan podpory. Budowa i usuwanie trwają po 91 ticków pracy (3003 ms). Tunnel przechowuje SupportWork (owner, removing, ticks); dopiero ukończenie zmienia roof_support. Usunięcie nie zwraca zasobów i wywołuje kaskadę po zakończeniu. Nie istnieje osobny budynek podpory.
`board_stress.rs` rozszerza BoardState: recalculate_stress wykonuje wieloźródłowe przejście po siatce. Bazy startują z odległością 0, podpory z 20; każdy krok po tunelu lub gotowej Mine dodaje 20, z limitem 100. Odłączone tunele mają 100. resolve_collapses zamienia tunele o stresie >=100 w CollapsedTunnel, usuwa jednostki i ponawia przeliczenie do stabilizacji. Jeśli zginął zaznaczony budowniczy, czyści rysowanie jego trasy. CollapsedTunnel ma 750 HP, tag #, stres 100 i nie jest przechodni; można go wykopać ponownie. Obrażenia kopania odnoszą się do bazowych 1000 HP kamienia, więc gruz usuwa się szybciej.
`advance_turn_with_events` wykonuje jeden krok jednostek i zwraca Excavation (origin, destination, owner, auto_supports). Tick SkirmishState najpierw advance_support_work (maksymalnie jedna praca na budowniczego, pauza bez budowniczego), następnie prepare_automatic_supports. Przed niebezpiecznym wykopem automat opłaca i rozpoczyna podporę na własnym polu 80%; prospective_tunnel_stress wylicza ryzyko przed uszkodzeniem przeszkody. working_on_support wstrzymuje ruch i kopanie pracującego górnika. Po wykopach tick rozstrzyga zawalenia przed rekrutacją i budową. Automat nie stawia podpory na końcu trasy, jeśli kolejny wykop jej nie potrzebuje. Low-level advance_turn pozostaje krokiem jednostek bez gospodarki i rozstrzygania stresu; pełną symulację uruchamia tick_update. Zakończenie budowy Mine również odświeża odległości sieci.
`Task::auto_supports/set_auto_supports` delegują ustawienie przez Tasks, DiggingTask i MovingTask; konwersja rodzaju zadania je zachowuje. BoardState::auto_supports jest domyślnym wyborem kopiowanym do nowych segmentów podczas zatwierdzenia trasy. Panel `t` jest dostępny tylko w trybie zaznaczenia przez Space i korzysta z prawego obszaru istniejącego layoutu, bez okna na środku mapy; `a` zmienia opcję, Enter zmienia rodzaj aktualnej trasy przez configure_selected_task, bez zatwierdzania jej do kolejki. Jeśli wybrano Moving, a szkic zawiera kamień, resetuje szkic do początku. Resize uwzględnia task_view. Budowanie ma zakładki Mine/Support. `s` przełącza stress_view i BoardWidget rysuje komórki przez CellWidget::render_stress: koordynaty na obramowaniu, procent, gradient zielony/czerwony i dotychczasowy kolor fokusu, bez znaczników jednostek, tras i innych postępów; zwykły Stone jest wyjątkiem i zachowuje normalny wygląd, bez stresu. Model komórek nie jest mutowany przez renderowanie.
## Audio i pakowanie
Główny wątek wysyła `AudioCmd`; osobny wątek obsługuje głośność, mute, część i soundtrack. Cache wykorzystuje `Lazy`, `RwLock`, `Arc` i bajty OGG. Calm ładuje `calm.ogg`; Buildup, Assault i Outro korzystają obecnie z `test.ogg`. Błędy urządzenia, odczytu i dekodowania mogą zakończyć wątek przez `expect`.
Rodio zapętla źródło przez `Decoder::new_looped`. Wątek sterujący blokuje się na odbiorniku (`for cmd in rx`), zamiast aktywnie sprawdzać komendy i koniec utworu. Zmiana części lub soundtracku dopisuje zapętlone źródło i pomija bieżące przez `skip_one`. Zamknięcie kanału kończy wątek. Test `audio_repeats_after_end_of_file` sprawdza przekroczenie końca rzeczywistego pliku OGG bez urządzenia audio.
Rodio zapętla źródło przez `Decoder::new_looped`. Wątek sterujący blokuje się na odbiorniku (`for cmd in rx`), zamiast aktywnie sprawdzać komendy i koniec utworu. Zmiana części lub soundtracku dopisuje zapętlone źródło i pomija bieżące przez `skip_one`. Zamknięcie kanału kończy wątek. Soundtracki są szukane obok rzeczywistej binarki, z fallbackiem do bieżącego katalogu dla Cargo. Testy audio obejmują zapętlanie OGG i ładowanie zasobów poza repozytorium, bez urządzenia audio.
`flake.nix` dostarcza devShell dla `x86_64-linux`, domyślny pakiet z `default.nix` i pakiet `.#develop` budowany przez Naersk. Obie ścieżki pakowania kopiują soundtracki do `$out/bin` i zmieniają nazwę binarki na `war-in-tunnels`. `default.nix` ma stały `cargoHash`; po zmianie zależności wymaga osobnej weryfikacji. Samo `cargo test` nie sprawdza pakowania.
@@ -116,7 +153,7 @@ To obserwacje statyczne, nie lista napraw wykonanych w tej sesji:
- **Zaznaczenie:** wybrany górnik czeka podczas rysowania trasy; inne jednostki wykonują zadania normalnie.
- **Audio:** usunięto aktywne odpytywanie; pomiar CPU na docelowym urządzeniu i odsłuch pozostają do wykonania. Test dekodera nie weryfikuje urządzenia audio.
- **Granice UI:** małe terminale, resize i trzy poziomy zoomu wymagają interaktywnego sprawdzenia. Indeksowanie planszy zakłada poprawne współrzędne.
- **Pokrycie:** 45 testów CLI, sterowania, renderowania, planszy, wykonywania zadań i dekodowania audio; szczegóły w [TESTING.md](TESTING.md). Workflow Gitea sprawdza też profil release; nie obejmuje pełnej rozgrywki. Clippy zgłasza 50 ostrzeżeń biblioteki, `unused_unit` jest jawnie dozwolony. Nie uruchamiać automatycznych poprawek poza zakresem zadania.
- **Pokrycie:** 107 testów CLI, sterowania i kontekstowej legendy, renderowania, planszy, zasobów, budowania/obsadzania kopalni, rekrutacji i blokowania kolejki bazy, zadań i dekodowania audio; szczegóły w [TESTING.md](TESTING.md). Workflow Gitea sprawdza też profil release; nie obejmuje pełnej rozgrywki. Clippy ma istniejące ostrzeżenia, unused_unit jest jawnie dozwolony. Nie uruchamiać automatycznych poprawek poza zakresem zadania.
## Gdzie zacząć konkretną zmianę
+37
View File
@@ -0,0 +1,37 @@
# Testowe paczki Linux
Workflow `Project tests` w `.gitea/workflows/tests.yml` uruchamia się po pushu i dla pull requestów. Zależności `needs` wymuszają kolejność `check → nix → linux → aur`: job `linux` buduje deb, rpm, AppImage i Flatpak, a po jego sukcesie job `aur` sprawdza przepis Arch. Błąd wcześniejszego joba pomija zależne joby. Workflow nie tworzy tagów, wydań ani wpisów w AUR/Flathub. Paczki są niesygnowanymi artefaktami CI przechowywanymi przez 7 dni. Używa `upload-artifact@v3` dla zgodności z Gitea.
Obsługiwana architektura to x86_64. Są to formaty dystrybucji Linux, nie kompilacje Windows/macOS.
Job AUR instaluje `nodejs` przed checkoutem: runner 0.6.1 uruchamia akcje JavaScript poleceniem `node`, którego obraz `archlinux:base-devel` nie zawiera. Jest to zależność CI (checkout i upload-artifact), nie zależność gry ani PKGBUILD.
| Format | Budowanie i kontrola |
|---|---|
| `.deb` | Rust 1.99.1 w Debian Bookworm, `dpkg-deb`, odczyt metadanych i uruchomienie rozpakowanej binarki z `--version` |
| `.rpm` | Ta sama binarka, `rpmbuild` i `packaging/war-in-tunnels.spec`, automatyczne zależności ELF, ekstrakcja i `--version` |
| AppImage | AppDir z binarką, muzyką i libasound.so.2; appimagetool 1.9.1; ekstrakcja narzędzia i paczki przez 7z, uruchomienie AppRun z `--version` bez FUSE |
| AUR | `packaging/aur/PKGBUILD`, lokalne archiwum bieżącego checkoutu z SHA256, `makepkg` jako zwykły użytkownik w Arch Linux; kompilacja, testy, instalacja paczki i `--version`; eksport .SRCINFO |
| Flatpak | Binarka z Debiana pakowana poleceniami `flatpak build-init`, `build-finish`, `build-export`, `build-bundle`; runtime org.freedesktop.Platform 25.08; instalacja paczki w kontenerze, kontrola soundtracku i `--version` zainstalowanej binarki bez sandboxa |
`packaging/stage.sh` instaluje binarkę i soundtracki w `PREFIX/lib/war-in-tunnels`, launcher w `PREFIX/bin`, licencję, plik desktop z `Terminal=true` i ikonę SVG. Nie zmienia katalogu roboczego, więc opcja `--log` nadal zapisuje log w katalogu wybranym przez użytkownika. Audio najpierw szuka soundtracków obok rzeczywistej binarki, a następnie w bieżącym katalogu dla lokalnego Cargo. Test audio sprawdza działanie poza repozytorium.
W kontenerze Debian z zależnościami workflow można wykonać:
```sh
cargo build --release --locked
bash packaging/build.sh deb
bash packaging/build.sh rpm
bash packaging/build.sh appimage
bash packaging/build.sh flatpak
```
Lokalnie na hoście projektu polecenia Cargo uruchamiaj przez `nix develop -c`; binarka zbudowana przez Nix może zależeć od `/nix/store`, dlatego do paczek dystrybucyjnych używaj kompilacji kontenerowej. Skrypty oczekują bieżącego katalogu repozytorium i zapisują wyniki do `dist/`.
Przepis AUR jest testowy: nie wskazuje publicznego tarballa wydania. Workflow dostarcza lokalny tarball oraz zastępuje `SKIP` rzeczywistym SHA256. Wersja `pkgver` musi odpowiadać Cargo.toml; CI to sprawdza. Artefakt AUR zawiera paczkę, przepis, .SRCINFO i pasujące źródła.
Flatpak jest testem pakowania istniejącej binarki, nie kompilacją wewnątrz SDK Flatpak. Bookworm dostarcza glibc 2.36, runtime 25.08 jest nowszy. Pakowana jest biblioteka ALSA; uprawnienia obejmują PulseAudio i urządzenia dla bezpośredniego ALSA. `build-export --disable-sandbox` wyłącza wyłącznie sandbox walidatora własnej ikony z repozytorium, nie walidację ani sandbox gotowej aplikacji. Tworzenie paczki nie potrzebuje privileged ani zmiany polityki Dockera. Instalacja w Dockerze może zgłaszać bwrap dla hooków integracji pulpitu mimo poprawnego zainstalowania paczki; sprawdzamy kod wyjścia, pliki i binarkę. Pełnego uruchomienia TUI i audio w sandboxie te kontrole nie zastępują. Runtime nie jest zawarty w pliku .flatpak; użytkownik musi go zainstalować z Flathub. CI używa --no-related, aby nie pobierać opcjonalnych dodatków GPU i tłumaczeń.
Paczki Debian, RPM i AppImage mają bazę ABI Bookworm (glibc >= 2.36); nie gwarantują działania na starszych dystrybucjach. AppImage zawiera ALSA, ale korzysta z konfiguracji audio hosta. Testy nie potwierdzają odsłuchu ani otwierania terminala z menu desktop. Appimagetool jest przypięty do wersji; jego domyślny runtime jest pobierany z upstream podczas pakowania. Joby `check` i `linux` współdzielą klucz cache toolchaina Rust, ale mają osobne klucze cache Cargo (`cargo-v1` i `packages-v1`); dla cache wymagany jest działający serwer runnera. Arch i Flatpak pobierają własne zależności bez trwałego cache.
Dokumentacja narzędzi: [AppImage](https://github.com/AppImage/appimagetool/tree/1.9.1), [Flatpak](https://docs.flatpak.org/en/latest/flatpak-command-reference.html), [makepkg](https://man.archlinux.org/man/makepkg.8).
+159 -1
View File
@@ -1,5 +1,163 @@
# Dziennik prac
## 2026-10-26 — koordynaty w mapie stresu i fokus gotowej trasy
CellWidget::render_stress wyświetla koordynaty na obramowaniu, w kolorze mapy stresu lub aktywnego fokusu. Kamień zachowuje zwykły wygląd. BoardWidget nie nadaje flagi marked skupionemu polu gotowej kolejki poza trybem wytyczania zadania: obramowanie jest wtedy żółte, a podczas rysowania pozostaje magenta. Podświetlenie pozostałej trasy i flagi modelu pozostają zachowane.
Regresje sprawdzają koordynaty przy wszystkich zoomach oraz fokus gotowej trasy przed zaznaczeniem jednostki i po Space, zarówno w zwykłym widoku, jak i mapie stresu. cargo fmt, cargo check --all-targets --locked i wszystkie 107 testów przez nix develop przeszły. Nie wykonywano ręcznej kontroli wyglądu w terminalu.
## 2026-10-26 — spokojniejsze kolory kratek
CellWidget obniża kanały RGB zwykłych, niezaznaczonych kratek o około 25%, wyłącznie podczas renderowania. Kolory jednostek, postępu, fokusu, tras, paneli i mapy stresu pozostają czytelne w dotychczasowej palecie; model struktur nie zmienia kolorów. Zastosowano cargo fmt; cargo check --all-targets --locked i wszystkie 106 testów przez nix develop przeszły. Nie oceniano wyglądu ręcznie w terminalu.
## 2026-10-26 — jawne kolory RGB zamiast palety terminala
Zastąpiono wszystkie nazwane kolory Ratatui w src/ i tests/ przez Color::Rgb, również skróty Stylize takie jak .gray(), .cyan() i .yellow(). Przyjęto standardowe wartości ANSI/xterm: podstawowe kanały 128, jasne 255, Gray (192, 192, 192), DarkGray (128, 128, 128). Kolor rudy Indexed(217) zastąpiono jego odpowiednikiem xterm (255, 175, 175). Istniejący gradient RGB stresu pozostaje bez zmian. Jawne RGB uniezależnia te kolory od palety motywu terminala, więc wygląd może różnić się od wcześniejszych kolorów nazwanych. Zmieniono wyłącznie zapis kolorów i potrzebne importy, bez dodawania warstwy motywów.
Zastosowano cargo fmt; cargo check --all-targets --locked i cargo test --all-targets --locked przez nix develop przeszły. git diff --check przeszedł; wyszukiwanie potwierdziło brak Color:: innych niż Rgb i nazwanych skrótów kolorowania w źródłach oraz testach. Nie sprawdzano wyglądu w interaktywnym terminalu. Zachowano zastaną zmianę użytkownika w flake.lock.
## 2026-10-24 — wspólny workflow Gitea i kolejność jobów
Odnotowano zmianę użytkownika: `.gitea/workflows/tests.yml` (`Project tests`) zastępuje osobne workflowy `rust.yml`, `nix.yml` i `packages.yml`. Po pushu i dla pull requestów wykonuje joby w kolejności `check → nix → linux → aur`, wymuszonej przez `needs`. Błąd wcześniejszego joba pomija zależne joby. `check` wykonuje kontrole Rust, `nix` buduje `.#default`, `linux` pakuje deb/rpm/AppImage/Flatpak, a `aur` buduje i instaluje paczkę Arch. Artefakty pozostają testowe, bez publikowania wydań i tagów, z retencją 7 dni.
Zaktualizowano CODE_MAP, TESTING, PACKAGING i AGENTS, w tym opis współdzielonego cache toolchaina oraz osobnych cache Cargo. Nie zmieniano workflowu ani flake.nix. Dokumentację porównano z aktualnym yml; `git diff --check` i `nix develop -c cargo check --all-targets --locked` przeszły. Nie uruchamiano jobów Gitea.
## 2026-10-24 — czas podpór, kamień bez stresu i wybór zadania po Space
Budowa i usuwanie podpór trwają po 91 ticków pracy (3003 ms). Tunnel przechowuje SupportWork z właścicielem, rodzajem pracy i postępem; roof_support zmienia się dopiero przy ukończeniu. Budowa pobiera koszt raz przy rozpoczęciu, a podpora w trakcie rozbiórki nadal stabilizuje sieć. Kaskada następuje dopiero po zakończeniu usuwania. Postęp jest na kratce, w zakładce Support i pod Tab. Bez budowniczego timer pauzuje; jeden górnik nie wykonuje dwóch podpór naraz, a jego ruch i kopanie są wstrzymane na czas pracy.
Automatyczne podpory są teraz przygotowywane przed ruchem i kopaniem: prospective_tunnel_stress przewiduje stres przyszłego wykopu bez zmiany terenu. Przy ryzyku 100% automat rozpoczyna pracę na obecnym polu 80%, zatrzymuje górnika i dopiero po jej zakończeniu pozwala kopać dalej. Nie buduje zdalnie, nie nalicza opłaty ponownie i nadal nie marnuje podpory na końcu trasy. Brak zasobów zachowuje wcześniejsze ryzyko śmiertelnego zawalenia.
Stone w mapie stresu korzysta ze zwykłego renderowania bez procentu stresu i bez podświetlenia kolejki; fokus zachowuje dotychczasowy wygląd. Widoczny i dostępny skrót t został usunięty z samego fokusu na Minerze, a dodany do trybu zaznaczenia przez Space. Enter w bocznym panelu tylko wybiera zadanie i wraca do rysowania, nie zatwierdza kolejki. Jeśli zmiana na Moving unieważnia szkic przez kamień, configure_selected_task resetuje szkic do początku trasy, zachowując zaznaczenie jednostki.
Dodano 4 regresje i dostosowano testy poprzedniej natychmiastowej budowy: pełny czas i zachowanie podpory do końca rozbiórki, zatrzymanie kolejki budowniczego, pauza bez budowniczego i wznowienie, jedna praca na górnika, normalny kamień przy wszystkich zoomach i kombinacjach fokusu oraz t dopiero po Space i reset nieprawidłowego szkicu Moving. Wszystkie 106 testów przeszły w debug i release; cargo check --all-targets --locked i cargo clippy --all-targets --locked przez nix develop przeszły. Clippy nadal ma 47 wcześniejszych ostrzeżeń. Zastosowano cargo fmt; git diff --check przeszedł. Uzupełniono README, CODE_MAP, TESTING i AGENTS. Nie wykonywano ręcznej rozgrywki ani budowy paczek.
## 2026-10-24 — stres tuneli, podpory, zawalenia i wybór zadania
Zachowano zmianę użytkownika: domyślny stres Tunnel to 20%. Podpora jest ulepszeniem roof_support, oznaczanym przez Structure::get_level jako T, z ceną 10 Wood + 2 Metal. Dodano zakładkę Support w budowaniu: Enter natychmiast stawia podporę na tunelu przy własnym Minerze na polu lub obok; Delete usuwa ją bez zwrotu kosztu i natychmiast rozstrzyga kaskadę. Walidacja terenu, budowniczego i zasobów jest wspólna dla TUI i automatu, bez zdalnego budowania ani powtórnych opłat.
board_stress.rs przelicza najkrótsze połączenia w czterech kierunkach: bazy startują z 0, podpory z 20, każdy kolejny krok dodaje 20 do limitu 100. Gotowe Mine przepuszczają połączenie; odłączone tunele mają stres 100. Osiągnięcie 100 zamienia tunel w nieprzechodni CollapsedTunnel (#, 750 durability), usuwa znajdującą się tam jednostkę i ponawia przeliczenie do stabilizacji. Wykonywane i oczekujące zadania zasypanych jednostek giną razem z nimi; rysowana trasa ginącej jednostki również jest czyszczona. Gruz można wykopać ponownie; obrażenia bazują na 1000 HP kamienia, więc usuwa się go szybciej niż zwykły kamień. Tunel ma nadal 500 HP.
Tick korzysta z advance_turn_with_events i rekordów Excavation. Po wykopach, przed rozstrzygnięciem zawaleń i rekrutacją, automat opłaca podporę na poprzednim polu 80% wyłącznie wtedy, gdy kolejny wykop bez niej osiągnąłby 100%. Pierwsza podpora na prostej trasie jest cztery pola od bazy, kolejne co trzy; nie ma zbędnej podpory na końcu trasy. Ustawienie auto_supports jest domyślnie włączone i kopiowane do nowych zadań; konwersje Digging/Moving je zachowują. Brak zasobów nie zatrzymuje kopania — może zakończyć się śmiercią górnika. Low-level advance_turn pozostaje samym krokiem jednostek; pełne rozliczenie stresu i gospodarki jest w SkirmishState::tick_update.
Dodano boczny panel t, który zajmuje prawy obszar layoutu i nie przykrywa środka mapy. Strzałki wybierają Digging/Moving, Enter zaczyna rysowanie, t zamyka panel; Esc jest ignorowany. a przełącza automatyczne podpory dla nowych zadań Digging, także podczas rysowania. Jawnie wybrany Moving ogranicza trasę do pól przechodnich i nie zaczyna kopać, gdy droga później się zawali. Spacja pozostaje skrótem domyślnej trasy z kopaniem. Resize uwzględnia panel zadania. Skrót s przełącza widok stresu: same procenty, gradient zielony/czerwony, zachowany kolor fokusu, bez jednostek, oznaczeń tras i innych postępów. W małym zoomie 100 nie ma sufiksu %. Renderer nie zmienia modelu.
Dodano 13 testów w tests/tunnel_stress.rs: odległości, cztery kierunki podpór, alternatywne podparcie, kaskady i śmierć obu stron, walidacja i koszty, minimalne rozmieszczenie auto-podpór, brak środków/wyłączenie automatu, konwersje opcji, gruz i ponowne kopanie, zakładka Support, widok stresu we wszystkich zoomach, fokus, geometria bocznego panelu oraz Moving bez kopania. Starszy test mieszanej trasy ma teraz podporę w odizolowanym końcowym tunelu, aby nadal sprawdzał podział Moving/Digging bez niezamierzonego zawalenia testowej planszy.
Wszystkie 102 testy przeszły w debug i release; cargo check --all-targets --locked i cargo clippy --all-targets --locked przez nix develop przeszły. Pozostało 47 dotychczasowych ostrzeżeń Clippy. Zastosowano cargo fmt, git diff --check przeszedł. Uzupełniono README, CODE_MAP, TESTING i AGENTS. Nie wykonywano ręcznej rozgrywki, odsłuchu ani przebudowy paczek. Ceny są wstępne; szansa przeżycia i ratowanie jednostek nie są częścią tej implementacji.
## 2026-10-23 — zakładki budowania i rekrutacji
Dodano paski Tabs z wyróżnieniem LightMagenta oraz strzałki Left/Right przełączające opcje w aktywnym oknie bez ruchu mapy. SkirmishState przechowuje osobne indeksy i listy opcji (obecnie wyłącznie Mine oraz Miner); wybór zawija na końcach listy. Przy jednej pozycji strzałki zachowują ten sam wybór. Nie dodano budynków ani jednostek. Enter, Delete i zamykanie b/r zachowują dotychczasowe działanie; Esc pozostaje ignorowany. Dodanie przyszłych typów wymaga powiązania ich szczegółów i akcji z wyborem, zgodnie z CODE_MAP.
Dodano test strzałek w obu oknach, niezmienności planszy i kolejek oraz zamykania właściwym skrótem. Dostosowano test kolorów zaznaczonej zakładki i legendy. Wszystkie 89 testów oraz cargo check --all-targets --locked przez nix develop przeszły. Zastosowano cargo fmt; git diff --check przeszedł. Zaktualizowano README, CODE_MAP i TESTING. Nie sprawdzano ręcznie terminala.
## 2026-10-23 — procent rekrutacji na kratce bazy
CellWidget wyświetla cyjanowy procent rekrutacji na środku Base z niepustą kolejką. Pokazuje także 0% i gotowe 100% czekające na wolne miejsce; znika po zakończeniu kolejki. W najmniejszym zoomie 100 jest bez sufiksu %, aby zmieścić się w trzech znakach wnętrza. Wskaźnik ma pierwszeństwo przed procentem ruchu jednostki opuszczającej bazę; w pozostałych zoomach zachowano znacznik jednostki. Brak zmian symulacji i czasu rekrutacji.
Dodano regresję wszystkich trzech zoomów: brak procentu bez kolejki, 0%, 50%, cyjanowy kolor, znacznik Minera, oczekiwanie przy 100% oraz usunięcie wskaźnika po spawnie. Wszystkie 88 testów i cargo check --all-targets --locked przez nix develop przeszły. Zastosowano cargo fmt, git diff --check przeszedł; zaktualizowano README i CODE_MAP. Nie sprawdzano ręcznie terminala.
## 2026-10-23 — podświetlenie ścieżek zadań
BoardWidget zbiera pozostałe pola wszystkich zadań własnych jednostek do HashSet i podświetla widoczne kopie komórek istniejącymi kolorami LightMagenta oraz Magenta pod kursorem. Obsługuje ruch i kopanie, także dalsze pozycje kolejki. Flagi marked modelu i wytyczanie nowych tras nie są zmieniane. Pola znikają z podświetlenia po wykonaniu lub odwołaniu zadań; przeciwnik nie ujawnia tras. Wykorzystano istniejące renderowanie CellWidget bez nowego stanu gry.
Dodano test renderowania w tests/task_cancellation.rs: kolor trasy, skracanie po przejściu, znikanie po anulowaniu i zakończeniu, zachowanie flag modelu oraz brak kolorowania tras przeciwnika. Wszystkie 87 testów i cargo check --all-targets --locked przez nix develop przeszły. Zastosowano cargo fmt; git diff --check przeszedł. Uzupełniono README, CODE_MAP i TESTING. Nie sprawdzano ręcznie terminala.
## 2026-10-23 — odwoływanie zadań jednostek
Pod Tab, z fokusem na własnej jednostce z zadaniami, Backspace odwołuje ostatnie zadanie, a Delete całą kolejkę. Jeśli zostało jedno zadanie, Backspace odwołuje także wykonywane. Legenda dopasowuje opisy do kontekstu; podczas rysowania trasy klawisze zachowują cofanie/anulowanie trasy, a Delete w budowaniu nadal anuluje budowę. BoardState::cancel_focused_tasks waliduje właściciela, kolejkę i brak rysowania. Nie dodano usuwania środkowej pozycji, ponieważ dalsze zadania bazują na końcu poprzednich odcinków trasy.
Anulowanie usuwa timer zadania, zachowując położenie jednostki, wykopane tunele i uszkodzenia kamienia. Dodano 3 testy integracyjne obejmujące opóźnione i aktywne zadania, przerwany ruch, ochronę przeciwnika i trybu trasy oraz klawisze pod Tab. Wszystkie 86 testów oraz cargo check --all-targets --locked przez nix develop przeszły. Zastosowano cargo fmt i zaktualizowano dokumentację. Nie sprawdzano ręcznej rozgrywki.
## 2026-10-23 — zamykanie paneli wyłącznie skrótem otwierającym
Odświeżono kontekst po zmianach z drugiego komputera: aktualne skróty to b (budowanie), r (rekrutacja), + i - (głośność), a rustfmt używa 4 spacji. Zachowano te ustawienia. Usunięto Esc z akcji i legendy obu paneli. Obsługa modalna jawnie ignoruje Esc, aby nie uruchamiał również globalnego powrotu do menu. b zamyka budowanie, r zamyka rekrutację; kolejki i budowy pozostają zachowane. Esc poza tymi panelami zachowuje dotychczasowe działanie.
Dostosowano testy kontekstu, rekrutacji i budowania: sprawdzają ignorowanie Esc bez zmiany widoku, brak Esc w legendzie oraz zamykanie odpowiednim skrótem. Zaktualizowano README, CODE_MAP, TESTING i instrukcje agenta. Wszystkie 83 testy oraz cargo check --all-targets --locked przez nix develop przeszły; zastosowano cargo fmt. Nie sprawdzano ręcznej rozgrywki w terminalu.
## 2026-10-21 — spójne skróty głośności
Przyciszanie muzyki przeniesiono z `n` na `-`, zgodnie z parą `+`/`-`. Zaktualizowano wspólną tabelę skrótów (także legendę), istniejący test komend audio i README. Kompilacja wszystkich targetów, testy `tests/app.rs` i kontrola formatowania przez Nix przeszły. Nie sprawdzano odsłuchu.
## 2026-10-21 — skróty b/r i kolory okien budowy oraz rekrutacji
Zastąpiono Ctrl+Space przez `b`, a Alt+Space przez `r`; usunięto także obsługę NUL dla budowania. Oba klawisze otwierają i zamykają właściwy panel. Ponieważ `b` sterowało głośnością, zwiększanie głośności przeniesiono na `+`. Legenda korzysta ze wspólnej tabeli skrótów.
Okna używają kolorowanych linii Ratatui: żółte nazwy i koszty, cyjanowe postępy i produkcja, zielone dostępne działania, czerwone błędy oraz żółte komunikaty oczekiwania. Zachowano tekstowe opisy statusów. Zaktualizowano testy i bieżącą dokumentację; dodano regresję kolorów obu okien i sprawdzenie odrzucania starych skrótów.
Weryfikacja: kompilacja wszystkich targetów i 83 testy debug przez `nix develop -c` przeszły. Nie sprawdzano interaktywnego terminala ani odsłuchu. Zastanej zmiany `flake.lock` nie modyfikowano.
## 2026-10-19 — alternatywny skrót rekrutacji
Zgłoszono brak reakcji na Alt+Space. Testy przekazywanego zdarzenia Alt+Space działają; nie potwierdzono, jakie zdarzenie wysyła terminal użytkownika ani czy skrót przechwytuje menedżer okien. Dodano alternatywne `r` do tej samej akcji Recruitment i pokazano oba skróty w legendzie. Obowiązują te same ograniczenia własnej bazy i trybu; `r` także zamyka panel bez kasowania kolejki. README opisuje alternatywę.
Dodano regresję otwarcia, opłacenia rekrutacji i zamknięcia przez `r`, oraz sprawdzenie blokady poza bazą i podczas rysowania trasy. Przeszły wszystkie 82 testy, cargo check --all-targets --locked i cargo fmt --all -- --check przez nix develop oraz git diff --check. Nie sprawdzano skrótów w środowisku graficznym użytkownika.
## 2026-10-19 — rekrutacja Miner w Base
Dodano koszt MinerUnit::RECRUIT_COST: roboczo 10 Wood i 15 Metal za każdą pozycję. SkirmishState::can_recruit_miner/recruit_miner sprawdza własną Base, współrzędne, miejsce w liczniku kolejki i środki, po czym opłaca zamówienie. Niedobór dowolnego zasobu lub niewłaściwy cel nie pobiera opłaty ani nie zmienia kolejki. Każda baza ma niezależny licznik queued_miners, recruitment_ticks i flagę waiting_for_departure; dla identycznych Minerów nie jest potrzebna lista obiektów ani utrata Copy struktur.
Rekrutacja trwa 304 ticki (10032 ms, około 10 sekund). Zgodnie z odpowiedzią użytkownika pierwsza pozycja może dojść do 100% na zajętej bazie i czeka tam na wolną kratkę. Nie postępują wtedy kolejne pozycje. Po pojawieniu się górnika flaga waiting_for_departure zatrzymuje następne odliczanie na 0% do jego wyjścia z bazy. BoardState::advance_recruitment działa po ruchu istniejących jednostek, więc nie nadpisuje zajętego pola i może wykorzystać właśnie zwolnioną bazę. Nowy Miner ma właściciela bazy, jej współrzędne, pełne HP, uprawnienia i pustą kolejkę zadań. Bazy przeciwnika używają jego zasobów; brak automatycznej rekrutacji AI.
Alt+Space otwiera panel wyłącznie na własnej bazie, poza rysowaniem trasy i innym modalem. Enter dopisuje Minera, jeśli są środki. Panel pokazuje koszt, czas, liczbę zamówionych (włącznie z aktywnym), postęp i status Recruiting/Ready/Paused. Esc lub Alt+Space zamyka widok bez kasowania kolejki. Ruch mapy i otwarcie budowania są w tym trybie blokowane, globalna muzyka i wyjście pozostają dostępne. Licznik i postęp są również pod Tab.
Dodano tests/recruitment.rs z 8 testami. Cały zestaw 81 testów przeszedł w debug i release, w tym własność i opłaty, brak przedwczesnego pojawienia się jednostki, czekanie przy 100%, blokowanie kolejnych pozycji i wznowienie po wyjściu, brak nadpisania także przy obcej jednostce w bazie, niezależne bazy, zadania nowego górnika oraz Alt+Space/Enter/Esc i renderowanie panelu. Końcowe cargo fmt --all -- --check, cargo check --all-targets --locked i cargo clippy --all-targets --locked przez nix develop przeszły; nadal 47 istniejących ostrzeżeń biblioteki. git diff --check przeszedł. Uzupełniono README, CODE_MAP, TESTING i AGENTS; zachowano wcześniejsze staged i unstaged zmiany użytkownika.
Nie wykonywano ręcznej pełnej rozgrywki ani ponownego budowania paczek. Cena jest wstępna do balansowania. Anulowanie pozycji rekrutacji, inne typy jednostek i pełna mechanika limitu populacji nie były częścią tej zmiany.
## 2026-10-19 — czas, koszt i anulowanie budowy Mine
Potwierdzono, że wyjątek Mine dotyczy tylko połączenia z tunelem/bazą: obecność własnego sąsiedniego budowniczego nadal jest obowiązkowa, więc ruda na środku mapy bez jednostki nie pozwala rozpocząć budowy. Wydzielono wspólne BoardState::has_adjacent_builder i dodano regresję rzeczywistej zdalnej rudy bez pobierania opłaty.
Mine::COST wynosi roboczo 20 Wood i 10 Metal. SkirmishState::can_start_mine_construction/start_mine_construction sprawdza dostępne środki i teren, po czym opłaca rozpoczęcie. Resources::spend sprawdza oba zasoby przed odjęciem; nieudana lub powtórzona próba nie pobiera środków. Nowa struktura MineConstruction przechowuje oryginalną rudę i postęp. Budowa trwa 304 ticki (10032 ms, około 10 sekund) aktywnej obecności własnego budowniczego na sąsiedniej kratce. Odejście pauzuje, powrót wznawia; niedokończona kopalnia nie wydobywa i nie przyjmuje jednostek. Ukończenie zmienia ją w gotową Mine z zachowaną rezerwą.
Panel Ctrl+Space pokazuje koszt, czas, procent, pracę lub brak budowniczego oraz Delete/Cancel construction. Po rozpoczęciu panel pozostaje otwarty; Esc lub Ctrl+Space tylko go zamyka. Procent jest także na kratce i pod Tab. SkirmishState::cancel_mine_construction przywraca oryginalne neutralne Ore i zwraca pełen koszt wyłącznie właścicielowi aktywnego placu. Nie można zwrócić kosztu dwukrotnie, anulować cudzego placu ani sprzedać ukończonej kopalni.
Dodano 7 regresji i dostosowano wcześniejsze testy budowania: construction.rs ma 16 testów, cały projekt 73. Wszystkie przeszły w debug i release. Kontrole cargo fmt --all -- --check, cargo check --all-targets --locked i cargo clippy --all-targets --locked przez nix develop zakończyły się sukcesem; nadal 47 istniejących ostrzeżeń biblioteki. git diff --check przeszedł. Zaktualizowano README, CODE_MAP, TESTING i instrukcje agenta, zachowując poprzednie zmiany w drzewie roboczym.
Nie wykonywano ręcznej pełnej rozgrywki ani ponownego budowania paczek. Koszt i czas są parametrami wstępnymi; anulowanie zwraca pełny koszt niezależnie od postępu, a pasywne dostawy drewna działają osobno.
## 2026-10-19 — budowanie Mine i bonus od obsadzenia górnikiem
Dodano Unit::get_can_build (domyślnie false), włączone dla Miner i delegowane przez Units/Option. Wspólne BoardState::can_build_at sprawdza sąsiedniego własnego budowniczego w czterech kierunkach, granice, brak jednostki na celu i teren: zwykłe budynki wymagają kamienia przy tunelu lub własnej bazie, a Mine wymaga neutralnego, niewyczerpanego Ore bez wymogu połączenia. BoardState::build_mine_at ponownie waliduje i zachowuje zawartość złoża. UI Ctrl+Space udostępnia Enter/Build Mine tylko dla poprawnego celu; udana budowa zamyka widok. Na razie budowa jest natychmiastowa i bez kosztów, bo kosztów ani czasu nie ustalono. Inne typy budynków nie są jeszcze dodane.
Structures::can_enter współdzieli regułę wejścia między planowaniem i wykonywaniem trasy. Mine akceptuje własnego Minera; ruch do i z kopalni jest Moving i trwa około 3 sekund. Jednostka pozostaje na kratce, zajmując jej standardowy pojedynczy slot, bez kopiowania pracownika do osobnego stanu budynku. Mine::production_per_cycle/produce_with_miner zwiększa wydobycie z 1 do 2 Metal na cykl przy obecności własnego górnika; po wyjściu bonus znika. Wydobycie nadal ogranicza rezerwa Ore, a metal trafia do właściciela. Panel Tab pokazuje tempo i dopisek Miner przy aktywnym bonusie, a opis jednostki pokazuje uprawnienie budowania.
Dodano tests/construction.rs (9 testów) i dostosowano test nieprawidłowego celu w context_keybindings.rs. Razem 66 testów przeszło w debug i release. Testy obejmują uprawnienia, sąsiedztwo przez krawędź, wrogą jednostkę, wyjątek Ore, połączenie dla kamienia, granice i zajęte/wyczerpane złoża, budowę Enter w TUI, Moving do/z kopalni, wzrost i zanik bonusu oraz ostatnią jednostkę rudy. Końcowe cargo check --all-targets --locked, cargo fmt --all -- --check i cargo clippy --all-targets --locked przez nix develop przeszły; Clippy nadal ma 47 istniejących ostrzeżeń biblioteki. git diff --check przeszedł. Zaktualizowano README, CODE_MAP, TESTING i AGENTS, zachowując wcześniejsze zmiany zasobów w drzewie roboczym.
Nie wykonywano ręcznej pełnej rozgrywki ani ponownego budowania paczek dystrybucyjnych. Koszty budowy, czas budowy i balans bonusu pozostają do późniejszego ustalenia.
## 2026-10-19 — rzeczywiste zasoby, model Mine i kontekstowa legenda
Zmieniono Iron na Metal w CLI, ustawieniach i nagłówku; --starting-metal zachowuje -i oraz alias --starting-iron. SkirmishState ma osobne magazyny Resources (u32) dla gracza i przeciwnika, inicjalizowane z CLI. TUI czyta aktualny stan magazynu, nie wartości startowe. Gracz dostaje 5 Wood co 152 ticki (około 5 sekund); dodawanie chroni przed przepełnieniem. Metal nie przyrasta bez kopalni.
Dodano Structures::Mine, które przechowuje właściciela i oryginalne Ore wraz z pozostałą zawartością. Structures::build_mine dopuszcza wyłącznie neutralną rudę; tick co 31 wywołań wydobywa do 1 Metal i kredytuje rzeczywistego właściciela. Wyczerpane złoże nie produkuje więcej. Side panel pokazuje zawartość Ore kopalni. Oba początkowe złoża pozostają na mapie, lecz są neutralne. Kopalnie nie są rozmieszczane na planszy. Zgodnie z doprecyzowaniem użytkownika widok Ctrl+Space jest wyłącznie podglądem, bez stawiania budynków, kosztów ani budowania przez Enter.
Wspólne available_skirmish_actions określa konteksty mapy, rysowania trasy i budowania dla wejścia i legendy. Własnego górnika można zaznaczyć spacją; na trasie widoczne są ruch, potwierdzenie i cofanie/anulowanie. Esc anuluje trasę lub zamyka podgląd budowania, w normalnym trybie wraca do menu. Ctrl+Space obsługuje też NUL starszych terminali. Wyjście i muzyka pozostają globalne. Legenda dobiera opisy i szerokości kolumn do tekstu; wysokość stopki pozostaje stała, bez zmiany geometrii planszy.
Dodano tests/resources.rs (7 testów) i tests/context_keybindings.rs (4 testy), poprawiono test neutralności złóż i CLI; razem 57 testów. Debug i release przeszły w całości, w tym renderowanie bieżących zasobów, wyczerpanie złoża, przypisanie wydobycia właścicielowi, brak automatycznej budowy i czytelność legendy przy 120 kolumnach. Końcowe cargo check --all-targets --locked, cargo fmt --all -- --check i cargo clippy --all-targets --locked przez nix develop zakończyły się sukcesem; Clippy zgłasza 47 istniejących ostrzeżeń biblioteki. git diff --check przeszedł. Zaktualizowano README, CODE_MAP, TESTING i instrukcje agenta.
Nie dodano faktycznego stawiania kopalń ani wymiany z powierzchnią. Wartości przychodów są wstępnymi stałymi do balansowania. Nie wykonywano ręcznej pełnej rozgrywki ani budowania paczek dystrybucyjnych; brak nowych zależności Cargo.
## 2026-10-19 — Node.js wymagany przez akcje joba AUR
Log Gitea wykazał brak `node` podczas uruchamiania checkout@v4 i jego kroku post. Arch base-devel nie zawiera Node.js, a runner 0.6.1 wywołuje akcje JavaScript przez `node` w kontenerze. Dodano `nodejs` do instalacji zależności przed checkoutem oraz kontrolę `node --version`. Node.js jest zależnością CI, nie paczki gry. Poprzednia próba lokalna weryfikowała makepkg, lecz nie uruchamiała akcji JavaScript; uzupełniono ten brak.
Weryfikacja w jednorazowym Arch base-devel: zainstalowano Node.js, `node --version` zwróciło v26.12.0; sprawdzono `node --check` rzeczywistych entrypointów checkout@v4 i upload-artifact@v3. Uruchomienie checkoutu doszło do oczekiwanego błędu brakującego inputu token (bez poświadczeń i klonowania repozytorium), potwierdzając poprawny start akcji. `nix develop -c cargo check --all-targets --locked` i `git diff --check` przeszły. Nie powtarzano budowania gry ani nie uruchamiano pełnego workflow na serwerze; transport artefaktów pozostaje do potwierdzenia na Gitea.
## 2026-10-19 — testowe paczki Linux poza Nix
Dodano `.gitea/workflows/packages.yml` dla push i pull request: job Debian kompiluje Rust 1.99.1 i tworzy deb, rpm, AppImage oraz Flatpak; job Arch kompiluje i testuje lokalny przepis AUR przez makepkg jako użytkownik builder. Workflow zapisuje artefakty przez 7 dni, nie tworzy wydań, tagów ani publikacji AUR/Flathub. Zależności i toolchain Cargo mają cache; Flatpak i Arch nie mają jeszcze trwałego cache. Obsługiwana architektura: x86_64.
Przepisy i wspólna instalacja są w `packaging/`, a zakres i ograniczenia w `docs/PACKAGING.md`. Pakowane są muzyka, licencja, launcher zachowujący katalog roboczy, plik desktop Terminal=true i ikona SVG. Flatpak pakuje binarkę z Debiana do runtime 25.08, bez kompilacji w SDK i bez zagnieżdżonego sandboxa. Przepis AUR dostaje archiwum checkoutu z rzeczywistym SHA256; nie jest jeszcze przepisem publikowanego wydania. Dodano `dist/` do .gitignore.
Audio szuka zasobów obok rzeczywistej binarki z fallbackiem do katalogu roboczego, aby instalowane paczki działały poza repozytorium bez zmiany miejsca zapisu logów. Dodano regresję uruchamianą w osobnym procesie z przeniesioną binarką testu i soundtrackami. Łącznie 46 testów integracyjnych.
Weryfikacja: cargo check --all-targets --locked, cargo test --all-targets --locked (46 testów), cargo fmt --all -- --check i cargo clippy --all-targets --locked przez nix develop przeszły; Clippy nadal ma 50 ostrzeżeń biblioteki. Przeszły git diff --check i bash -n. W jednorazowym Debianie rzeczywiście skompilowano grę, utworzono deb i rpm, rozpakowano i sprawdzono --version; zbudowano też AppImage, rozpakowano je i uruchomiono AppRun --version. Narzędzie i wynik AppImage rozpakowywane są przez 7z, bez FUSE i kolizji z binfmt hosta. Osobny Arch Linux wykonał makepkg, testy release i doctest, instalację paczki oraz --version. Flatpak został wyeksportowany, zapisany do pliku i zainstalowany w kontenerze; zweryfikowano soundtrack i --version zainstalowanej binarki. Wyłączono sandbox wyłącznie walidatora własnej ikony przy build-export; instalacja może zgłaszać ostrzeżenia bwrap dla hooków integracji pulpitu w standardowym Dockerze. Nie wyłączono sandboxa gotowej aplikacji. Instalacja runtime i aplikacji używa --no-related, aby nie pobierać opcjonalnych sterowników GPU ani tłumaczeń dla testu TUI.
Nie sprawdzono pełnej rozgrywki, odsłuchu, uruchamiania z menu pulpitu ani działania na starszych dystrybucjach; pełny workflow i transport artefaktów wymagają przebiegu na Gitea. Testowe kontenery miały repozytorium tylko do odczytu; nie instalowano paczek na hoście. Usunięto tymczasowy kontener war-packages-check i obraz war-in-tunnels-packaging-test:local.
## 2026-10-19 — oddzielny workflow budowania default.nix
Dodano `.gitea/workflows/nix.yml` dla push i pull request. Workflow w `node:22-bookworm` instaluje sudo i Nix (KVM wyłączone), następnie buduje `.#default` z przypiętym `flake.lock`, bez aktualizacji lockfile i bez devShell. Obejmuje testy buildRustPackage oraz versionCheckHook. Nie zmieniono workflow Rust ani konfiguracji runnera; nie dodano trwałego cache Nix.
@@ -42,7 +200,7 @@ Zaktualizowano instrukcje agentów, mapę i opis testów. Nie zmieniano mechanik
## 2026-10-19 — formatowanie Rust z wcięciami po 2 spacje
Dodano `rustfmt.toml` z uzgodnionymi opcjami: edycja i styl 2024, `max_width = 100`, `hard_tabs = false`, `tab_spaces = 2`, zakończenia Unix, sortowanie importów i modułów oraz domyślne heurystyki krótkich konstrukcji. Zastosowano `nix develop -c cargo fmt --all` do źródeł i testów (56 zmienionych plików Rust). Zaktualizowano instrukcje agentów i mapę konfiguracji projektu.
Dodano `rustfmt.toml` z uzgodnionymi opcjami: edycja i styl 2024, `max_width = 100`, `hard_tabs = false`, `tab_spaces = 4`, zakończenia Unix, sortowanie importów i modułów oraz domyślne heurystyki krótkich konstrukcji. Zastosowano `nix develop -c cargo fmt --all` do źródeł i testów (56 zmienionych plików Rust). Zaktualizowano instrukcje agentów i mapę konfiguracji projektu.
Weryfikacja przez `nix develop -c`: `cargo fmt --all -- --check`, `cargo check --all-targets` i `cargo test --all-targets` zakończone sukcesem; 45 testów zaliczonych. Nie wprowadzano zmian mechanik ani zależności. Nie powtarzano Clippy ani ręcznej sesji TUI przy zmianie formatowania.
+20 -8
View File
@@ -10,16 +10,22 @@ Testy korzystają z wbudowanego mechanizmu Rust, Clap i `ratatui::backend::TestB
## Zakres
`tests/tunnel_stress.rs` obejmuje 17 testów: odległość od bazy, propagacja podpór w czterech kierunkach, alternatywne podpory, śmiertelna kaskada i gruz, koszt oraz blokada zdalnego budowania, auto-podpory w ostatnich bezpiecznych miejscach, brak środków, zachowanie opcji przy konwersji zadań, szybsze ponowne kopanie, zakładka Support, widok stresu i boczny panel zadań, wybór Moving bez kopania oraz położenie panelu poza środkiem planszy. Regresje obejmują także pełny czas budowy i rozbiórki, zatrzymanie pracującego górnika, pauzę bez budowniczego, brak dwóch równoległych prac jednego górnika, zwykły kamień w widoku stresu oraz t dostępne dopiero po Space.
| Plik | Liczba | Zachowania |
|---|---:|---|
| `tests/cli.rs` | 5 | Domyślne ustawienia, granice wszystkich opcji liczbowych, XP (także NaN i infinity), błędne enumy, flagi audio, uruchomienie binarki z help/version i błędnym argumentem |
| `tests/app.rs` | 7 | Nawigacja menu, wybór ekranów, wyjście, ignorowanie zwolnienia klawisza, komendy audio i zamknięty kanał, panel, zoom, ruch, renderowanie wszystkich widoków bez mutacji stanu, wejście przed inicjalizacją |
| `tests/board.rs` | 7 | Rozmieszczenie baz/złóż/jednostek, granice kursora i kamery, zatwierdzanie/anulowanie ścieżki, cofanie z powtórzonym polem, duży viewport, resize i zoom |
| `tests/tasks.rs` | 7 | Przyrost i limit kopania, reset etapu, pusta ścieżka, kolejność zadań, timer MovingTask, całkowity postęp, zachowanie liczby etapów przy konwersji, formatowanie postępu aktywnych i oczekujących zadań |
| `tests/audio.rs` | 1 | Zapętlanie rzeczywistego pliku OGG po jego końcu |
| `tests/audio.rs` | 2 | Zapętlanie rzeczywistego pliku OGG po jego końcu i ładowanie soundtracków obok binarki poza katalogiem repozytorium |
| `tests/resources.rs` | 7 | Stan z CLI, alias starting-iron, pasywne Wood, neutralne złoża, brak kopalń na starcie, budowa wyłącznie na Ore, wyczerpanie złoża i przychód właściciela, ochrona przepełnień i bieżące zasoby w TUI |
| `tests/context_keybindings.rs` | 6 | Kontekst mapy i trasy, anulowanie przez Esc, blokada budowania na nieprawidłowym polu, b i odrzucenie starych skrótów, globalne wyjście i czytelność legendy przy 120 kolumnach i kolory tekstu obu okien |
| `tests/construction.rs` | 16 | Uprawnienia budowniczego i delegacja, sąsiedztwo i brak zdalnej budowy, właściciel, reguła kamienia i wyjątek Ore, ochrona zajętych/wyczerpanych/wrogich złóż, atomowa opłata, 10 sekund budowy, pauza i wznowienie, brak przedwczesnego wydobycia/wejścia, procenty i anulowanie Delete w TUI, jednokrotny zwrot i zachowanie Ore, ruch do/z Mine i bonus wydobycia, ochrona rezerwy |
| `tests/recruitment.rs` | 10 | Wyłącznie własna Base, atomowy koszt każdej pozycji i brak obciążenia przy błędzie, 10 sekund rekrutacji, oczekiwanie przy 100%, brak nadpisania jednostki, blokada kolejnych pozycji do wyjścia poprzedniego rekruta, wznowienie po ruchu, niezależne bazy i właściciele, r/Enter, ignorowanie Esc w obu panelach i kontekstowa legenda |
| `tests/digging.rs` | 18 | Wykonywanie kopania i ruchu, kolejki, 3 sekundy na krok, wejście po wykopaniu, blokady i walidacja, kolory i procenty w TUI, podział trasy Moving/Digging, ochrona struktur, zmiana rodzaju zadania, panel Tab z postępem etapu i całego zadania |
Łącznie: 45 testów. Wszystkie testy znajdują się w `tests/` i sprawdzają publiczne interfejsy biblioteki lub binarkę. Funkcja `load_audio` jest eksportowana przez `app::threads`, dzięki czemu test korzysta z tego samego dekodera co odtwarzanie.
Łącznie: 107 testów. Wszystkie testy znajdują się w `tests/` i sprawdzają publiczne interfejsy biblioteki lub binarkę. Funkcja `load_audio` jest eksportowana przez `app::threads`, dzięki czemu test korzysta z tego samego dekodera co odtwarzanie.
Wybrane zestawy i pojedynczy przypadek:
@@ -41,19 +47,25 @@ nix develop -c cargo fmt --all -- --check
nix develop -c cargo clippy --all-targets --locked
```
`shellHook` środowiska Nix dodatkowo wykonuje `cargo run --locked -- -h`. Wynik pomocy nie jest wynikiem testów; właściwy raport zawiera podsumowania `test result`.
`shellHook` środowiska Nix wyświetla dostępne polecenia; nie kompiluje automatycznie programu.
Testy `tests/task_cancellation.rs` (4) sprawdzają usuwanie ostatniego zadania i całej kolejki pod Tab, zachowanie wykonanych uszkodzeń, przerwany ruch bez teleportacji i zachowania timera, ochronę przeciwnika i rysowania trasy. Dodatkowa regresja sprawdza kolory tras, znikanie po wykonaniu i odwołaniu, brak zmian flag modelu oraz ukrywanie tras przeciwnika.
## CI i profile kompilacji
Osobny workflow `.gitea/workflows/nix.yml` (push i pull request) instaluje Nix w `node:22-bookworm` i wykonuje `nix build .#default --no-update-lock-file --no-link --print-out-paths --print-build-logs`. Atrybut `.#default` wywołuje `pkgs.callPackage ./default.nix {}` z nixpkgs przypiętym w `flake.lock`; nie buduje wariantu `.#develop` opartego na Fenix. Budowa obejmuje standardową fazę testów `buildRustPackage` i włączony w pakiecie `versionCheckHook`. Nie wymaga devShell ani uruchamiania gry. Instalator potrzebuje sudo; KVM jest wyłączone. Workflow nie konfiguruje trwałego cache magazynu Nix, więc nowy job może ponownie pobierać zależności. Kontrole Rust opisane poniżej pozostają niezależne.
Jeden workflow `Project tests` w `.gitea/workflows/tests.yml` uruchamia się po pushu i dla pull requestów. Zależności `needs` ustalają kolejność `check → nix → linux → aur`; kolejny job czeka na sukces poprzedniego, a błąd pomija zależne joby. Zastępuje wcześniejsze osobne pliki `rust.yml`, `nix.yml` i `packages.yml`. Kolejność dotyczy jobów jednego przebiegu, nie serializuje różnych pushów.
Workflow `.gitea/workflows/rust.yml` uruchamia kontrole po pushu i dla pull requestów. Wymaga włączonych Actions w repozytorium Gitea i runnera Linux x86_64 o etykiecie `ubuntu-latest`, obsługującego akcje Node. Nazwy akcji zawierają pełne adresy GitHub. Konfiguracja nie rejestruje runnera ani nie włącza Actions na serwerze; zobacz [instrukcję Gitea](https://docs.gitea.com/usage/actions/quickstart/).
Job `linux` sprawdza testowe paczki deb, rpm, AppImage i Flatpak, a następujący po nim `aur` sprawdza paczkę Arch, bez publikowania wydań. Zasady budowania, artefakty i ograniczenia opisano w [PACKAGING.md](PACKAGING.md).
Workflow określa obraz zadania jawnie jako `node:22-bookworm`. Kontener działa jako root i instaluje pakiety potrzebne do budowania: build-essential, pkg-config i libasound2-dev oraz curl, ca-certificates i zstd. Rust 1.99.1 z rustfmt i Clippy jest instalowany przez rustup. CI nie instaluje Nix i nie wykonuje jego shellHook.
Job `nix` instaluje Nix w `node:22-bookworm` i wykonuje `nix build .#default --no-update-lock-file --no-link --print-out-paths --print-build-logs`. Atrybut `.#default` wywołuje `pkgs.callPackage ./default.nix {}` z nixpkgs przypiętym w `flake.lock`; nie buduje wariantu `.#develop` opartego na Fenix. Budowa obejmuje standardową fazę testów `buildRustPackage` i włączony w pakiecie `versionCheckHook`. Nie wymaga devShell ani uruchamiania gry. Instalator potrzebuje sudo; KVM jest wyłączone. Workflow nie konfiguruje trwałego cache magazynu Nix, więc nowy job może ponownie pobierać zależności.
Job `check` wykonuje kontrole Rust. Workflow wymaga włączonych Actions w repozytorium Gitea i runnera Linux x86_64 o etykiecie `ubuntu-latest`, obsługującego akcje Node. Nazwy akcji zawierają pełne adresy GitHub. Konfiguracja nie rejestruje runnera ani nie włącza Actions na serwerze; zobacz [instrukcję Gitea](https://docs.gitea.com/usage/actions/quickstart/).
Job `check` określa obraz zadania jawnie jako `node:22-bookworm`. Kontener działa jako root i instaluje pakiety potrzebne do budowania: build-essential, pkg-config i libasound2-dev oraz curl, ca-certificates i zstd. Rust 1.99.1 z rustfmt i Clippy jest instalowany przez rustup. Ten job nie instaluje Nix i nie wykonuje jego shellHook.
Wersję Rust w CI określa `RUST_VERSION` w workflow, niezależnie od lokalnego `flake.lock`. Formatowanie, kompilacja, testy i Clippy są oddzielnymi krokami Cargo. Polecenia kompilujące projekt mają `--locked`; Clippy nie używa `-D warnings` z powodu istniejących ostrzeżeń. `actions/cache@v4` zachowuje osobno toolchain oraz rejestr Cargo, repozytoria git i `target`. Klucze uwzględniają wersję Rust, a dla Cargo także manifest, lockfile i kod; klucze częściowe pozwalają ponownie wykorzystać wcześniejsze kompilacje. Działanie cache wymaga dostępnego serwera cache runnera. Pierwszy przebieg wypełnia cache; błędy pobierania cache mogą spowodować ponowną instalację i kompilację.
Release ma `overflow-checks = true`. Ten sam zestaw 45 testów można wykonać z kontrolą przepełnień w profilu produkcyjnym:
Release ma `overflow-checks = true`. Ten sam zestaw 107 testów można wykonać z kontrolą przepełnień w profilu produkcyjnym:
```sh
nix develop -c cargo test --all-targets --release --locked
@@ -63,4 +75,4 @@ Manifest określa minimalną wspieraną wersję Rust jako 1.88. Potwierdzono `ca
## Ograniczenia
Zestaw weryfikuje wykonywanie zadań przez ticki potyczki, ale nie pełną rozgrywkę, ekonomię ani walkę. Nie potwierdza odsłuchu, zużycia CPU, obsługi urządzeń, pakowania Nix ani wszystkich układów małych terminali. Testy renderowania są kontrolą podstawową, bez utrwalania pełnych zrzutów ekranów. Nowy workflow bez Nix wymaga przebiegu na Gitea; odczyt cache należy potwierdzić w kolejnym przebiegu.
Zestaw weryfikuje wykonywanie zadań przez ticki potyczki i podstawowe zasoby, ale nie pełną rozgrywkę, balans ekonomii ani walkę. Nie potwierdza odsłuchu, zużycia CPU, obsługi urządzeń, pakowania Nix ani wszystkich układów małych terminali. Testy renderowania są kontrolą podstawową, bez utrwalania pełnych zrzutów ekranów. Transport artefaktów i odczyt cache wymagają przebiegu na Gitea.
Generated
+9 -9
View File
@@ -8,11 +8,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1792320163,
"narHash": "sha256-3YqCuAdykExGnEFw44w+8qsjdOEZMBCViDpvue5ENoc=",
"lastModified": 1792926139,
"narHash": "sha256-uLMpwLKdV5U1966bkbe5DzC+5Gp8O6g+wjydKmih7zQ=",
"owner": "nix-community",
"repo": "fenix",
"rev": "53d7844fe302be84e5e63b2ca92a2f84c6490b4e",
"rev": "44ec3e059cf1631403a0faca34106be5fdb17631",
"type": "github"
},
"original": {
@@ -46,11 +46,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1792094642,
"narHash": "sha256-zp8ToDMnqZcu4o4IH9pdwXFXGp7yLm/M0dEnNWnyd7I=",
"lastModified": 1792773339,
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "fe13242281a67641c0102cdb1087f0ebe586c7df",
"rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504",
"type": "github"
},
"original": {
@@ -70,11 +70,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1792141191,
"narHash": "sha256-H161gJK9JlpkP3bGnsbTdzzRcP51oiFYrHbCdsQH4kc=",
"lastModified": 1792691653,
"narHash": "sha256-3/DMV3Lpmthk6OV2b7bJSJ5Okouyvl755YZENyLwllo=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "70e0c46be740a471bcc95f2be290f4bbfdcffd87",
"rev": "0c4d74686b21f2b3bd8872b6d3cf36ad2e740d49",
"type": "github"
},
"original": {
+4
View File
@@ -59,6 +59,7 @@
buildInputs = with pkgs; [
rustToolchain
alsa-lib
gitea-actions-runner
];
nativeBuildInputs = with pkgs; [pkg-config];
@@ -70,6 +71,9 @@
echo " nix run - Run production version"
echo " nix build .#develop - Build development version"
echo " nix run .#develop - Run development version"
echo ""
echo "Gitea workflows:"
echo " gitea-runner exec -W .gitea/workflows/tests.yml --event push --gitea-instance https://gitea.garandplg.com --network host"
'';
};
};
+28
View File
@@ -0,0 +1,28 @@
# Test recipe: CI supplies the source archive from the checked-out commit.
pkgname=war-in-tunnels
pkgver=0.1.0
pkgrel=1
pkgdesc='Underground strategy game in your terminal'
arch=('x86_64')
url='https://gitea.garandplg.com/GarandPLG/war-in-tunnels'
license=('MIT')
depends=('alsa-lib' 'gcc-libs' 'glibc')
makedepends=('cargo' 'pkgconf')
options=('!debug')
source=("$pkgname-$pkgver.tar.gz")
sha256sums=('SKIP')
build() {
cd "$srcdir/$pkgname-$pkgver"
cargo build --release --locked
}
check() {
cd "$srcdir/$pkgname-$pkgver"
cargo test --release --locked
}
package() {
cd "$srcdir/$pkgname-$pkgver"
sh packaging/stage.sh "$pkgdir/usr"
}
+91
View File
@@ -0,0 +1,91 @@
#!/bin/bash
set -euo pipefail
format=${1:?expected deb, rpm, appimage or flatpak}
version=$(python3 -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["package"]["version"])')
mkdir -p dist
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
sh packaging/stage.sh "$work/root/usr"
desktop-file-validate "$work/root/usr/share/applications/com.garandplg.WarInTunnels.desktop"
"$work/root/usr/bin/war-in-tunnels" --version | grep -Fx "war_in_tunnels $version"
test -s "$work/root/usr/lib/war-in-tunnels/soundtracks/default/calm.ogg"
case "$format" in
deb)
mkdir -p "$work/root/DEBIAN"
cat > "$work/root/DEBIAN/control" <<EOF
Package: war-in-tunnels
Version: $version
Architecture: amd64
Maintainer: GarandPLG
Depends: libasound2, libc6 (>= 2.36), libgcc-s1
Section: games
Priority: optional
Description: Underground strategy game in your terminal
EOF
output="$PWD/dist/war-in-tunnels_${version}_amd64.deb"
dpkg-deb --root-owner-group --build "$work/root" "$output"
dpkg-deb --info "$output"
dpkg-deb --extract "$output" "$work/extracted"
"$work/extracted/usr/bin/war-in-tunnels" --version
;;
rpm)
rpmbuild -bb packaging/war-in-tunnels.spec \
--define "_topdir $work/rpmbuild" \
--define "package_version $version" \
--define "staging $work/root" \
--define '_build_id_links none'
cp "$work"/rpmbuild/RPMS/x86_64/*.rpm dist/
rpm -qp --requires dist/*.rpm
mkdir "$work/extracted"
rpm2cpio dist/*.rpm | (cd "$work/extracted" && cpio -idm --quiet)
"$work/extracted/usr/bin/war-in-tunnels" --version
;;
appimage)
appdir="$work/WarInTunnels.AppDir"
mv "$work/root" "$appdir"
cp packaging/com.garandplg.WarInTunnels.desktop "$appdir/"
cp packaging/com.garandplg.WarInTunnels.svg "$appdir/"
mkdir -p "$appdir/usr/lib/bundled"
# Bundle ALSA; glibc and the ELF loader come from the host.
cp -L /usr/lib/x86_64-linux-gnu/libasound.so.2 "$appdir/usr/lib/bundled/"
cat > "$appdir/AppRun" <<'EOF'
#!/bin/sh
set -eu
appdir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
export LD_LIBRARY_PATH="$appdir/usr/lib/bundled${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
exec "$appdir/usr/bin/war-in-tunnels" "$@"
EOF
chmod +x "$appdir/AppRun"
curl -fL --retry 3 https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage -o "$work/appimagetool"
# Extract without executing the runtime: works without FUSE and with host binfmt handlers.
7z x "$work/appimagetool" "-o$work/appimagetool-dir" > /dev/null
output="$PWD/dist/war-in-tunnels-${version}-x86_64.AppImage"
ARCH=x86_64 "$work/appimagetool-dir/AppRun" --no-appstream "$appdir" "$output"
7z x "$output" "-o$work/appimage-check" > /dev/null
"$work/appimage-check/AppRun" --version
;;
flatpak)
appid=com.garandplg.WarInTunnels
# Package the Debian-built binary against a newer glibc runtime; no nested sandbox is needed.
flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak --user install -y --noninteractive --no-related flathub org.freedesktop.Platform//25.08
flatpak build-init "$work/flatpak" "$appid" org.freedesktop.Platform org.freedesktop.Platform 25.08
cp -a "$work/root/usr/." "$work/flatpak/files/"
install -Dm755 /usr/lib/x86_64-linux-gnu/libasound.so.2 "$work/flatpak/files/lib/libasound.so.2"
flatpak build-finish --command=war-in-tunnels --socket=pulseaudio --device=all "$work/flatpak"
# The icon is repository-owned; its validator cannot create a sandbox in standard Docker.
flatpak build-export --disable-sandbox "$work/repo" "$work/flatpak" test
output="$PWD/dist/war-in-tunnels-${version}-x86_64.flatpak"
flatpak build-bundle --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo "$work/repo" "$output" "$appid" test
flatpak --user install -y --noninteractive --no-related "$output"
location=$(flatpak --user info --show-location "$appid")
test -s "$location/files/lib/war-in-tunnels/soundtracks/default/calm.ogg"
"$location/files/bin/war-in-tunnels" --version
;;
*)
echo "Unknown package format: $format" >&2
exit 1
;;
esac
@@ -0,0 +1,8 @@
[Desktop Entry]
Type=Application
Name=War in Tunnels
Comment=Underground strategy game in your terminal
Exec=war-in-tunnels
Icon=com.garandplg.WarInTunnels
Terminal=true
Categories=Game;StrategyGame;
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="16" fill="#20252b"/>
<path d="M20 104V56a44 44 0 0 1 88 0v48H88V56a24 24 0 0 0-48 0v48Z" fill="#bd985e"/>
<path d="M51 91V59l13 15 13-15v32" fill="none" stroke="#e9edf0" stroke-width="7"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -eu
prefix=${1:?installation prefix required}
binary=${2:-target/release/war_in_tunnels}
install -Dm755 "$binary" "$prefix/lib/war-in-tunnels/war-in-tunnels"
mkdir -p "$prefix/bin"
cat > "$prefix/bin/war-in-tunnels" <<'EOF'
#!/bin/sh
set -eu
bindir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec "$bindir/../lib/war-in-tunnels/war-in-tunnels" "$@"
EOF
chmod +x "$prefix/bin/war-in-tunnels"
cp -r soundtracks "$prefix/lib/war-in-tunnels/"
install -Dm644 LICENSE "$prefix/share/licenses/war-in-tunnels/LICENSE"
install -Dm644 packaging/com.garandplg.WarInTunnels.desktop "$prefix/share/applications/com.garandplg.WarInTunnels.desktop"
install -Dm644 packaging/com.garandplg.WarInTunnels.svg "$prefix/share/icons/hicolor/scalable/apps/com.garandplg.WarInTunnels.svg"
+21
View File
@@ -0,0 +1,21 @@
Name: war-in-tunnels
Version: %{package_version}
Release: 1
Summary: Underground strategy game in your terminal
License: MIT
URL: https://gitea.garandplg.com/GarandPLG/war-in-tunnels
Requires: alsa-lib
%description
A terminal-based underground strategy game.
%install
mkdir -p %{buildroot}
cp -a %{staging}/. %{buildroot}/
%files
/usr/bin/war-in-tunnels
/usr/lib/war-in-tunnels
/usr/share/applications/com.garandplg.WarInTunnels.desktop
/usr/share/icons/hicolor/scalable/apps/com.garandplg.WarInTunnels.svg
%license /usr/share/licenses/war-in-tunnels/LICENSE
+1 -1
View File
@@ -3,7 +3,7 @@ style_edition = "2024"
max_width = 100
hard_tabs = false
tab_spaces = 2
tab_spaces = 4
newline_style = "Unix"
reorder_imports = true
+85 -84
View File
@@ -1,102 +1,103 @@
use crate::{
app::{GameStates, handle_keybindings, threads::AudioCmd, view::View},
cli::Cli,
app::{GameStates, handle_keybindings, threads::AudioCmd, view::View},
cli::Cli,
};
use ratatui::{DefaultTerminal, Frame, crossterm::event::KeyEvent, layout::Rect};
use std::{
io::Result,
sync::mpsc::{Receiver, Sender},
thread::sleep,
time::Duration,
io::Result,
sync::mpsc::{Receiver, Sender},
thread::sleep,
time::Duration,
};
pub struct App {
pub exit: bool,
pub view: View,
pub window_area: Rect,
pub args: Cli,
pub states: Option<GameStates>,
pub audio_tx: Sender<AudioCmd>,
pub exit: bool,
pub view: View,
pub window_area: Rect,
pub args: Cli,
pub states: Option<GameStates>,
pub audio_tx: Sender<AudioCmd>,
}
impl App {
pub fn new(args: Cli, audio_tx: Sender<AudioCmd>) -> Self {
Self {
exit: false,
view: args.view,
window_area: Rect::default(),
args: args,
states: None,
audio_tx,
}
}
pub fn states_ref(&self) -> Option<&GameStates> {
self.states.as_ref()
}
pub fn states_mut(&mut self) -> Option<&mut GameStates> {
self.states.as_mut()
}
pub fn run(
&mut self,
terminal: &mut DefaultTerminal,
input_rx: Receiver<KeyEvent>,
resize_rx: Receiver<(u16, u16)>,
tick_rx: Receiver<()>,
) -> Result<()> {
while !self.exit {
terminal.draw(|frame: &mut Frame<'_>| self.draw(frame))?;
if let Ok(key) = input_rx.try_recv() {
self.handle_key_event(key)?;
}
if let Ok((_, _)) = resize_rx.try_recv() {
let window_area: Rect = self.window_area;
if let Some(state) = self.states_mut() {
state
.skirmish
.board
.change_resize(&window_area, state.skirmish.side_panel);
pub fn new(args: Cli, audio_tx: Sender<AudioCmd>) -> Self {
Self {
exit: false,
view: args.view,
window_area: Rect::default(),
args: args,
states: None,
audio_tx,
}
}
if let Ok(()) = tick_rx.try_recv() {
self.update()?;
}
sleep(Duration::from_millis(10));
}
Ok(())
}
fn draw(&mut self, frame: &mut Frame<'_>) {
let window_area: Rect = frame.area();
if window_area.width != self.window_area.width || window_area.height != self.window_area.height
{
self.window_area = window_area;
if self.states.is_none() {
self.states = Some(GameStates::new(&self.args, &self.window_area))
}
pub fn states_ref(&self) -> Option<&GameStates> {
self.states.as_ref()
}
frame.render_widget(self, window_area);
}
fn handle_key_event(&mut self, key_event: KeyEvent) -> Result<()> {
handle_keybindings(self, key_event);
Ok(())
}
fn update(&mut self) -> Result<()> {
if let Some(state) = self.states_mut() {
state.skirmish.tick_update();
pub fn states_mut(&mut self) -> Option<&mut GameStates> {
self.states.as_mut()
}
Ok(())
}
pub fn run(
&mut self,
terminal: &mut DefaultTerminal,
input_rx: Receiver<KeyEvent>,
resize_rx: Receiver<(u16, u16)>,
tick_rx: Receiver<()>,
) -> Result<()> {
while !self.exit {
terminal.draw(|frame: &mut Frame<'_>| self.draw(frame))?;
if let Ok(key) = input_rx.try_recv() {
self.handle_key_event(key)?;
}
if let Ok((_, _)) = resize_rx.try_recv() {
let window_area: Rect = self.window_area;
if let Some(state) = self.states_mut() {
state.skirmish.board.change_resize(
&window_area,
state.skirmish.side_panel || state.skirmish.task_view,
);
}
}
if let Ok(()) = tick_rx.try_recv() {
self.update()?;
}
sleep(Duration::from_millis(10));
}
Ok(())
}
fn draw(&mut self, frame: &mut Frame<'_>) {
let window_area: Rect = frame.area();
if window_area.width != self.window_area.width
|| window_area.height != self.window_area.height
{
self.window_area = window_area;
if self.states.is_none() {
self.states = Some(GameStates::new(&self.args, &self.window_area))
}
}
frame.render_widget(self, window_area);
}
fn handle_key_event(&mut self, key_event: KeyEvent) -> Result<()> {
handle_keybindings(self, key_event);
Ok(())
}
fn update(&mut self) -> Result<()> {
if let Some(state) = self.states_mut() {
state.skirmish.tick_update();
}
Ok(())
}
}
+20 -17
View File
@@ -1,6 +1,6 @@
use ratatui::{
style::{Color, Style, Stylize},
text::{Line, Span},
style::{Color, Style, Stylize},
text::{Line, Span},
};
/// Creates a styled title `Line` for a UI block.
@@ -9,27 +9,30 @@ use ratatui::{
/// the spans, so the returned `Line` owns all its data and does not borrow from
/// the caller.
pub fn block_title_helper(
texts: &[(String, Color)],
separator: Option<&'static str>,
texts: &[(String, Color)],
separator: Option<&'static str>,
) -> Line<'static> {
let mut line: Line<'static> = Line::default();
let mut line: Line<'static> = Line::default();
line.spans.push(Span::from("[ ").gray());
line.spans
.push(Span::from("[ ").fg(Color::Rgb(192, 192, 192)));
for (i, (text, color)) in texts.iter().enumerate() {
let span: Span<'_> = Span::styled(text.clone(), Style::new().fg(*color));
line.spans.push(span);
for (i, (text, color)) in texts.iter().enumerate() {
let span: Span<'_> = Span::styled(text.clone(), Style::new().fg(*color));
line.spans.push(span);
if let Some(sep) = separator {
if i + 1 < texts.len() {
line.spans.push(Span::from(sep).gray());
}
if let Some(sep) = separator {
if i + 1 < texts.len() {
line.spans
.push(Span::from(sep).fg(Color::Rgb(192, 192, 192)));
}
}
}
}
line.spans.push(Span::from(" ]").gray());
line.spans
.push(Span::from(" ]").fg(Color::Rgb(192, 192, 192)));
line
line
}
/// Convenience wrapper for a single‑title line.
@@ -38,5 +41,5 @@ pub fn block_title_helper(
/// forwards it to `block_title_helper`. Because `block_title_helper` clones the
/// string, the temporary slice is safe.
pub fn block_single_title_helper(text: String, color: Color) -> Line<'static> {
block_title_helper(&[(text, color)], None)
block_title_helper(&[(text, color)], None)
}
+10 -10
View File
@@ -1,17 +1,17 @@
use crate::app::views::{skirmish_layout, skirmish_main_area_layout};
use ratatui::{
layout::{Margin, Rect},
widgets::{Block, Borders},
layout::{Margin, Rect},
widgets::{Block, Borders},
};
pub fn cells_area_helper(area: &Rect, side_panel: bool) -> Rect {
let board_area: Rect = skirmish_main_area_layout(skirmish_layout(*area)[1], side_panel)[0];
let board_area: Rect = skirmish_main_area_layout(skirmish_layout(*area)[1], side_panel)[0];
Block::new()
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT)
.inner(board_area)
.inner(Margin {
horizontal: 1,
vertical: 1,
})
Block::new()
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT)
.inner(board_area)
.inner(Margin {
horizontal: 1,
vertical: 1,
})
}
+11 -11
View File
@@ -1,13 +1,13 @@
pub fn main_menu_option_helper(s: String) -> String {
if let Some(pos) = s
.char_indices()
.filter(|(_, c)| c.is_ascii_uppercase())
.nth(1)
.map(|(i, _)| i)
{
let (first, second) = s.split_at(pos);
format!("{first} {second}")
} else {
s
}
if let Some(pos) = s
.char_indices()
.filter(|(_, c)| c.is_ascii_uppercase())
.nth(1)
.map(|(i, _)| i)
{
let (first, second) = s.split_at(pos);
format!("{first} {second}")
} else {
s
}
}
+8 -8
View File
@@ -1,14 +1,14 @@
use crate::app::{
App,
keybindings::{default_keybindings, main_menu_keybindings, skirmish_keybindings},
view::View,
App,
keybindings::{default_keybindings, main_menu_keybindings, skirmish_keybindings},
view::View,
};
use ratatui::crossterm::event::KeyEvent;
pub fn handle_keybindings(app: &mut App, key_event: KeyEvent) {
match app.view {
View::MainMenu => main_menu_keybindings(app, &key_event),
View::Skirmish => skirmish_keybindings(app, &key_event),
_ => default_keybindings(app, &key_event),
}
match app.view {
View::MainMenu => main_menu_keybindings(app, &key_event),
View::Skirmish => skirmish_keybindings(app, &key_event),
_ => default_keybindings(app, &key_event),
}
}
+14 -14
View File
@@ -1,23 +1,23 @@
use crate::app::{
App, View,
keybindings::{Action, event_to_action},
threads::AudioCmd,
App, View,
keybindings::{Action, event_to_action},
threads::AudioCmd,
};
use ratatui::crossterm::event::KeyEvent;
pub fn common_keybindings(app: &mut App, action: Action) {
match action {
Action::Quit | Action::Quit2 => app.exit = true,
Action::Esc => app.view = View::MainMenu,
Action::Mute => if let Err(_) = app.audio_tx.send(AudioCmd::Mute) {},
Action::VolumeUp => if let Err(_) = app.audio_tx.send(AudioCmd::VolumeUp) {},
Action::VolumeDown => if let Err(_) = app.audio_tx.send(AudioCmd::VolumeDown) {},
_ => (),
}
match action {
Action::Quit | Action::Quit2 => app.exit = true,
Action::Esc => app.view = View::MainMenu,
Action::Mute => if let Err(_) = app.audio_tx.send(AudioCmd::Mute) {},
Action::VolumeUp => if let Err(_) = app.audio_tx.send(AudioCmd::VolumeUp) {},
Action::VolumeDown => if let Err(_) = app.audio_tx.send(AudioCmd::VolumeDown) {},
_ => (),
}
}
pub fn default_keybindings(app: &mut App, key_event: &KeyEvent) {
if let Some(action) = event_to_action(&key_event) {
common_keybindings(app, action);
}
if let Some(action) = event_to_action(&key_event) {
common_keybindings(app, action);
}
}
+359 -307
View File
@@ -9,76 +9,83 @@ use std::collections::HashMap;
/// character key and carries the actual character pressed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Action {
/// Request the application to quit.
Quit,
/// Alternate quit action.
Quit2,
/// Move the selection cursor up.
Up,
/// Move the selection cursor down.
Down,
/// Move the selection cursor left.
Left,
/// Move the selection cursor right.
Right,
/// Scroll up without moving the cursor.
ScrollUp,
/// Scroll down without moving the cursor.
ScrollDown,
/// Scroll left without moving the cursor.
ScrollLeft,
/// Scroll right without moving the cursor.
ScrollRight,
/// Toggle selecting cell.
Space,
/// Unmark last selected cell.
Backspace,
/// Cancel marking cell.
Delete,
/// Submit or confirm the current choice.
Enter,
/// Return to the main menu or previous screen.
Esc,
/// Zoom the view in.
ZoomIn,
/// Zoom the view out.
ZoomOut,
/// Open side panel.
Tab,
/// Toggle skirmish board/skills view.
ShiftTab,
/// Mute music.
Mute,
/// Volume up.
VolumeUp,
/// Volume down.
VolumeDown,
/// Matches any character key; the inner `char` is the actual key pressed.
WildCard(char),
/// Request the application to quit.
Quit,
/// Alternate quit action.
Quit2,
/// Move the selection cursor up.
Up,
/// Move the selection cursor down.
Down,
/// Move the selection cursor left.
Left,
/// Move the selection cursor right.
Right,
/// Scroll up without moving the cursor.
ScrollUp,
/// Scroll down without moving the cursor.
ScrollDown,
/// Scroll left without moving the cursor.
ScrollLeft,
/// Scroll right without moving the cursor.
ScrollRight,
/// Toggle selecting cell.
Space,
/// Open or close the construction view.
Building,
/// Open or close the recruitment view.
Recruitment,
Tasks,
AutoSupports,
Stress,
/// Unmark last selected cell.
Backspace,
/// Cancel marking cell.
Delete,
/// Submit or confirm the current choice.
Enter,
/// Return to the main menu or previous screen.
Esc,
/// Zoom the view in.
ZoomIn,
/// Zoom the view out.
ZoomOut,
/// Open side panel.
Tab,
/// Toggle skirmish board/skills view.
ShiftTab,
/// Mute music.
Mute,
/// Volume up.
VolumeUp,
/// Volume down.
VolumeDown,
/// Matches any character key; the inner `char` is the actual key pressed.
WildCard(char),
}
/// Logical groups of key bindings used for organising help screens or
/// configuration sections.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
pub enum Group {
/// Bindings that combine `Ctrl` with movement keys.
CtrlMovement,
/// Plain movement bindings.
Movement,
/// Scroll bindings.
Scroll,
/// Selection bindings.
Select,
/// Text input related bindings.
Input,
/// Zoom related bindings.
Zoom,
/// Music related bindings.
Music,
/// Opens/Closes/Toggles elements in current view.
Opener,
/// Quit related bindings.
Quit,
/// Bindings that combine `Ctrl` with movement keys.
CtrlMovement,
/// Plain movement bindings.
Movement,
/// Scroll bindings.
Scroll,
/// Selection bindings.
Select,
/// Text input related bindings.
Input,
/// Zoom related bindings.
Zoom,
/// Music related bindings.
Music,
/// Opens/Closes/Toggles elements in current view.
Opener,
/// Quit related bindings.
Quit,
}
/// Description of a single key binding.
@@ -87,20 +94,20 @@ pub enum Group {
/// together with metadata used for displaying help information.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct KeyBinding {
/// The logical action performed when this binding is triggered.
pub action: Action,
/// The raw `crossterm::event::KeyCode` that identifies the key.
pub code: KeyCode,
/// The kind of key event (crossterm::event::KeyEventKind).
pub kind: KeyEventKind,
/// Any modifier keys that must be held (crossterm::event::KeyModifiers).
pub modifiers: KeyModifiers,
/// The group this binding belongs to, useful for categorising help.
pub group: Group,
/// Human‑readable symbol shown in UI/help screens.
pub symbol: &'static str,
/// Short description of what the binding does.
pub description: &'static str,
/// The logical action performed when this binding is triggered.
pub action: Action,
/// The raw `crossterm::event::KeyCode` that identifies the key.
pub code: KeyCode,
/// The kind of key event (crossterm::event::KeyEventKind).
pub kind: KeyEventKind,
/// Any modifier keys that must be held (crossterm::event::KeyModifiers).
pub modifiers: KeyModifiers,
/// The group this binding belongs to, useful for categorising help.
pub group: Group,
/// Human‑readable symbol shown in UI/help screens.
pub symbol: &'static str,
/// Short description of what the binding does.
pub description: &'static str,
}
/// Complete list of all key bindings used by the application.
@@ -108,218 +115,263 @@ pub struct KeyBinding {
/// The slice is ordered primarily for readability; the runtime lookup scans
/// this slice linearly, which is acceptable given the modest size of the list.
static KEYBINDINGS: &[KeyBinding] = &[
KeyBinding {
action: Action::Quit,
code: KeyCode::Char('q'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Quit,
symbol: "q",
description: "Quit",
},
KeyBinding {
action: Action::Quit2,
code: KeyCode::Char('c'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
group: Group::Quit,
symbol: "Ctrl + c",
description: "Quit",
},
KeyBinding {
action: Action::Up,
code: KeyCode::Up,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Movement,
symbol: "↑",
description: "Up",
},
KeyBinding {
action: Action::Down,
code: KeyCode::Down,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Movement,
symbol: "↓",
description: "Down",
},
KeyBinding {
action: Action::Left,
code: KeyCode::Left,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Movement,
symbol: "←",
description: "Left",
},
KeyBinding {
action: Action::Right,
code: KeyCode::Right,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Movement,
symbol: "→",
description: "Right",
},
KeyBinding {
action: Action::ScrollUp,
code: KeyCode::Up,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
group: Group::CtrlMovement,
symbol: "Ctrl + ↑",
description: "Scroll Up ",
},
KeyBinding {
action: Action::ScrollDown,
code: KeyCode::Down,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
group: Group::CtrlMovement,
symbol: "Ctrl + ↓",
description: "Scroll Down",
},
KeyBinding {
action: Action::ScrollLeft,
code: KeyCode::Left,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
group: Group::CtrlMovement,
symbol: "Ctrl + ←",
description: "Scroll Left",
},
KeyBinding {
action: Action::ScrollRight,
code: KeyCode::Right,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
group: Group::CtrlMovement,
symbol: "Ctrl + →",
description: "Scroll Right",
},
KeyBinding {
action: Action::Space,
code: KeyCode::Char(' '),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Select,
symbol: "Space",
description: "Marking cells",
},
KeyBinding {
action: Action::Backspace,
code: KeyCode::Backspace,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Select,
symbol: "Backspace",
description: "Unmark cell",
},
KeyBinding {
action: Action::Delete,
code: KeyCode::Delete,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Select,
symbol: "Delete",
description: "Cancel marking",
},
KeyBinding {
action: Action::Enter,
code: KeyCode::Enter,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Select,
symbol: "Enter",
description: "Submit",
},
KeyBinding {
action: Action::Esc,
code: KeyCode::Esc,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Quit,
symbol: "Esc",
description: "Main menu",
},
KeyBinding {
action: Action::ZoomIn,
code: KeyCode::Char(','),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Zoom,
symbol: ",",
description: "Zoom in",
},
KeyBinding {
action: Action::ZoomOut,
code: KeyCode::Char('.'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Zoom,
symbol: ".",
description: "Zoom out",
},
KeyBinding {
action: Action::Mute,
code: KeyCode::Char('m'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Music,
symbol: "m",
description: "🔈",
},
KeyBinding {
action: Action::VolumeUp,
code: KeyCode::Char('b'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Music,
symbol: "b",
description: "🔊",
},
KeyBinding {
action: Action::VolumeDown,
code: KeyCode::Char('n'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Music,
symbol: "n",
description: "🔉",
},
KeyBinding {
action: Action::Tab,
code: KeyCode::Tab,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Opener,
symbol: "Tab",
description: "Toggle side panel",
},
KeyBinding {
action: Action::ShiftTab,
code: KeyCode::Tab,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::SHIFT,
group: Group::Opener,
symbol: "Shift + Tab",
description: "Toggle Map/Skills window",
},
KeyBinding {
action: Action::WildCard('_'),
code: KeyCode::Char('_'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Input,
symbol: "[All]",
description: "All keyboard characters",
},
KeyBinding {
action: Action::Tasks,
code: KeyCode::Char('t'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Opener,
symbol: "t",
description: "Tasks",
},
KeyBinding {
action: Action::AutoSupports,
code: KeyCode::Char('a'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Opener,
symbol: "a",
description: "Auto supports",
},
KeyBinding {
action: Action::Stress,
code: KeyCode::Char('s'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Opener,
symbol: "s",
description: "Stress view",
},
KeyBinding {
action: Action::Recruitment,
code: KeyCode::Char('r'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Opener,
symbol: "r",
description: "Recruit",
},
KeyBinding {
action: Action::Building,
code: KeyCode::Char('b'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Opener,
symbol: "b",
description: "Buildings",
},
KeyBinding {
action: Action::Quit,
code: KeyCode::Char('q'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Quit,
symbol: "q",
description: "Quit",
},
KeyBinding {
action: Action::Quit2,
code: KeyCode::Char('c'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
group: Group::Quit,
symbol: "Ctrl + c",
description: "Quit",
},
KeyBinding {
action: Action::Up,
code: KeyCode::Up,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Movement,
symbol: "↑",
description: "Up",
},
KeyBinding {
action: Action::Down,
code: KeyCode::Down,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Movement,
symbol: "↓",
description: "Down",
},
KeyBinding {
action: Action::Left,
code: KeyCode::Left,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Movement,
symbol: "←",
description: "Left",
},
KeyBinding {
action: Action::Right,
code: KeyCode::Right,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Movement,
symbol: "→",
description: "Right",
},
KeyBinding {
action: Action::ScrollUp,
code: KeyCode::Up,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
group: Group::CtrlMovement,
symbol: "Ctrl + ↑",
description: "Scroll Up ",
},
KeyBinding {
action: Action::ScrollDown,
code: KeyCode::Down,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
group: Group::CtrlMovement,
symbol: "Ctrl + ↓",
description: "Scroll Down",
},
KeyBinding {
action: Action::ScrollLeft,
code: KeyCode::Left,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
group: Group::CtrlMovement,
symbol: "Ctrl + ←",
description: "Scroll Left",
},
KeyBinding {
action: Action::ScrollRight,
code: KeyCode::Right,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
group: Group::CtrlMovement,
symbol: "Ctrl + →",
description: "Scroll Right",
},
KeyBinding {
action: Action::Space,
code: KeyCode::Char(' '),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Select,
symbol: "Space",
description: "Marking cells",
},
KeyBinding {
action: Action::Backspace,
code: KeyCode::Backspace,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Select,
symbol: "Backspace",
description: "Unmark cell",
},
KeyBinding {
action: Action::Delete,
code: KeyCode::Delete,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Select,
symbol: "Delete",
description: "Cancel marking",
},
KeyBinding {
action: Action::Enter,
code: KeyCode::Enter,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Select,
symbol: "Enter",
description: "Submit",
},
KeyBinding {
action: Action::Esc,
code: KeyCode::Esc,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Quit,
symbol: "Esc",
description: "Main menu",
},
KeyBinding {
action: Action::ZoomIn,
code: KeyCode::Char(','),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Zoom,
symbol: ",",
description: "Zoom in",
},
KeyBinding {
action: Action::ZoomOut,
code: KeyCode::Char('.'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Zoom,
symbol: ".",
description: "Zoom out",
},
KeyBinding {
action: Action::Mute,
code: KeyCode::Char('m'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Music,
symbol: "m",
description: "🔈",
},
KeyBinding {
action: Action::VolumeUp,
code: KeyCode::Char('+'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Music,
symbol: "+",
description: "🔊",
},
KeyBinding {
action: Action::VolumeDown,
code: KeyCode::Char('-'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Music,
symbol: "-",
description: "🔉",
},
KeyBinding {
action: Action::Tab,
code: KeyCode::Tab,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Opener,
symbol: "Tab",
description: "Toggle side panel",
},
KeyBinding {
action: Action::ShiftTab,
code: KeyCode::Tab,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::SHIFT,
group: Group::Opener,
symbol: "Shift + Tab",
description: "Toggle Map/Skills window",
},
KeyBinding {
action: Action::WildCard('_'),
code: KeyCode::Char('_'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
group: Group::Input,
symbol: "[All]",
description: "All keyboard characters",
},
];
/// Returns the `KeyBinding` associated with the given `Action`, if one exists.
pub fn binding_for(action: Action) -> Option<&'static KeyBinding> {
KEYBINDINGS.iter().find(|b| b.action == action)
KEYBINDINGS.iter().find(|b| b.action == action)
}
/// Converts a raw `KeyEvent` into an `Action` if a matching binding is found.
@@ -330,23 +382,23 @@ pub fn binding_for(action: Action) -> Option<&'static KeyBinding> {
/// was pressed. Returns `None` when the event does not correspond to any known
/// action.
pub fn event_to_action(event: &KeyEvent) -> Option<Action> {
if let Some(b) = KEYBINDINGS
.iter()
.find(|b| b.code == event.code && b.kind == event.kind && b.modifiers == event.modifiers)
{
return Some(b.action);
}
if KEYBINDINGS
.iter()
.any(|b| matches!(b.action, Action::WildCard(_)) && b.kind == event.kind)
{
if let KeyCode::Char(c) = event.code {
return Some(Action::WildCard(c));
if let Some(b) = KEYBINDINGS
.iter()
.find(|b| b.code == event.code && b.kind == event.kind && b.modifiers == event.modifiers)
{
return Some(b.action);
}
}
None
if KEYBINDINGS
.iter()
.any(|b| matches!(b.action, Action::WildCard(_)) && b.kind == event.kind)
{
if let KeyCode::Char(c) = event.code {
return Some(Action::WildCard(c));
}
}
None
}
/// Determines the size of the largest group among a slice of actions.
@@ -355,13 +407,13 @@ pub fn event_to_action(event: &KeyEvent) -> Option<Action> {
/// returns the highest count. This can be used, for example, to size UI
/// elements that display groups of bindings.
pub fn count_largest_group(actions: &[Action]) -> u16 {
let mut group_counts: HashMap<Group, u16> = HashMap::new();
let mut group_counts: HashMap<Group, u16> = HashMap::new();
for action in actions {
if let Some(binding) = binding_for(*action) {
*group_counts.entry(binding.group).or_insert(0) += 1;
for action in actions {
if let Some(binding) = binding_for(*action) {
*group_counts.entry(binding.group).or_insert(0) += 1;
}
}
}
group_counts.values().copied().max().map_or(0, |v| v)
group_counts.values().copied().max().map_or(0, |v| v)
}
+27 -25
View File
@@ -1,36 +1,38 @@
use crate::app::{
App, View,
keybindings::{Action, common_keybindings, event_to_action},
App, View,
keybindings::{Action, common_keybindings, event_to_action},
};
use ratatui::crossterm::event::KeyEvent;
pub fn main_menu_keybindings(app: &mut App, event: &KeyEvent) {
if let Some(action) = event_to_action(&event) {
common_keybindings(app, action);
if let Some(action) = event_to_action(&event) {
common_keybindings(app, action);
let Some(states) = app.states_mut() else {
return;
};
let Some(states) = app.states_mut() else {
return;
};
match action {
Action::Up => {
states.main_menu.selected_view = states.main_menu.selected_view.saturating_sub(1).max(1);
}
Action::Down => {
states.main_menu.selected_view = states.main_menu.selected_view.saturating_add(1).min(3);
}
Action::Space => {
let selected_view: usize = states.main_menu.selected_view;
match action {
Action::Up => {
states.main_menu.selected_view =
states.main_menu.selected_view.saturating_sub(1).max(1);
}
Action::Down => {
states.main_menu.selected_view =
states.main_menu.selected_view.saturating_add(1).min(3);
}
Action::Space => {
let selected_view: usize = states.main_menu.selected_view;
if selected_view == 1 {
app.view = View::Skirmish;
} else if selected_view == 2 {
app.view = View::PerkDecks;
} else if selected_view == 3 {
app.view = View::SkillsConfig;
if selected_view == 1 {
app.view = View::Skirmish;
} else if selected_view == 2 {
app.view = View::PerkDecks;
} else if selected_view == 3 {
app.view = View::SkillsConfig;
}
}
_ => (),
}
}
_ => (),
}
}
}
+2 -2
View File
@@ -5,7 +5,7 @@ mod skirmish;
pub use default::{common_keybindings, default_keybindings};
pub use keybindings::{
Action, Group, KeyBinding, binding_for, count_largest_group, event_to_action,
Action, Group, KeyBinding, binding_for, count_largest_group, event_to_action,
};
pub use main_menu::main_menu_keybindings;
pub use skirmish::skirmish_keybindings;
pub use skirmish::{available_skirmish_actions, skirmish_keybindings};
+310 -86
View File
@@ -1,98 +1,322 @@
use crate::app::{
App,
keybindings::{Action, common_keybindings, event_to_action},
states::skirmish_states::{BoardState, MoveFocusedCell, ZoomLevel},
App,
keybindings::{Action, common_keybindings, event_to_action},
states::{
SkirmishState,
skirmish_states::{
BoardState, FocusedCell, MoveFocusedCell, Players, ZoomLevel,
structures::{Structure, Structures},
units::{Unit, Units},
},
},
};
use ratatui::{crossterm::event::KeyEvent, layout::Rect};
pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) {
if let Some(action) = event_to_action(&key_event) {
common_keybindings(app, action);
pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
if state.task_view {
let mut actions: Vec<Action> = vec![
Action::Tasks,
Action::Left,
Action::Right,
Action::Enter,
Action::Stress,
Action::Quit2,
];
if state.selected_task == 0 {
actions.push(Action::AutoSupports);
}
return actions;
}
if state.recruitment_view {
let mut actions: Vec<Action> = vec![
Action::Recruitment,
Action::Left,
Action::Right,
Action::Stress,
Action::Quit2,
];
if state.can_recruit_miner(state.board.get_focused_cell().get_coords(), Players::Player) {
actions.push(Action::Enter);
}
return actions;
}
if state.building_view {
let mut actions: Vec<Action> = vec![
Action::Building,
Action::Left,
Action::Right,
Action::Stress,
Action::Quit2,
];
let coords: (usize, usize) = state.board.get_focused_cell().get_coords();
if state.selected_building == 1 {
if state.can_build_support(coords, Players::Player) {
actions.push(Action::Enter);
}
if state.can_manage_support(coords, Players::Player)
&& matches!(state.board.cells[coords.0][coords.1].get_structure(), Structures::Tunnel(tunnel) if tunnel.has_support() && tunnel.support_work().is_none())
{
actions.push(Action::Delete);
}
return actions;
}
if let Structures::MineConstruction(site) =
state.board.get_ref_cell(coords.0, coords.1).get_structure()
&& site.get_owner() == Players::Player
{
actions.push(Action::Delete);
} else if state.can_start_mine_construction(coords, Players::Player) {
actions.push(Action::Enter);
}
return actions;
}
let mut actions: Vec<Action> = vec![Action::Up, Action::Down, Action::Left, Action::Right];
if state.board.marked_cells.marking_cells {
actions.extend([
Action::Space,
Action::Backspace,
Action::Delete,
Action::Esc,
Action::Tab,
Action::Quit2,
Action::Stress,
Action::Tasks,
]);
if !state.board.movement_only {
actions.push(Action::AutoSupports);
}
return actions;
}
actions.extend([
Action::ScrollUp,
Action::ScrollDown,
Action::ScrollLeft,
Action::ScrollRight,
Action::Tab,
Action::Building,
Action::Mute,
Action::Quit,
Action::Esc,
Action::Stress,
]);
if state.board.zoom_level != ZoomLevel::ZoomedIn {
actions.push(Action::ZoomIn);
}
if state.board.zoom_level != ZoomLevel::ZoomedOut {
actions.push(Action::ZoomOut);
}
let cell: &FocusedCell = state.board.get_focused_cell();
let (row, col): (usize, usize) = cell.get_coords();
if matches!(state.board.get_ref_cell(row, col).get_structure(), Structures::Base(base) if base.get_owner() == Players::Player)
{
actions.push(Action::Recruitment);
}
let unit: &Option<Units> = state.board.get_ref_cell(row, col).get_ref_option_unit();
if unit.get_owner() == Players::Player && unit.get_can_dig() {
actions.push(Action::Space);
}
if state.side_panel && unit.get_owner() == Players::Player && !unit.get_ref_tasks().is_empty() {
actions.extend([Action::Backspace, Action::Delete]);
}
actions
}
pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () {
let window_area: Rect = app.window_area;
if let Some(action) = event_to_action(key_event) {
let Some(states) = app.states_mut() else {
common_keybindings(app, action);
return;
};
if action == Action::Stress {
states.skirmish.stress_view = !states.skirmish.stress_view;
return;
}
if states.skirmish.task_view {
match action {
Action::Tasks | Action::Enter => {
states.skirmish.task_view = false;
if action == Action::Enter {
states
.skirmish
.board
.configure_selected_task(states.skirmish.selected_task == 1);
}
states
.skirmish
.board
.change_resize(&window_area, states.skirmish.side_panel);
}
Action::Left => states.skirmish.cycle_popup_option(false),
Action::Right => states.skirmish.cycle_popup_option(true),
Action::AutoSupports if states.skirmish.selected_task == 0 => {
states.skirmish.board.auto_supports = !states.skirmish.board.auto_supports
}
Action::Esc => (),
_ => common_keybindings(app, action),
}
return;
}
if states.skirmish.recruitment_view {
match action {
Action::Recruitment => states.skirmish.recruitment_view = false,
Action::Left => states.skirmish.cycle_popup_option(false),
Action::Right => states.skirmish.cycle_popup_option(true),
Action::Esc => (),
Action::Enter => {
let coords: (usize, usize) =
states.skirmish.board.get_focused_cell().get_coords();
states.skirmish.recruit_miner(coords, Players::Player);
}
_ => common_keybindings(app, action),
}
return;
}
if states.skirmish.building_view {
match action {
Action::Building => states.skirmish.building_view = false,
Action::Left => states.skirmish.cycle_popup_option(false),
Action::Right => states.skirmish.cycle_popup_option(true),
Action::Esc => (),
Action::Enter => {
let coords: (usize, usize) =
states.skirmish.board.get_focused_cell().get_coords();
if states.skirmish.selected_building == 1 {
states.skirmish.build_support(coords, Players::Player);
} else {
states
.skirmish
.start_mine_construction(coords, Players::Player);
}
}
Action::Delete => {
let coords: (usize, usize) =
states.skirmish.board.get_focused_cell().get_coords();
if states.skirmish.selected_building == 1 {
states.skirmish.remove_support(coords, Players::Player);
} else {
states
.skirmish
.cancel_mine_construction(coords, Players::Player);
}
}
_ => common_keybindings(app, action),
}
return;
}
if action == Action::Esc && states.skirmish.board.marked_cells.marking_cells {
states.skirmish.board.marked_cells.marking_cells = false;
states.skirmish.board.end_marking_cells(true);
return;
}
let available: bool = available_skirmish_actions(&states.skirmish).contains(&action);
common_keybindings(app, action);
if !available {
return;
}
let Some(states) = app.states_mut() else {
return;
};
let window_area: Rect = app.window_area;
let board: &mut BoardState = &mut states.skirmish.board;
let Some(states) = app.states_mut() else {
return;
};
match action {
Action::Up => {
board.change_focused_cell(MoveFocusedCell::Up);
if !board.is_focused_cell_visible() {
board.vertical_offset.prev();
let board: &mut BoardState = &mut states.skirmish.board;
match action {
Action::Up => {
board.change_focused_cell(MoveFocusedCell::Up);
if !board.is_focused_cell_visible() {
board.vertical_offset.prev();
}
}
Action::Down => {
board.change_focused_cell(MoveFocusedCell::Down);
if !board.is_focused_cell_visible() {
board.vertical_offset.next();
}
}
Action::Left => {
board.change_focused_cell(MoveFocusedCell::Left);
if !board.is_focused_cell_visible() {
board.horizontal_offset.prev();
}
}
Action::Right => {
board.change_focused_cell(MoveFocusedCell::Right);
if !board.is_focused_cell_visible() {
board.horizontal_offset.next();
}
}
Action::ScrollUp => {
board.vertical_offset.prev();
board.change_focused_cell(MoveFocusedCell::Up);
}
Action::ScrollDown => {
board.vertical_offset.next();
board.change_focused_cell(MoveFocusedCell::Down);
}
Action::ScrollLeft => {
board.horizontal_offset.prev();
board.change_focused_cell(MoveFocusedCell::Left);
}
Action::ScrollRight => {
board.horizontal_offset.next();
board.change_focused_cell(MoveFocusedCell::Right);
}
Action::ZoomIn => match board.zoom_level {
ZoomLevel::ZoomedIn => {}
ZoomLevel::Default => {
board.change_zoom(ZoomLevel::ZoomedIn);
board.vertical_offset.next();
}
ZoomLevel::ZoomedOut => {
board.change_zoom(ZoomLevel::Default);
board.vertical_offset.next();
board.vertical_offset.next();
}
},
Action::ZoomOut => match board.zoom_level {
ZoomLevel::ZoomedIn => {
board.change_zoom(ZoomLevel::Default);
board.vertical_offset.prev();
}
ZoomLevel::Default => {
board.change_zoom(ZoomLevel::ZoomedOut);
board.vertical_offset.prev();
board.vertical_offset.prev();
}
ZoomLevel::ZoomedOut => {}
},
Action::Space => {
if !board.marked_cells.marking_cells {
board.movement_only = false;
}
board.toggle_marking();
}
Action::Building => states.skirmish.building_view = true,
Action::Recruitment => states.skirmish.recruitment_view = true,
Action::Tasks => {
states.skirmish.task_view = true;
board.change_resize(&window_area, true);
}
Action::AutoSupports => board.auto_supports = !board.auto_supports,
Action::Backspace if board.marked_cells.marking_cells => board.pop_marked_cell(),
Action::Backspace => {
board.cancel_focused_tasks(false);
}
Action::Delete if !board.marked_cells.marking_cells => {
board.cancel_focused_tasks(true);
}
Action::Delete => {
board.marked_cells.marking_cells = false;
board.end_marking_cells(true)
}
Action::Tab => {
states.skirmish.side_panel = !states.skirmish.side_panel;
board.change_resize(&window_area, states.skirmish.side_panel);
}
_ => (),
}
}
Action::Down => {
board.change_focused_cell(MoveFocusedCell::Down);
if !board.is_focused_cell_visible() {
board.vertical_offset.next();
}
}
Action::Left => {
board.change_focused_cell(MoveFocusedCell::Left);
if !board.is_focused_cell_visible() {
board.horizontal_offset.prev();
}
}
Action::Right => {
board.change_focused_cell(MoveFocusedCell::Right);
if !board.is_focused_cell_visible() {
board.horizontal_offset.next();
}
}
Action::ScrollUp => {
board.vertical_offset.prev();
board.change_focused_cell(MoveFocusedCell::Up);
}
Action::ScrollDown => {
board.vertical_offset.next();
board.change_focused_cell(MoveFocusedCell::Down);
}
Action::ScrollLeft => {
board.horizontal_offset.prev();
board.change_focused_cell(MoveFocusedCell::Left);
}
Action::ScrollRight => {
board.horizontal_offset.next();
board.change_focused_cell(MoveFocusedCell::Right);
}
Action::ZoomIn => match board.zoom_level {
ZoomLevel::ZoomedIn => {}
ZoomLevel::Default => {
board.change_zoom(ZoomLevel::ZoomedIn);
board.vertical_offset.next();
}
ZoomLevel::ZoomedOut => {
board.change_zoom(ZoomLevel::Default);
board.vertical_offset.next();
board.vertical_offset.next();
}
},
Action::ZoomOut => match board.zoom_level {
ZoomLevel::ZoomedIn => {
board.change_zoom(ZoomLevel::Default);
board.vertical_offset.prev();
}
ZoomLevel::Default => {
board.change_zoom(ZoomLevel::ZoomedOut);
board.vertical_offset.prev();
board.vertical_offset.prev();
}
ZoomLevel::ZoomedOut => {}
},
Action::Space => board.toggle_marking(),
Action::Backspace => board.pop_marked_cell(),
Action::Delete => {
board.marked_cells.marking_cells = false;
board.end_marking_cells(true)
}
Action::Tab => {
states.skirmish.side_panel = !states.skirmish.side_panel;
board.change_resize(&window_area, states.skirmish.side_panel);
}
_ => (),
}
}
}
+1
View File
@@ -11,5 +11,6 @@ mod widgets;
pub use app::App;
pub use keybind::handle_keybindings;
pub use keybindings::{Action, available_skirmish_actions};
pub use state::GameStates;
pub use view::View;
+60 -51
View File
@@ -1,62 +1,71 @@
use crate::{
app::states::{
MainMenuState, PerkDecksState, SettingsState, SkillsConfigState, SkirmishState,
skirmish_states::BoardState,
},
cli::Cli,
app::states::{
MainMenuState, PerkDecksState, SettingsState, SkillsConfigState, SkirmishState,
skirmish_states::{BoardState, Resources},
},
cli::Cli,
};
use ratatui::layout::Rect;
#[derive(Debug, Clone, PartialEq)]
pub struct GameStates {
pub main_menu: MainMenuState,
pub skirmish: SkirmishState,
pub perk_decks: PerkDecksState,
pub skills_config: SkillsConfigState,
pub settings: SettingsState,
pub main_menu: MainMenuState,
pub skirmish: SkirmishState,
pub perk_decks: PerkDecksState,
pub skills_config: SkillsConfigState,
pub settings: SettingsState,
}
impl GameStates {
pub fn new(args: &Cli, area: &Rect) -> Self {
Self {
main_menu: MainMenuState {
id: 0,
name: "Main Menu",
selected_view: 1,
},
skirmish: SkirmishState {
id: 1,
name: "Skirmish",
board: BoardState::new(
area,
args.map_width as usize,
args.map_height as usize,
args.zoom_level,
false,
),
side_panel: false,
turn_counter: 0,
},
perk_decks: PerkDecksState {
id: 2,
name: "Perk Decks",
selected_perk_deck: 0,
},
skills_config: SkillsConfigState {
id: 3,
name: "Skills Config",
selected_skill: 0,
},
settings: SettingsState {
username: args.username.clone(),
game_mode: args.game_mode.clone(),
perk_deck: args.perk_deck.clone(),
starting_wood: args.starting_wood.clone(),
starting_iron: args.starting_iron.clone(),
supply_limit: args.supply_limit.clone(),
xp_modifier: args.xp_modifier.clone(),
skill_points_limit: args.skill_points_limit.clone(),
},
pub fn new(args: &Cli, area: &Rect) -> Self {
Self {
main_menu: MainMenuState {
id: 0,
name: "Main Menu",
selected_view: 1,
},
skirmish: SkirmishState {
id: 1,
name: "Skirmish",
board: BoardState::new(
area,
args.map_width as usize,
args.map_height as usize,
args.zoom_level,
false,
),
side_panel: false,
turn_counter: 0,
player_resources: Resources::new(args.starting_wood, args.starting_metal),
enemy_resources: Resources::new(args.starting_wood, args.starting_metal),
building_view: false,
recruitment_view: false,
selected_building: 0,
selected_recruit: 0,
task_view: false,
stress_view: false,
selected_task: 0,
},
perk_decks: PerkDecksState {
id: 2,
name: "Perk Decks",
selected_perk_deck: 0,
},
skills_config: SkillsConfigState {
id: 3,
name: "Skills Config",
selected_skill: 0,
},
settings: SettingsState {
username: args.username.clone(),
game_mode: args.game_mode.clone(),
perk_deck: args.perk_deck.clone(),
starting_wood: args.starting_wood.clone(),
starting_metal: args.starting_metal,
supply_limit: args.supply_limit.clone(),
xp_modifier: args.xp_modifier.clone(),
skill_points_limit: args.skill_points_limit.clone(),
},
}
}
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MainMenuState {
pub id: usize,
pub name: &'static str,
pub selected_view: usize,
pub id: usize,
pub name: &'static str,
pub selected_view: usize,
}
+12 -12
View File
@@ -3,24 +3,24 @@ use std::fmt::{Display, Formatter, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PerkDecksState {
pub id: usize,
pub name: &'static str,
pub selected_perk_deck: usize,
pub id: usize,
pub name: &'static str,
pub selected_perk_deck: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
pub enum PerkDecks {
Silesian,
BogeyMan,
Anteater,
Silesian,
BogeyMan,
Anteater,
}
impl Display for PerkDecks {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
match self {
PerkDecks::Silesian => write!(f, "Silesian"),
PerkDecks::BogeyMan => write!(f, "Bogey Man"),
PerkDecks::Anteater => write!(f, "Anteater"),
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
match self {
PerkDecks::Silesian => write!(f, "Silesian"),
PerkDecks::BogeyMan => write!(f, "Bogey Man"),
PerkDecks::Anteater => write!(f, "Anteater"),
}
}
}
}
+8 -8
View File
@@ -2,12 +2,12 @@ use crate::app::states::{PerkDecks, skirmish_states::GameMode};
#[derive(Debug, Clone, PartialEq)]
pub struct SettingsState {
pub username: String,
pub game_mode: GameMode,
pub perk_deck: PerkDecks,
pub starting_wood: u16,
pub starting_iron: u16,
pub supply_limit: u8,
pub xp_modifier: f32,
pub skill_points_limit: u16,
pub username: String,
pub game_mode: GameMode,
pub perk_deck: PerkDecks,
pub starting_wood: u16,
pub starting_metal: u16,
pub supply_limit: u8,
pub xp_modifier: f32,
pub skill_points_limit: u16,
}
+3 -3
View File
@@ -1,6 +1,6 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SkillsConfigState {
pub id: usize,
pub name: &'static str,
pub selected_skill: usize,
pub id: usize,
pub name: &'static str,
pub selected_skill: usize,
}
+294 -11
View File
@@ -1,20 +1,303 @@
use crate::app::states::skirmish_states::BoardState;
use crate::app::states::skirmish_states::{
BoardState, Excavation, Players, Resources,
resources::{MINE_PRODUCTION_TICKS, WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_TICKS},
structures::{BuildingTerrain, Mine, Structure, Structures, Tunnel},
tasks::{Task, Tasks},
units::{MinerUnit, Unit},
};
use crate::app::widgets::CellWidget;
#[derive(Debug, Clone, PartialEq)]
pub struct SkirmishState {
pub id: usize,
pub name: &'static str,
pub board: BoardState,
pub side_panel: bool,
pub turn_counter: u64,
pub id: usize,
pub name: &'static str,
pub board: BoardState,
pub side_panel: bool,
pub turn_counter: u64,
pub player_resources: Resources,
pub enemy_resources: Resources,
pub building_view: bool,
pub recruitment_view: bool,
pub selected_building: usize,
pub selected_recruit: usize,
pub task_view: bool,
pub stress_view: bool,
pub selected_task: usize,
}
impl SkirmishState {
pub fn tick_update(&mut self) {
self.board.advance_turn();
pub const BUILDING_OPTIONS: &'static [&'static str] = &["Mine", "Support"];
pub const RECRUITMENT_OPTIONS: &'static [&'static str] = &["Miner"];
pub const TASK_OPTIONS: &'static [&'static str] = &["Digging", "Moving"];
// if self.board.is_victory() {}
pub fn can_manage_support(&self, coords: (usize, usize), owner: Players) -> bool {
owner != Players::Unclaimed
&& self
.board
.cells
.get(coords.0)
.and_then(|row: &Vec<CellWidget>| -> Option<&CellWidget> { row.get(coords.1) })
.is_some_and(|cell: &CellWidget| -> bool {
matches!(cell.get_structure(), Structures::Tunnel(_))
&& ((cell.get_ref_option_unit().get_owner() == owner
&& cell.get_ref_option_unit().get_can_build())
|| self.board.has_adjacent_builder(coords, owner))
})
}
self.turn_counter += 1;
}
pub fn can_build_support(&self, coords: (usize, usize), owner: Players) -> bool {
let resources: &Resources = match owner {
Players::Player => &self.player_resources,
Players::Enemy => &self.enemy_resources,
Players::Unclaimed => return false,
};
self.can_manage_support(coords, owner)
&& resources.can_afford(Tunnel::SUPPORT_COST)
&& matches!(self.board.cells[coords.0][coords.1].get_structure(), Structures::Tunnel(tunnel) if !tunnel.has_support() && tunnel.support_work().is_none())
}
pub fn build_support(&mut self, coords: (usize, usize), owner: Players) -> bool {
if !self.can_build_support(coords, owner) {
return false;
}
let Structures::Tunnel(mut tunnel) = self.board.cells[coords.0][coords.1].get_structure()
else {
return false;
};
let resources: &mut Resources = if owner == Players::Player {
&mut self.player_resources
} else {
&mut self.enemy_resources
};
if !resources.spend(Tunnel::SUPPORT_COST) {
return false;
}
tunnel.start_support_work(owner, false);
self.board.cells[coords.0][coords.1].set_structure(Structures::Tunnel(tunnel));
true
}
pub fn remove_support(&mut self, coords: (usize, usize), owner: Players) -> bool {
if !self.can_manage_support(coords, owner) {
return false;
}
let Structures::Tunnel(mut tunnel) = self.board.cells[coords.0][coords.1].get_structure()
else {
return false;
};
if !tunnel.has_support() || tunnel.support_work().is_some() {
return false;
}
tunnel.start_support_work(owner, true);
self.board.cells[coords.0][coords.1].set_structure(Structures::Tunnel(tunnel));
true
}
pub fn advance_support_work(&mut self) -> () {
let mut workers: std::collections::HashSet<(usize, usize)> =
std::collections::HashSet::new();
let mut finished: bool = false;
let mut removed: bool = false;
for row in 0..self.board.map_height {
for col in 0..self.board.map_width {
let Structures::Tunnel(mut tunnel) = self.board.cells[row][col].get_structure()
else {
continue;
};
let Some(work) = tunnel.support_work() else {
continue;
};
let Some(worker) = self.board.support_worker((row, col), work.owner) else {
continue;
};
if !workers.insert(worker) {
continue;
}
if let Some(removing) = tunnel.advance_support_work() {
finished = true;
removed |= removing;
}
self.board.cells[row][col].set_structure(Structures::Tunnel(tunnel));
}
}
if removed {
self.board.resolve_collapses();
} else if finished {
self.board.recalculate_stress();
}
}
fn prepare_automatic_supports(&mut self) -> () {
let mut candidates: Vec<((usize, usize), Players)> = Vec::new();
for (row, cells) in self.board.cells.iter().enumerate() {
for (col, cell) in cells.iter().enumerate() {
if !matches!(cell.get_structure(), Structures::Tunnel(tunnel) if !tunnel.has_support() && tunnel.get_stress() == 80)
{
continue;
}
if self.board.working_on_support((row, col))
|| (self.board.marked_cells.marking_cells
&& self.board.marked_cells.selected_unit.get_coords() == (row, col))
{
continue;
}
let Some(unit) = cell.get_ref_option_unit() else {
continue;
};
let Some(task) = unit.get_ref_tasks().front() else {
continue;
};
let Some(&(r, c)) = task.get_path().front() else {
continue;
};
if task.auto_supports()
&& self.can_build_support((row, col), unit.get_owner())
&& (matches!(task, Tasks::Digging(_))
|| matches!(task, Tasks::Moving(moving) if moving.dig_if_blocked))
&& r < self.board.map_height
&& c < self.board.map_width
&& row.abs_diff(r) + col.abs_diff(c) == 1
&& self.board.cells[r][c].get_ref_option_unit().is_none()
&& matches!(
self.board.cells[r][c].get_structure(),
Structures::Stone(_) | Structures::CollapsedTunnel(_)
)
&& self.board.prospective_tunnel_stress((r, c)) >= 100
{
candidates.push(((row, col), unit.get_owner()));
}
}
}
for (coords, owner) in candidates {
self.build_support(coords, owner);
}
}
pub fn cycle_popup_option(&mut self, next: bool) -> () {
let (selected, count): (&mut usize, usize) = if self.task_view {
(&mut self.selected_task, Self::TASK_OPTIONS.len())
} else if self.recruitment_view {
(&mut self.selected_recruit, Self::RECRUITMENT_OPTIONS.len())
} else if self.building_view {
(&mut self.selected_building, Self::BUILDING_OPTIONS.len())
} else {
return;
};
*selected = if next {
(*selected + 1) % count
} else {
(*selected + count - 1) % count
};
}
pub fn can_recruit_miner(&self, (row, col): (usize, usize), owner: Players) -> bool {
let resources: &Resources = match owner {
Players::Player => &self.player_resources,
Players::Enemy => &self.enemy_resources,
Players::Unclaimed => return false,
};
if !resources.can_afford(MinerUnit::RECRUIT_COST) {
return false;
}
self.board.cells.get(row).and_then(|cells: &Vec<CellWidget>| -> Option<&CellWidget> { cells.get(col) })
.is_some_and(|cell: &CellWidget| -> bool {
matches!(cell.get_structure(), Structures::Base(base) if base.get_owner() == owner && base.can_queue_miner())
})
}
pub fn recruit_miner(&mut self, coords: (usize, usize), owner: Players) -> bool {
if !self.can_recruit_miner(coords, owner) {
return false;
}
let cell: &mut CellWidget = &mut self.board.cells[coords.0][coords.1];
let Structures::Base(mut base) = cell.get_structure() else {
return false;
};
if !base.queue_miner() {
return false;
}
cell.set_structure(Structures::Base(base));
let resources: &mut Resources = if owner == Players::Player {
&mut self.player_resources
} else {
&mut self.enemy_resources
};
resources.spend(MinerUnit::RECRUIT_COST)
}
pub fn can_start_mine_construction(&self, coords: (usize, usize), owner: Players) -> bool {
let resources: &Resources = match owner {
Players::Player => &self.player_resources,
Players::Enemy => &self.enemy_resources,
Players::Unclaimed => return false,
};
resources.can_afford(Mine::COST)
&& self.board.can_build_at(coords, owner, BuildingTerrain::Ore)
}
pub fn start_mine_construction(&mut self, coords: (usize, usize), owner: Players) -> bool {
if !self.can_start_mine_construction(coords, owner)
|| !self.board.start_mine_construction(coords, owner)
{
return false;
}
let resources: &mut Resources = match owner {
Players::Player => &mut self.player_resources,
Players::Enemy => &mut self.enemy_resources,
Players::Unclaimed => return false,
};
resources.spend(Mine::COST)
}
pub fn cancel_mine_construction(&mut self, coords: (usize, usize), owner: Players) -> bool {
if owner == Players::Unclaimed || !self.board.cancel_mine_construction(coords, owner) {
return false;
}
let resources: &mut Resources = if owner == Players::Player {
&mut self.player_resources
} else {
&mut self.enemy_resources
};
resources.add_wood(Mine::COST.wood);
resources.add_metal(Mine::COST.metal);
true
}
pub fn tick_update(&mut self) -> () {
self.advance_support_work();
self.prepare_automatic_supports();
let excavated: Vec<Excavation> = self.board.advance_turn_with_events();
if !excavated.is_empty() {
self.board.resolve_collapses();
}
self.board.advance_recruitment();
self.board.advance_construction();
// if self.board.is_victory() {}
self.turn_counter += 1;
if self.turn_counter.is_multiple_of(WOOD_DELIVERY_TICKS) {
self.player_resources.add_wood(WOOD_DELIVERY_AMOUNT);
}
if self.turn_counter.is_multiple_of(MINE_PRODUCTION_TICKS) {
for row in &mut self.board.cells {
for cell in row {
if let Structures::Mine(mut mine) = cell.get_structure() {
let staffed: bool = cell.get_ref_option_unit().as_ref().is_some_and(
|unit: &crate::app::states::skirmish_states::units::Units| -> bool {
mine.accepts_unit(unit)
},
);
let amount: u32 = u32::from(mine.produce_with_miner(staffed));
match mine.get_owner() {
Players::Player => self.player_resources.add_metal(amount),
Players::Enemy => self.enemy_resources.add_metal(amount),
Players::Unclaimed => (),
}
cell.set_structure(Structures::Mine(mine));
}
}
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,166 @@
use super::{
BoardState, Players,
structures::{CollapsedTunnel, Structure, Structures},
units::{Unit, Units},
};
use crate::app::widgets::CellWidget;
use std::collections::VecDeque;
impl BoardState {
fn stress_distances(&self) -> Vec<Vec<u8>> {
let mut distance: Vec<Vec<u8>> = vec![vec![100; self.map_width]; self.map_height];
let mut queue: VecDeque<(usize, usize)> = VecDeque::new();
for (row, cells) in self.cells.iter().enumerate() {
for (col, cell) in cells.iter().enumerate() {
let source: Option<u8> = match cell.get_structure() {
Structures::Base(_) => Some(0),
Structures::Tunnel(tunnel) if tunnel.has_support() => Some(20),
_ => None,
};
if let Some(stress) = source {
distance[row][col] = stress;
queue.push_back((row, col));
}
}
}
while let Some((row, col)) = queue.pop_front() {
let next: u8 = distance[row][col].saturating_add(20).min(100);
for (r, c) in [
(row.wrapping_sub(1), col),
(row + 1, col),
(row, col.wrapping_sub(1)),
(row, col + 1),
] {
if r < self.map_height
&& c < self.map_width
&& next < distance[r][c]
&& matches!(
self.cells[r][c].get_structure(),
Structures::Tunnel(_) | Structures::Base(_) | Structures::Mine(_)
)
{
distance[r][c] = next;
queue.push_back((r, c));
}
}
}
distance
}
pub fn prospective_tunnel_stress(&self, (row, col): (usize, usize)) -> u8 {
let distance: Vec<Vec<u8>> = self.stress_distances();
[
(row.wrapping_sub(1), col),
(row + 1, col),
(row, col.wrapping_sub(1)),
(row, col + 1),
]
.into_iter()
.filter_map(|(r, c): (usize, usize)| -> Option<u8> {
distance
.get(r)
.and_then(|line: &Vec<u8>| -> Option<&u8> { line.get(c) })
.copied()
})
.min()
.unwrap_or(100)
.saturating_add(20)
.min(100)
}
pub fn recalculate_stress(&mut self) -> () {
let distance: Vec<Vec<u8>> = self.stress_distances();
for (row, cells) in self.cells.iter_mut().enumerate() {
for (col, cell) in cells.iter_mut().enumerate() {
if let Structures::Tunnel(mut tunnel) = cell.get_structure() {
tunnel.set_stress(distance[row][col]);
cell.set_structure(Structures::Tunnel(tunnel));
}
}
}
}
pub fn support_worker(
&self,
(row, col): (usize, usize),
owner: Players,
) -> Option<(usize, usize)> {
if owner == Players::Unclaimed {
return None;
}
[
(row, col),
(row.wrapping_sub(1), col),
(row + 1, col),
(row, col.wrapping_sub(1)),
(row, col + 1),
]
.into_iter()
.find(|&(r, c): &(usize, usize)| -> bool {
self.cells
.get(r)
.and_then(|line: &Vec<CellWidget>| -> Option<&CellWidget> { line.get(c) })
.and_then(|cell: &CellWidget| -> Option<&Units> {
cell.get_ref_option_unit().as_ref()
})
.is_some_and(|unit: &Units| -> bool {
unit.get_owner() == owner && unit.get_can_build()
})
})
}
pub fn working_on_support(&self, (row, col): (usize, usize)) -> bool {
[
(row, col),
(row.wrapping_sub(1), col),
(row + 1, col),
(row, col.wrapping_sub(1)),
(row, col + 1),
]
.into_iter()
.any(|(r, c): (usize, usize)| -> bool {
self.cells
.get(r)
.and_then(|line: &Vec<CellWidget>| -> Option<&CellWidget> { line.get(c) })
.is_some_and(|cell: &CellWidget| -> bool {
if let Structures::Tunnel(tunnel) = cell.get_structure()
&& let Some(work) = tunnel.support_work()
{
self.support_worker((r, c), work.owner) == Some((row, col))
} else {
false
}
})
})
}
pub fn resolve_collapses(&mut self) -> usize {
let mut total: usize = 0;
loop {
self.recalculate_stress();
let mut collapsed: usize = 0;
for row in &mut self.cells {
for cell in row {
if matches!(cell.get_structure(), Structures::Tunnel(tunnel) if tunnel.get_stress() >= 100)
{
cell.set_structure(Structures::CollapsedTunnel(CollapsedTunnel::new()));
cell.get_mut_option_unit().take();
collapsed += 1;
}
}
}
total += collapsed;
if collapsed == 0 {
break;
}
}
if self.marked_cells.marking_cells {
let (row, col): (usize, usize) = self.marked_cells.selected_unit.get_coords();
if self.cells[row][col].get_ref_option_unit().is_none() {
self.marked_cells.marking_cells = false;
self.end_marking_cells(true);
}
}
total
}
}
+41 -41
View File
@@ -1,53 +1,53 @@
pub enum MoveFocusedCell {
Up,
Down,
Left,
Right,
Up,
Down,
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FocusedCell {
row: usize,
col: usize,
max_row: usize,
max_col: usize,
row: usize,
col: usize,
max_row: usize,
max_col: usize,
}
impl FocusedCell {
pub fn new(row: usize, col: usize, max_row: usize, max_col: usize) -> Self {
Self {
row,
col,
max_row,
max_col,
}
}
pub fn get_coords(&self) -> (usize, usize) {
(self.row, self.col)
}
pub fn move_focused_cell(&mut self, direction: MoveFocusedCell) -> (usize, usize) {
match direction {
MoveFocusedCell::Up => {
self.row = self.row.saturating_sub(1).max(0);
}
MoveFocusedCell::Down => {
self.row = self.row.saturating_add(1).min(self.max_row - 1);
}
MoveFocusedCell::Left => {
self.col = self.col.saturating_sub(1).max(0);
}
MoveFocusedCell::Right => {
self.col = self.col.saturating_add(1).min(self.max_col - 1);
}
pub fn new(row: usize, col: usize, max_row: usize, max_col: usize) -> Self {
Self {
row,
col,
max_row,
max_col,
}
}
(self.row, self.col)
}
pub fn get_coords(&self) -> (usize, usize) {
(self.row, self.col)
}
pub fn set_focused_cell(&mut self, cell: (usize, usize)) {
self.row = cell.0.max(0).min(self.max_row - 1);
self.col = cell.1.max(0).min(self.max_col - 1);
}
pub fn move_focused_cell(&mut self, direction: MoveFocusedCell) -> (usize, usize) {
match direction {
MoveFocusedCell::Up => {
self.row = self.row.saturating_sub(1).max(0);
}
MoveFocusedCell::Down => {
self.row = self.row.saturating_add(1).min(self.max_row - 1);
}
MoveFocusedCell::Left => {
self.col = self.col.saturating_sub(1).max(0);
}
MoveFocusedCell::Right => {
self.col = self.col.saturating_add(1).min(self.max_col - 1);
}
}
(self.row, self.col)
}
pub fn set_focused_cell(&mut self, cell: (usize, usize)) {
self.row = cell.0.max(0).min(self.max_row - 1);
self.col = cell.1.max(0).min(self.max_col - 1);
}
}
+2 -2
View File
@@ -2,6 +2,6 @@ use clap::ValueEnum;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
pub enum GameMode {
LastManStanding,
FrontLines,
LastManStanding,
FrontLines,
}
@@ -3,17 +3,17 @@ use std::collections::VecDeque;
#[derive(Debug, Clone, PartialEq)]
pub struct MarkedCells {
pub marking_cells: bool,
pub marked_cells: VecDeque<(usize, usize)>,
pub selected_unit: Option<Units>,
pub marking_cells: bool,
pub marked_cells: VecDeque<(usize, usize)>,
pub selected_unit: Option<Units>,
}
impl MarkedCells {
pub fn new() -> Self {
Self {
marking_cells: false,
marked_cells: VecDeque::new(),
selected_unit: None,
pub fn new() -> Self {
Self {
marking_cells: false,
marked_cells: VecDeque::new(),
selected_unit: None,
}
}
}
}
+4 -1
View File
@@ -1,18 +1,21 @@
mod board;
mod board_stress;
mod focused_cell;
mod game_mode;
mod marked_cells;
mod offset;
mod players;
pub mod resources;
pub mod structures;
pub mod tasks;
pub mod units;
pub mod zoom_level;
pub use board::BoardState;
pub use board::{BoardState, Excavation};
pub use focused_cell::{FocusedCell, MoveFocusedCell};
pub use game_mode::GameMode;
pub use marked_cells::MarkedCells;
pub use offset::Offset;
pub use players::Players;
pub use resources::Resources;
pub use zoom_level::{CellSizes, ZoomLevel};
+23 -23
View File
@@ -1,35 +1,35 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Offset {
value: usize,
max: usize,
value: usize,
max: usize,
}
impl Offset {
pub fn new(initial_value: Option<usize>, initial_max: Option<usize>) -> Self {
Self {
value: initial_value.unwrap_or(0).min(initial_max.unwrap_or(0)),
max: initial_max.unwrap_or(0),
pub fn new(initial_value: Option<usize>, initial_max: Option<usize>) -> Self {
Self {
value: initial_value.unwrap_or(0).min(initial_max.unwrap_or(0)),
max: initial_max.unwrap_or(0),
}
}
}
pub fn get_value(&self) -> usize {
self.value
}
pub fn get_value(&self) -> usize {
self.value
}
pub fn set_value(&mut self, value: usize) {
self.value = value.min(self.max);
}
pub fn set_value(&mut self, value: usize) {
self.value = value.min(self.max);
}
pub fn set_max(&mut self, max: usize) {
self.max = max;
self.value = self.value.min(max);
}
pub fn set_max(&mut self, max: usize) {
self.max = max;
self.value = self.value.min(max);
}
pub fn next(&mut self) {
self.value = self.value.saturating_add(1).min(self.max);
}
pub fn next(&mut self) {
self.value = self.value.saturating_add(1).min(self.max);
}
pub fn prev(&mut self) {
self.value = self.value.saturating_sub(1).max(0)
}
pub fn prev(&mut self) {
self.value = self.value.saturating_sub(1).max(0)
}
}
+23 -23
View File
@@ -1,37 +1,37 @@
use ratatui::{
style::{Color, Stylize},
text::Span,
style::{Color, Stylize},
text::Span,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Players {
Player,
Enemy,
Unclaimed,
Player,
Enemy,
Unclaimed,
}
impl Players {
pub fn get_text(&self) -> &'static str {
match self {
Self::Player => "Player",
Self::Enemy => "Enemy",
Self::Unclaimed => "Unclaimed",
pub fn get_text(&self) -> &'static str {
match self {
Self::Player => "Player",
Self::Enemy => "Enemy",
Self::Unclaimed => "Unclaimed",
}
}
}
pub fn get_color(&self) -> Color {
match self {
Self::Player => Color::LightBlue,
Self::Enemy => Color::LightRed,
Self::Unclaimed => Color::White,
pub fn get_color(&self) -> Color {
match self {
Self::Player => Color::Rgb(0, 0, 255),
Self::Enemy => Color::Rgb(255, 0, 0),
Self::Unclaimed => Color::Rgb(255, 255, 255),
}
}
}
pub fn get_span(&self) -> Span<'static> {
match self {
Self::Player => self.get_text().fg(self.get_color()),
Self::Enemy => self.get_text().fg(self.get_color()),
Self::Unclaimed => self.get_text().fg(self.get_color()),
pub fn get_span(&self) -> Span<'static> {
match self {
Self::Player => self.get_text().fg(self.get_color()),
Self::Enemy => self.get_text().fg(self.get_color()),
Self::Unclaimed => self.get_text().fg(self.get_color()),
}
}
}
}
@@ -0,0 +1,41 @@
use crate::app::threads::TICK_MS;
pub const WOOD_DELIVERY_AMOUNT: u32 = 5;
pub const WOOD_DELIVERY_INTERVAL_MS: u64 = 5000;
pub const WOOD_DELIVERY_TICKS: u64 = WOOD_DELIVERY_INTERVAL_MS.div_ceil(TICK_MS as u64);
pub const MINE_PRODUCTION_TICKS: u64 = 1000_u64.div_ceil(TICK_MS as u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Resources {
pub wood: u32,
pub metal: u32,
}
impl Resources {
pub fn can_afford(&self, cost: Self) -> bool {
self.wood >= cost.wood && self.metal >= cost.metal
}
pub fn spend(&mut self, cost: Self) -> bool {
if !self.can_afford(cost) {
return false;
}
self.wood -= cost.wood;
self.metal -= cost.metal;
true
}
pub fn new(wood: u16, metal: u16) -> Self {
Self {
wood: u32::from(wood),
metal: u32::from(metal),
}
}
pub fn add_wood(&mut self, amount: u32) -> () {
self.wood = self.wood.saturating_add(amount);
}
pub fn add_metal(&mut self, amount: u32) -> () {
self.metal = self.metal.saturating_add(amount);
}
}
+119 -40
View File
@@ -1,59 +1,138 @@
use crate::app::states::skirmish_states::{Players, structures::Structure};
use ratatui::style::Color;
use crate::app::threads::TICK_MS;
use ratatui::{style::Color, text::Line};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BaseBuilding {
durability: u16,
stress: u8,
owner: Players,
level: u8,
durability: u16,
stress: u8,
owner: Players,
level: u8,
queued_miners: u32,
recruitment_ticks: u16,
waiting_for_departure: bool,
}
impl BaseBuilding {
pub fn new(owner: Players) -> Self {
Self {
durability: 1500,
stress: 0,
owner,
level: 1,
pub const RECRUITMENT_TICKS: u16 = 10000_u16.div_ceil(TICK_MS as u16);
pub fn queued_miners(&self) -> u32 {
self.queued_miners
}
pub fn recruitment_progress(&self) -> u16 {
(u32::from(self.recruitment_ticks) * 100 / u32::from(Self::RECRUITMENT_TICKS)) as u16
}
pub fn can_queue_miner(&self) -> bool {
self.owner != Players::Unclaimed && self.queued_miners < u32::MAX
}
pub fn queue_miner(&mut self) -> bool {
if !self.can_queue_miner() {
return false;
}
self.queued_miners += 1;
true
}
pub fn advance_recruitment(&mut self, occupied: bool) -> bool {
if self.waiting_for_departure {
if occupied {
return false;
}
self.waiting_for_departure = false;
}
if self.queued_miners == 0 {
return false;
}
self.recruitment_ticks = self
.recruitment_ticks
.saturating_add(1)
.min(Self::RECRUITMENT_TICKS);
if self.recruitment_ticks < Self::RECRUITMENT_TICKS || occupied {
return false;
}
self.queued_miners -= 1;
self.recruitment_ticks = 0;
self.waiting_for_departure = true;
true
}
pub fn recruitment_status(&self, occupied: bool) -> &'static str {
if self.queued_miners == 0 {
"Queue empty"
} else if self.waiting_for_departure && occupied {
"Paused: waiting for Miner to leave base"
} else if self.recruitment_ticks == Self::RECRUITMENT_TICKS && occupied {
"Ready: waiting for free base cell"
} else {
"Recruiting"
}
}
pub fn new(owner: Players) -> Self {
Self {
durability: 1500,
stress: 0,
owner,
level: 1,
queued_miners: 0,
recruitment_ticks: 0,
waiting_for_departure: false,
}
}
}
}
impl Structure for BaseBuilding {
fn get_tag(&self) -> char {
'B'
}
fn get_color(&self) -> Color {
match self.owner {
Players::Player => Color::LightBlue,
Players::Enemy => Color::LightRed,
Players::Unclaimed => Color::LightGreen,
fn get_tag(&self) -> char {
'B'
}
}
fn get_level(&self) -> char {
(self.level + b'0') as char
}
fn get_color(&self) -> Color {
match self.owner {
Players::Player => Color::Rgb(0, 0, 255),
Players::Enemy => Color::Rgb(255, 0, 0),
Players::Unclaimed => Color::Rgb(0, 255, 0),
}
}
fn get_durability(&self) -> u16 {
self.durability
}
fn get_level(&self) -> char {
(self.level + b'0') as char
}
fn get_max_durability(&self) -> u16 {
1500
}
fn get_durability(&self) -> u16 {
self.durability
}
fn get_stress(&self) -> u8 {
self.stress
}
fn get_max_durability(&self) -> u16 {
1500
}
fn get_name(&self) -> &'static str {
"Base"
}
fn get_stress(&self) -> u8 {
self.stress
}
fn get_owner(&self) -> Players {
self.owner
}
fn get_name(&self) -> &'static str {
"Base"
}
fn get_owner(&self) -> Players {
self.owner
}
fn text(&self) -> Vec<Line<'_>> {
let mut lines: Vec<Line<'_>> = self.base_text();
lines.push(Line::from(format!(
"Recruitment queue: {}",
self.queued_miners
)));
if self.queued_miners > 0 {
lines.push(Line::from(format!(
"Miner progress: {}%",
self.recruitment_progress()
)));
}
lines
}
}
@@ -0,0 +1,49 @@
use crate::app::states::skirmish_states::{Players, structures::Structure};
use ratatui::style::Color;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CollapsedTunnel {
durability: u16,
}
impl Default for CollapsedTunnel {
fn default() -> Self {
Self::new()
}
}
impl CollapsedTunnel {
pub fn new() -> Self {
Self { durability: 750 }
}
pub fn take_damage(&mut self, damage: u16) -> () {
self.durability = self.durability.saturating_sub(damage);
}
}
impl Structure for CollapsedTunnel {
fn get_tag(&self) -> char {
'#'
}
fn get_color(&self) -> Color {
Color::Rgb(128, 128, 128)
}
fn get_level(&self) -> char {
' '
}
fn get_stress(&self) -> u8 {
100
}
fn get_durability(&self) -> u16 {
self.durability
}
fn get_max_durability(&self) -> u16 {
750
}
fn get_name(&self) -> &'static str {
"Collapsed tunnel"
}
fn get_owner(&self) -> Players {
Players::Unclaimed
}
}
@@ -0,0 +1,98 @@
use crate::app::states::skirmish_states::{
Players, Resources,
structures::{Ore, Structure},
units::{Unit, Units},
};
use ratatui::{
style::{Color, Stylize},
text::Line,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Mine {
owner: Players,
ore: Ore,
}
impl Mine {
pub const COST: Resources = Resources {
wood: 20,
metal: 10,
};
pub const METAL_PER_CYCLE: u16 = 1;
pub const STAFFED_METAL_PER_CYCLE: u16 = 2;
pub fn new(owner: Players, ore: Ore) -> Option<Self> {
if owner == Players::Unclaimed || ore.get_owner() != Players::Unclaimed {
return None;
}
Some(Self { owner, ore })
}
pub fn get_ore_amount(&self) -> u16 {
self.ore.get_ore_amount()
}
pub fn into_ore(self) -> Ore {
self.ore
}
pub fn produce(&mut self) -> u16 {
self.produce_with_miner(false)
}
pub fn accepts_unit(&self, unit: &Units) -> bool {
matches!(unit, Units::Miner(_)) && unit.get_owner() == self.owner
}
pub fn production_per_cycle(&self, staffed: bool) -> u16 {
let rate: u16 = if staffed {
Self::STAFFED_METAL_PER_CYCLE
} else {
Self::METAL_PER_CYCLE
};
rate.min(self.get_ore_amount())
}
pub fn produce_with_miner(&mut self, staffed: bool) -> u16 {
self.ore.extract(self.production_per_cycle(staffed))
}
}
impl Structure for Mine {
fn get_tag(&self) -> char {
'M'
}
fn get_color(&self) -> Color {
self.owner.get_color()
}
fn get_level(&self) -> char {
self.ore.get_level()
}
fn get_stress(&self) -> u8 {
self.ore.get_stress()
}
fn get_durability(&self) -> u16 {
self.ore.get_durability()
}
fn get_max_durability(&self) -> u16 {
self.ore.get_max_durability()
}
fn get_name(&self) -> &'static str {
"Mine"
}
fn get_owner(&self) -> Players {
self.owner
}
fn text(&self) -> Vec<Line<'_>> {
let mut lines: Vec<Line<'_>> = self.base_text();
lines.push(Line::from_iter([
"Ore: ".fg(Color::Rgb(192, 192, 192)),
self.get_ore_amount()
.to_string()
.fg(Color::Rgb(0, 128, 128)),
format!("/{}", self.ore.get_max_ore_amount()).fg(Color::Rgb(192, 192, 192)),
]));
lines
}
}
@@ -0,0 +1,75 @@
use crate::app::{
states::skirmish_states::{
Players,
structures::{Mine, Ore, Structure},
},
threads::TICK_MS,
};
use ratatui::{style::Color, text::Line};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MineConstruction {
mine: Mine,
ticks: u16,
}
impl MineConstruction {
pub const BUILD_TICKS: u16 = 10000_u16.div_ceil(TICK_MS as u16);
pub fn new(owner: Players, ore: Ore) -> Option<Self> {
Mine::new(owner, ore).map(|mine: Mine| -> Self { Self { mine, ticks: 0 } })
}
pub fn advance(&mut self) -> () {
self.ticks = self.ticks.saturating_add(1).min(Self::BUILD_TICKS);
}
pub fn is_finished(&self) -> bool {
self.ticks == Self::BUILD_TICKS
}
pub fn progress_percent(&self) -> u16 {
(u32::from(self.ticks) * 100 / u32::from(Self::BUILD_TICKS)) as u16
}
pub fn into_mine(self) -> Option<Mine> {
self.is_finished().then_some(self.mine)
}
pub fn cancel(self) -> Ore {
self.mine.into_ore()
}
}
impl Structure for MineConstruction {
fn get_tag(&self) -> char {
'C'
}
fn get_color(&self) -> Color {
self.mine.get_color()
}
fn get_level(&self) -> char {
self.mine.get_level()
}
fn get_stress(&self) -> u8 {
self.mine.get_stress()
}
fn get_durability(&self) -> u16 {
self.mine.get_durability()
}
fn get_max_durability(&self) -> u16 {
self.mine.get_max_durability()
}
fn get_name(&self) -> &'static str {
"Mine construction"
}
fn get_owner(&self) -> Players {
self.mine.get_owner()
}
fn text(&self) -> Vec<Line<'_>> {
vec![
Line::from(format!("Construction: {}%", self.progress_percent())),
Line::from(format!("Ore: {}", self.mine.get_ore_amount())),
]
}
}
@@ -1,4 +1,7 @@
mod base;
mod collapsed_tunnel;
mod mine;
mod mine_construction;
mod ore;
mod stone;
mod structure_trait;
@@ -6,8 +9,17 @@ mod structures_enum;
mod tunnel;
pub use base::BaseBuilding;
pub use collapsed_tunnel::CollapsedTunnel;
pub use mine::Mine;
pub use mine_construction::MineConstruction;
pub use ore::Ore;
pub use stone::Stone;
pub use structure_trait::Structure;
pub use structures_enum::Structures;
pub use tunnel::Tunnel;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuildingTerrain {
Stone,
Ore,
}
@@ -1,94 +1,104 @@
use crate::app::states::skirmish_states::{Players, structures::Structure};
use ratatui::{
style::{Color, Stylize},
text::Line,
style::{Color, Stylize},
text::Line,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Ore {
owner: Players,
ore_amount: u16,
durability: u16,
stress: u8,
level: u8,
owner: Players,
ore_amount: u16,
durability: u16,
stress: u8,
level: u8,
}
impl Ore {
pub fn new(owner: Players, level: u8) -> Self {
Self {
owner,
ore_amount: 5000,
durability: 1000,
stress: 0,
level,
pub fn new(owner: Players, level: u8) -> Self {
Self {
owner,
ore_amount: 5000,
durability: 1000,
stress: 0,
level,
}
}
}
pub fn get_ore_amount(&self) -> u16 {
self.ore_amount
}
pub fn get_ore_amount(&self) -> u16 {
self.ore_amount
}
pub fn get_max_ore_amount(&self) -> u16 {
5000
}
pub fn get_max_ore_amount(&self) -> u16 {
5000
}
pub fn set_owner(&mut self, new_owner: Players) -> &mut Self {
self.owner = new_owner;
self
}
pub fn extract(&mut self, amount: u16) -> u16 {
let extracted: u16 = amount.min(self.ore_amount);
self.ore_amount -= extracted;
extracted
}
pub fn set_owner(&mut self, new_owner: Players) -> &mut Self {
self.owner = new_owner;
self
}
}
impl Structure for Ore {
fn get_tag(&self) -> char {
'O'
}
fn get_color(&self) -> Color {
match self.owner {
Players::Player => self.owner.get_color(),
Players::Enemy => self.owner.get_color(),
Players::Unclaimed => Color::Indexed(217),
fn get_tag(&self) -> char {
'O'
}
}
fn get_durability(&self) -> u16 {
self.durability
}
fn get_level(&self) -> char {
if self.level != 0 {
(self.level + b'0') as char
} else {
' '
fn get_color(&self) -> Color {
match self.owner {
Players::Player => self.owner.get_color(),
Players::Enemy => self.owner.get_color(),
Players::Unclaimed => Color::Rgb(255, 175, 175),
}
}
}
fn get_max_durability(&self) -> u16 {
1000
}
fn get_durability(&self) -> u16 {
self.durability
}
fn get_name(&self) -> &'static str {
"Ore"
}
fn get_level(&self) -> char {
if self.level != 0 {
(self.level + b'0') as char
} else {
' '
}
}
fn get_owner(&self) -> Players {
self.owner
}
fn get_max_durability(&self) -> u16 {
1000
}
fn get_stress(&self) -> u8 {
self.stress
}
fn get_name(&self) -> &'static str {
"Ore"
}
fn text(&self) -> Vec<Line<'_>> {
let mut lines: Vec<Line<'_>> = Structure::base_text(self);
fn get_owner(&self) -> Players {
self.owner
}
lines.push(Line::from_iter([
"Ore: ".gray(),
self.get_ore_amount().to_string().cyan(),
"/".gray(),
self.get_max_ore_amount().to_string().cyan(),
]));
fn get_stress(&self) -> u8 {
self.stress
}
lines
}
fn text(&self) -> Vec<Line<'_>> {
let mut lines: Vec<Line<'_>> = Structure::base_text(self);
lines.push(Line::from_iter([
"Ore: ".fg(Color::Rgb(192, 192, 192)),
self.get_ore_amount()
.to_string()
.fg(Color::Rgb(0, 128, 128)),
"/".fg(Color::Rgb(192, 192, 192)),
self.get_max_ore_amount()
.to_string()
.fg(Color::Rgb(0, 128, 128)),
]));
lines
}
}
@@ -3,53 +3,53 @@ use ratatui::style::Color;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Stone {
durability: u16,
stress: u8,
durability: u16,
stress: u8,
}
impl Stone {
pub fn take_damage(&mut self, damage: u16) {
self.durability = self.durability.saturating_sub(damage);
}
pub fn take_damage(&mut self, damage: u16) {
self.durability = self.durability.saturating_sub(damage);
}
pub fn new() -> Self {
Self {
durability: 1000,
stress: 0,
pub fn new() -> Self {
Self {
durability: 1000,
stress: 0,
}
}
}
}
impl Structure for Stone {
fn get_tag(&self) -> char {
' '
}
fn get_tag(&self) -> char {
' '
}
fn get_color(&self) -> Color {
Color::White
}
fn get_color(&self) -> Color {
Color::Rgb(255, 255, 255)
}
fn get_level(&self) -> char {
' '
}
fn get_level(&self) -> char {
' '
}
fn get_durability(&self) -> u16 {
self.durability
}
fn get_durability(&self) -> u16 {
self.durability
}
fn get_max_durability(&self) -> u16 {
1000
}
fn get_max_durability(&self) -> u16 {
1000
}
fn get_stress(&self) -> u8 {
self.stress
}
fn get_stress(&self) -> u8 {
self.stress
}
fn get_name(&self) -> &'static str {
"Stone"
}
fn get_name(&self) -> &'static str {
"Stone"
}
fn get_owner(&self) -> Players {
Players::Unclaimed
}
fn get_owner(&self) -> Players {
Players::Unclaimed
}
}
@@ -1,57 +1,61 @@
use crate::app::states::skirmish_states::Players;
use ratatui::{
style::{Color, Stylize},
text::Line,
style::{Color, Stylize},
text::Line,
};
pub trait Structure {
fn get_tag(&self) -> char;
fn get_tag(&self) -> char;
fn get_color(&self) -> Color;
fn get_color(&self) -> Color;
fn get_level(&self) -> char;
fn get_level(&self) -> char;
fn get_stress(&self) -> u8;
fn get_stress(&self) -> u8;
fn get_durability(&self) -> u16;
fn get_durability(&self) -> u16;
fn get_max_durability(&self) -> u16;
fn get_max_durability(&self) -> u16;
fn get_name(&self) -> &'static str;
fn get_name(&self) -> &'static str;
fn get_owner(&self) -> Players;
fn get_owner(&self) -> Players;
fn base_text(&self) -> Vec<Line<'_>> {
let durability: u16 = self.get_durability();
let max_durability: u16 = self.get_max_durability();
let durability_percent: u32 = durability as u32 * 100 / max_durability as u32;
let stress: u8 = self.get_stress();
let level: char = self.get_level();
fn base_text(&self) -> Vec<Line<'_>> {
let durability: u16 = self.get_durability();
let max_durability: u16 = self.get_max_durability();
let durability_percent: u32 = durability as u32 * 100 / max_durability as u32;
let stress: u8 = self.get_stress();
let level: char = self.get_level();
let mut lines: Vec<Line<'_>> = vec![
Line::from_iter([
"Durability: ".gray(),
durability.to_string().cyan(),
"/".gray(),
max_durability.to_string().cyan(),
" ( ".gray(),
durability_percent.to_string().cyan(),
"% )".gray(),
]),
Line::from_iter(["Stress: ".gray(), stress.to_string().cyan(), "%".gray()]),
];
let mut lines: Vec<Line<'_>> = vec![
Line::from_iter([
"Durability: ".fg(Color::Rgb(192, 192, 192)),
durability.to_string().fg(Color::Rgb(0, 128, 128)),
"/".fg(Color::Rgb(192, 192, 192)),
max_durability.to_string().fg(Color::Rgb(0, 128, 128)),
" ( ".fg(Color::Rgb(192, 192, 192)),
durability_percent.to_string().fg(Color::Rgb(0, 128, 128)),
"% )".fg(Color::Rgb(192, 192, 192)),
]),
Line::from_iter([
"Stress: ".fg(Color::Rgb(192, 192, 192)),
stress.to_string().fg(Color::Rgb(0, 128, 128)),
"%".fg(Color::Rgb(192, 192, 192)),
]),
];
if level != ' ' {
lines.push(Line::from_iter([
"Level: ".gray(),
level.to_string().cyan(),
]));
if level != ' ' {
lines.push(Line::from_iter([
"Level: ".fg(Color::Rgb(192, 192, 192)),
level.to_string().fg(Color::Rgb(0, 128, 128)),
]));
}
lines
}
lines
}
fn text(&self) -> Vec<Line<'_>> {
self.base_text()
}
fn text(&self) -> Vec<Line<'_>> {
self.base_text()
}
}
@@ -1,78 +1,111 @@
use crate::app::states::skirmish_states::{
Players,
structures::{BaseBuilding, Ore, Stone, Structure, Tunnel},
Players,
structures::{
BaseBuilding, CollapsedTunnel, Mine, MineConstruction, Ore, Stone, Structure, Tunnel,
},
units::Units,
};
use ratatui::{
style::{Color, Stylize},
text::Line,
style::{Color, Stylize},
text::Line,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Structures {
Base(BaseBuilding),
Tunnel(Tunnel),
Stone(Stone),
Ore(Ore),
Base(BaseBuilding),
Tunnel(Tunnel),
Stone(Stone),
CollapsedTunnel(CollapsedTunnel),
Ore(Ore),
Mine(Mine),
MineConstruction(MineConstruction),
}
impl Structures {
#[inline]
fn s(&self) -> &dyn Structure {
match self {
Self::Base(b) => b,
Self::Tunnel(t) => t,
Self::Stone(s) => s,
Self::Ore(o) => o,
pub fn take_damage(&mut self, damage: u16) -> () {
match self {
Self::Stone(stone) => stone.take_damage(damage),
Self::CollapsedTunnel(rubble) => rubble.take_damage(damage),
_ => (),
}
}
pub fn can_enter(&self, unit: &Units) -> bool {
match self {
Self::Base(_) | Self::Tunnel(_) => true,
Self::Mine(mine) => mine.accepts_unit(unit),
_ => false,
}
}
pub fn build_mine(&mut self, owner: Players) -> bool {
if let Self::Ore(ore) = *self
&& let Some(mine) = Mine::new(owner, ore)
{
*self = Self::Mine(mine);
return true;
}
false
}
#[inline]
fn s(&self) -> &dyn Structure {
match self {
Self::Base(b) => b,
Self::Tunnel(t) => t,
Self::Stone(s) => s,
Self::CollapsedTunnel(rubble) => rubble,
Self::Ore(o) => o,
Self::Mine(mine) => mine,
Self::MineConstruction(site) => site,
}
}
}
}
impl Structure for Structures {
fn get_color(&self) -> Color {
self.s().get_color()
}
fn get_tag(&self) -> char {
self.s().get_tag()
}
fn get_level(&self) -> char {
self.s().get_level()
}
fn get_name(&self) -> &'static str {
self.s().get_name()
}
fn get_durability(&self) -> u16 {
self.s().get_durability()
}
fn get_max_durability(&self) -> u16 {
self.s().get_max_durability()
}
fn get_stress(&self) -> u8 {
self.s().get_stress()
}
fn get_owner(&self) -> Players {
self.s().get_owner()
}
fn text(&self) -> Vec<Line<'_>> {
match self.s().get_owner() {
Players::Unclaimed => self.s().text(),
Players::Player | Players::Enemy => {
let mut lines: Vec<Line<'_>> = self.s().text();
lines.push(Line::from_iter([
"Owner: ".gray(),
self.s().get_owner().get_span(),
]));
lines
}
fn get_color(&self) -> Color {
self.s().get_color()
}
fn get_tag(&self) -> char {
self.s().get_tag()
}
fn get_level(&self) -> char {
self.s().get_level()
}
fn get_name(&self) -> &'static str {
self.s().get_name()
}
fn get_durability(&self) -> u16 {
self.s().get_durability()
}
fn get_max_durability(&self) -> u16 {
self.s().get_max_durability()
}
fn get_stress(&self) -> u8 {
self.s().get_stress()
}
fn get_owner(&self) -> Players {
self.s().get_owner()
}
fn text(&self) -> Vec<Line<'_>> {
match self.s().get_owner() {
Players::Unclaimed => self.s().text(),
Players::Player | Players::Enemy => {
let mut lines: Vec<Line<'_>> = self.s().text();
lines.push(Line::from_iter([
"Owner: ".fg(Color::Rgb(192, 192, 192)),
self.s().get_owner().get_span(),
]));
lines
}
}
}
}
}
@@ -1,57 +1,140 @@
use crate::app::states::skirmish_states::{Players, structures::Structure};
use ratatui::style::Color;
use crate::app::states::skirmish_states::{Players, Resources, structures::Structure};
use crate::app::threads::TICK_MS;
use ratatui::{
style::{Color, Stylize},
text::Line,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SupportWork {
pub owner: Players,
pub removing: bool,
ticks: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Tunnel {
durability: u16,
stress: u8,
roof_support: bool,
rail: bool,
lamp: bool,
durability: u16,
stress: u8,
roof_support: bool,
support_work: Option<SupportWork>,
rail: bool,
lamp: bool,
}
impl Tunnel {
pub fn new() -> Self {
Self {
durability: 500,
stress: 25,
roof_support: false,
rail: false,
lamp: false,
pub const SUPPORT_WORK_TICKS: u16 = 3000_u16.div_ceil(TICK_MS as u16);
pub const SUPPORT_COST: Resources = Resources { wood: 10, metal: 2 };
pub fn has_support(&self) -> bool {
self.roof_support
}
pub fn support_work(&self) -> Option<SupportWork> {
self.support_work
}
pub fn start_support_work(&mut self, owner: Players, removing: bool) -> bool {
if owner == Players::Unclaimed
|| self.support_work.is_some()
|| self.roof_support != removing
{
return false;
}
self.support_work = Some(SupportWork {
owner,
removing,
ticks: 0,
});
true
}
pub fn support_progress(&self) -> Option<u16> {
self.support_work
.map(|work: SupportWork| -> u16 { work.ticks * 100 / Self::SUPPORT_WORK_TICKS })
}
pub fn advance_support_work(&mut self) -> Option<bool> {
let work: &mut SupportWork = self.support_work.as_mut()?;
work.ticks += 1;
if work.ticks < Self::SUPPORT_WORK_TICKS {
return None;
}
let removing: bool = work.removing;
self.roof_support = !removing;
self.support_work = None;
Some(removing)
}
pub fn set_support(&mut self, supported: bool) -> () {
self.roof_support = supported;
}
pub fn set_stress(&mut self, stress: u8) -> () {
self.stress = stress.min(100);
}
pub fn new() -> Self {
Self {
durability: 500,
stress: 20,
roof_support: false,
support_work: None,
rail: false,
lamp: false,
}
}
}
}
impl Structure for Tunnel {
fn get_tag(&self) -> char {
'T'
}
fn text(&self) -> Vec<Line<'_>> {
let mut lines: Vec<Line<'_>> = self.base_text();
if let Some(work) = self.support_work {
lines.push(
Line::from(format!(
"{} support: {}%",
if work.removing {
"Removing"
} else {
"Building"
},
self.support_progress().unwrap_or(0)
))
.fg(Color::Rgb(0, 128, 128)),
);
}
lines
}
fn get_color(&self) -> Color {
Color::Gray
}
fn get_tag(&self) -> char {
'T'
}
fn get_level(&self) -> char {
' '
}
fn get_color(&self) -> Color {
Color::Rgb(192, 192, 192)
}
fn get_durability(&self) -> u16 {
self.durability
}
fn get_level(&self) -> char {
if self.roof_support { 'T' } else { ' ' }
}
fn get_max_durability(&self) -> u16 {
500
}
fn get_durability(&self) -> u16 {
self.durability
}
fn get_stress(&self) -> u8 {
self.stress
}
fn get_max_durability(&self) -> u16 {
500
}
fn get_name(&self) -> &'static str {
"Tunnel"
}
fn get_stress(&self) -> u8 {
self.stress
}
fn get_owner(&self) -> Players {
Players::Unclaimed
}
fn get_name(&self) -> &'static str {
"Tunnel"
}
fn get_owner(&self) -> Players {
Players::Unclaimed
}
}
+60 -50
View File
@@ -3,71 +3,81 @@ use std::collections::VecDeque;
#[derive(Debug, Clone, PartialEq)]
pub struct DiggingTask {
path: VecDeque<(usize, usize)>,
progress: f32,
tick_increase: f32,
total_steps: usize,
path: VecDeque<(usize, usize)>,
progress: f32,
tick_increase: f32,
total_steps: usize,
auto_supports: bool,
}
impl DiggingTask {
pub fn new(path: VecDeque<(usize, usize)>, tick_increase: f32) -> Self {
Self {
total_steps: path.len(),
path,
progress: 0.0,
tick_increase,
pub fn new(path: VecDeque<(usize, usize)>, tick_increase: f32) -> Self {
Self {
total_steps: path.len(),
auto_supports: true,
path,
progress: 0.0,
tick_increase,
}
}
}
pub fn pop_front(&mut self) {
self.path.pop_front();
self.progress = 0.0;
}
pub fn from_moving(task: MovingTask, tick_increase: f32) -> Self {
Self {
path: task.get_path().clone(),
progress: 0.0,
tick_increase,
total_steps: task.get_total_steps(),
pub fn pop_front(&mut self) {
self.path.pop_front();
self.progress = 0.0;
}
}
pub fn current_target(&self) -> Option<(usize, usize)> {
self.path.front().copied()
}
pub fn from_moving(task: MovingTask, tick_increase: f32) -> Self {
Self {
path: task.get_path().clone(),
progress: 0.0,
tick_increase,
total_steps: task.get_total_steps(),
auto_supports: task.auto_supports(),
}
}
pub fn current_target(&self) -> Option<(usize, usize)> {
self.path.front().copied()
}
}
impl Task for DiggingTask {
fn get_icon(&self) -> char {
'⛏'
}
fn auto_supports(&self) -> bool {
self.auto_supports
}
fn set_auto_supports(&mut self, enabled: bool) -> () {
self.auto_supports = enabled;
}
fn get_path(&self) -> &VecDeque<(usize, usize)> {
&self.path
}
fn get_icon(&self) -> char {
'⛏'
}
fn get_path_front_coords(&self) -> &(usize, usize) {
self.path.front().map_or(&(0, 0), |c| c)
}
fn get_path(&self) -> &VecDeque<(usize, usize)> {
&self.path
}
fn get_path_back_coords(&self) -> &(usize, usize) {
self.path.back().map_or(&(0, 0), |c| c)
}
fn get_path_front_coords(&self) -> &(usize, usize) {
self.path.front().map_or(&(0, 0), |c| c)
}
fn get_progress(&self) -> f32 {
self.progress
}
fn get_path_back_coords(&self) -> &(usize, usize) {
self.path.back().map_or(&(0, 0), |c| c)
}
fn get_total_steps(&self) -> usize {
self.total_steps
}
fn get_progress(&self) -> f32 {
self.progress
}
fn progress(&mut self) {
self.progress = (self.progress + self.tick_increase).clamp(0.0, 100.0);
}
fn get_total_steps(&self) -> usize {
self.total_steps
}
fn is_finished(&self) -> bool {
self.progress >= 100.0
}
fn progress(&mut self) {
self.progress = (self.progress + self.tick_increase).clamp(0.0, 100.0);
}
fn is_finished(&self) -> bool {
self.progress >= 100.0
}
}
+61 -48
View File
@@ -1,6 +1,6 @@
use crate::app::{
states::skirmish_states::tasks::{DiggingTask, Task},
threads::TICK_MS,
states::skirmish_states::tasks::{DiggingTask, Task},
threads::TICK_MS,
};
use std::collections::VecDeque;
@@ -8,68 +8,81 @@ const MOVEMENT_TICKS: u16 = 3000_u16.div_ceil(TICK_MS as u16);
#[derive(Debug, Clone, PartialEq)]
pub struct MovingTask {
path: VecDeque<(usize, usize)>,
ticks: u16,
total_steps: usize,
path: VecDeque<(usize, usize)>,
ticks: u16,
total_steps: usize,
auto_supports: bool,
pub dig_if_blocked: bool,
}
impl MovingTask {
pub fn new(path: VecDeque<(usize, usize)>) -> Self {
Self {
total_steps: path.len(),
path,
ticks: 0,
pub fn new(path: VecDeque<(usize, usize)>) -> Self {
Self {
total_steps: path.len(),
auto_supports: true,
dig_if_blocked: true,
path,
ticks: 0,
}
}
}
pub fn from_digging(task: DiggingTask) -> Self {
Self {
total_steps: task.get_total_steps(),
path: task.get_path().clone(),
ticks: 0,
pub fn from_digging(task: DiggingTask) -> Self {
Self {
total_steps: task.get_total_steps(),
auto_supports: task.auto_supports(),
dig_if_blocked: true,
path: task.get_path().clone(),
ticks: 0,
}
}
}
pub fn pop_front(&mut self) {
self.path.pop_front();
self.ticks = 0;
}
pub fn pop_front(&mut self) {
self.path.pop_front();
self.ticks = 0;
}
pub fn get_movement_progress(&self) -> Option<u16> {
(self.ticks > 0).then(|| self.ticks * 100 / MOVEMENT_TICKS)
}
pub fn get_movement_progress(&self) -> Option<u16> {
(self.ticks > 0).then(|| self.ticks * 100 / MOVEMENT_TICKS)
}
}
impl Task for MovingTask {
fn get_icon(&self) -> char {
'→'
}
fn auto_supports(&self) -> bool {
self.auto_supports
}
fn set_auto_supports(&mut self, enabled: bool) -> () {
self.auto_supports = enabled;
}
fn get_path(&self) -> &VecDeque<(usize, usize)> {
&self.path
}
fn get_icon(&self) -> char {
'→'
}
fn get_path_front_coords(&self) -> &(usize, usize) {
self.path.front().unwrap_or(&(0, 0))
}
fn get_path(&self) -> &VecDeque<(usize, usize)> {
&self.path
}
fn get_path_back_coords(&self) -> &(usize, usize) {
self.path.back().unwrap_or(&(0, 0))
}
fn get_path_front_coords(&self) -> &(usize, usize) {
self.path.front().unwrap_or(&(0, 0))
}
fn get_progress(&self) -> f32 {
f32::from(self.ticks) * 100.0 / f32::from(MOVEMENT_TICKS)
}
fn get_path_back_coords(&self) -> &(usize, usize) {
self.path.back().unwrap_or(&(0, 0))
}
fn get_total_steps(&self) -> usize {
self.total_steps
}
fn get_progress(&self) -> f32 {
f32::from(self.ticks) * 100.0 / f32::from(MOVEMENT_TICKS)
}
fn progress(&mut self) {
self.ticks = (self.ticks + 1).min(MOVEMENT_TICKS);
}
fn get_total_steps(&self) -> usize {
self.total_steps
}
fn is_finished(&self) -> bool {
self.ticks == MOVEMENT_TICKS
}
fn progress(&mut self) {
self.ticks = (self.ticks + 1).min(MOVEMENT_TICKS);
}
fn is_finished(&self) -> bool {
self.ticks == MOVEMENT_TICKS
}
}
@@ -1,28 +1,32 @@
use std::collections::VecDeque;
pub trait Task {
fn get_icon(&self) -> char;
fn auto_supports(&self) -> bool;
fn get_path(&self) -> &VecDeque<(usize, usize)>;
fn set_auto_supports(&mut self, enabled: bool) -> ();
fn get_path_front_coords(&self) -> &(usize, usize);
fn get_icon(&self) -> char;
fn get_path_back_coords(&self) -> &(usize, usize);
fn get_path(&self) -> &VecDeque<(usize, usize)>;
fn get_progress(&self) -> f32;
fn get_path_front_coords(&self) -> &(usize, usize);
fn get_total_steps(&self) -> usize;
fn get_path_back_coords(&self) -> &(usize, usize);
fn get_total_progress(&self) -> f32 {
let total: usize = self.get_total_steps();
if total == 0 {
return 100.0;
fn get_progress(&self) -> f32;
fn get_total_steps(&self) -> usize;
fn get_total_progress(&self) -> f32 {
let total: usize = self.get_total_steps();
if total == 0 {
return 100.0;
}
let completed: usize = total.saturating_sub(self.get_path().len());
((completed as f32 * 100.0 + self.get_progress()) / total as f32).clamp(0.0, 100.0)
}
let completed: usize = total.saturating_sub(self.get_path().len());
((completed as f32 * 100.0 + self.get_progress()) / total as f32).clamp(0.0, 100.0)
}
fn progress(&mut self);
fn progress(&mut self);
fn is_finished(&self) -> bool;
fn is_finished(&self) -> bool;
}
@@ -3,58 +3,66 @@ use std::collections::VecDeque;
#[derive(Debug, Clone, PartialEq)]
pub enum Tasks {
Digging(DiggingTask),
Moving(MovingTask),
Digging(DiggingTask),
Moving(MovingTask),
}
impl Tasks {
#[inline]
fn t(&self) -> &dyn Task {
match self {
Self::Digging(d) => d,
Self::Moving(m) => m,
#[inline]
fn t(&self) -> &dyn Task {
match self {
Self::Digging(d) => d,
Self::Moving(m) => m,
}
}
}
#[inline]
fn t_mut(&mut self) -> &mut dyn Task {
match self {
Self::Digging(d) => d,
Self::Moving(m) => m,
#[inline]
fn t_mut(&mut self) -> &mut dyn Task {
match self {
Self::Digging(d) => d,
Self::Moving(m) => m,
}
}
}
}
impl Task for Tasks {
fn get_icon(&self) -> char {
self.t().get_icon()
}
fn auto_supports(&self) -> bool {
self.t().auto_supports()
}
fn get_path(&self) -> &VecDeque<(usize, usize)> {
self.t().get_path()
}
fn set_auto_supports(&mut self, enabled: bool) -> () {
self.t_mut().set_auto_supports(enabled);
}
fn get_path_front_coords(&self) -> &(usize, usize) {
self.t().get_path_front_coords()
}
fn get_icon(&self) -> char {
self.t().get_icon()
}
fn get_path_back_coords(&self) -> &(usize, usize) {
self.t().get_path_back_coords()
}
fn get_path(&self) -> &VecDeque<(usize, usize)> {
self.t().get_path()
}
fn get_progress(&self) -> f32 {
self.t().get_progress()
}
fn get_path_front_coords(&self) -> &(usize, usize) {
self.t().get_path_front_coords()
}
fn get_total_steps(&self) -> usize {
self.t().get_total_steps()
}
fn get_path_back_coords(&self) -> &(usize, usize) {
self.t().get_path_back_coords()
}
fn is_finished(&self) -> bool {
self.t().is_finished()
}
fn get_progress(&self) -> f32 {
self.t().get_progress()
}
fn progress(&mut self) {
self.t_mut().progress();
}
fn get_total_steps(&self) -> usize {
self.t().get_total_steps()
}
fn is_finished(&self) -> bool {
self.t().is_finished()
}
fn progress(&mut self) {
self.t_mut().progress();
}
}
+108 -96
View File
@@ -1,125 +1,137 @@
use ratatui::{style::Stylize, text::Line};
use ratatui::{
style::{Color, Stylize},
text::Line,
};
use crate::app::states::skirmish_states::{
Players,
tasks::{Task, Tasks},
units::Unit,
Players, Resources,
tasks::{Task, Tasks},
units::Unit,
};
use std::collections::VecDeque;
#[derive(Debug, Clone, PartialEq)]
pub struct MinerUnit {
owner: Players,
hp: u16,
can_dig: bool,
digging_power: f32,
coords: (usize, usize),
tasks: VecDeque<Tasks>,
owner: Players,
hp: u16,
can_dig: bool,
digging_power: f32,
coords: (usize, usize),
tasks: VecDeque<Tasks>,
}
impl MinerUnit {
pub fn new(owner: Players, coords: (usize, usize)) -> Self {
Self {
owner,
hp: 100,
can_dig: true,
digging_power: 0.55,
coords,
tasks: VecDeque::new(),
}
}
fn col_to_letters(&self, col: usize) -> String {
let mut col: usize = col + 1;
let mut letters: Vec<char> = Vec::new();
while col > 0 {
letters.push((b'A' + ((col - 1) % 26) as u8) as char);
col = (col - 1) / 26;
pub const RECRUIT_COST: Resources = Resources {
wood: 10,
metal: 15,
};
pub fn new(owner: Players, coords: (usize, usize)) -> Self {
Self {
owner,
hp: 100,
can_dig: true,
digging_power: 0.55,
coords,
tasks: VecDeque::new(),
}
}
letters.iter().rev().collect()
}
fn col_to_letters(&self, col: usize) -> String {
let mut col: usize = col + 1;
let mut letters: Vec<char> = Vec::new();
fn display_coords(&self, coords: &(usize, usize)) -> String {
format!("{}{}", self.col_to_letters(coords.0), coords.1)
}
while col > 0 {
letters.push((b'A' + ((col - 1) % 26) as u8) as char);
col = (col - 1) / 26;
}
letters.iter().rev().collect()
}
fn display_coords(&self, coords: &(usize, usize)) -> String {
format!("{}{}", self.col_to_letters(coords.0), coords.1)
}
}
impl Unit for MinerUnit {
fn get_owner(&self) -> Players {
self.owner
}
fn get_owner(&self) -> Players {
self.owner
}
fn get_tag(&self) -> char {
'M'
}
fn get_tag(&self) -> char {
'M'
}
fn get_name(&self) -> &'static str {
"Miner"
}
fn get_name(&self) -> &'static str {
"Miner"
}
fn get_hp(&self) -> u16 {
self.hp
}
fn get_hp(&self) -> u16 {
self.hp
}
fn get_max_hp(&self) -> u16 {
100
}
fn get_max_hp(&self) -> u16 {
100
}
fn get_can_dig(&self) -> bool {
self.can_dig
}
fn get_can_dig(&self) -> bool {
self.can_dig
}
fn get_digging_power(&self) -> f32 {
self.digging_power
}
fn get_can_build(&self) -> bool {
true
}
fn get_coords(&self) -> (usize, usize) {
self.coords
}
fn get_digging_power(&self) -> f32 {
self.digging_power
}
fn set_coords(&mut self, coords: (usize, usize)) {
self.coords = coords;
}
fn get_coords(&self) -> (usize, usize) {
self.coords
}
fn get_ref_tasks(&self) -> &VecDeque<Tasks> {
&self.tasks
}
fn set_coords(&mut self, coords: (usize, usize)) {
self.coords = coords;
}
fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks> {
&mut self.tasks
}
fn get_ref_tasks(&self) -> &VecDeque<Tasks> {
&self.tasks
}
fn get_tasks_formatted(&self) -> Vec<Line<'_>> {
Vec::from(
self
.tasks
.iter()
.enumerate()
.flat_map(|(i, task): (usize, &Tasks)| -> [Line<'_>; 2] {
[
Line::from_iter([
format!("{}. ", i + 1).gray(),
task.get_icon().gray(),
" ".gray(),
self.display_coords(task.get_path_front_coords()).green(),
" -> ".gray(),
self.display_coords(task.get_path_back_coords()).yellow(),
]),
Line::from(format!(
" Step: {}% | Total: {}%",
task.get_progress() as u16,
task.get_total_progress() as u16,
))
.cyan(),
]
})
.collect::<Vec<Line<'_>>>(),
)
}
fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks> {
&mut self.tasks
}
fn set_task(&mut self, task: Tasks) {
self.tasks.push_back(task);
}
fn get_tasks_formatted(&self) -> Vec<Line<'_>> {
Vec::from(
self.tasks
.iter()
.enumerate()
.flat_map(|(i, task): (usize, &Tasks)| -> [Line<'_>; 2] {
[
Line::from_iter([
format!("{}. ", i + 1).fg(Color::Rgb(192, 192, 192)),
task.get_icon().fg(Color::Rgb(192, 192, 192)),
" ".fg(Color::Rgb(192, 192, 192)),
self.display_coords(task.get_path_front_coords())
.fg(Color::Rgb(0, 128, 0)),
" -> ".fg(Color::Rgb(192, 192, 192)),
self.display_coords(task.get_path_back_coords())
.fg(Color::Rgb(128, 128, 0)),
]),
Line::from(format!(
" Step: {}% | Total: {}%",
task.get_progress() as u16,
task.get_total_progress() as u16,
))
.fg(Color::Rgb(0, 128, 128)),
]
})
.collect::<Vec<Line<'_>>>(),
)
}
fn set_task(&mut self, task: Tasks) {
self.tasks.push_back(task);
}
}
@@ -1,82 +1,90 @@
use crate::app::states::skirmish_states::{Players, tasks::Tasks};
use ratatui::{
style::Stylize,
text::{Line, Span},
style::{Color, Stylize},
text::{Line, Span},
};
use std::collections::VecDeque;
pub trait Unit {
fn is_unit(&self) -> bool {
true
}
fn get_owner(&self) -> Players;
fn get_tag(&self) -> char;
fn get_name(&self) -> &'static str;
fn get_hp(&self) -> u16;
fn get_max_hp(&self) -> u16;
fn get_can_dig(&self) -> bool;
fn get_digging_power(&self) -> f32;
fn get_coords(&self) -> (usize, usize);
fn set_coords(&mut self, coords: (usize, usize));
fn get_ref_tasks(&self) -> &VecDeque<Tasks>;
fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks>;
fn set_task(&mut self, task: Tasks);
fn get_tasks_formatted(&self) -> Vec<Line<'_>>;
fn base_text(&self) -> Vec<Line<'_>> {
let owner: Span<'_> = self.get_owner().get_span();
let hp: u16 = self.get_hp();
let max_hp: u16 = self.get_max_hp();
let hp_percent: u16 = hp * 100 / max_hp;
let can_dig: bool = self.get_can_dig();
let digging_power: f32 = self.get_digging_power();
let have_any_tasks: bool = self.get_ref_tasks().is_empty();
let tasks: Vec<Line<'_>> = self.get_tasks_formatted();
let mut lines: Vec<Line<'_>> = Vec::from([
Line::from_iter(["Owner: ".gray(), owner]),
Line::from_iter([
"HP: ".gray(),
hp.green(),
"/".gray(),
max_hp.green(),
" ( ".gray(),
hp_percent.green(),
"% )".gray(),
]),
]);
if can_dig {
lines.push(Line::from_iter([
"Digging power: ".gray(),
digging_power.green(),
]));
fn is_unit(&self) -> bool {
true
}
if !have_any_tasks {
lines.push(Line::from("Tasks Queue: ".gray()));
for task in tasks {
lines.push(task);
}
fn get_owner(&self) -> Players;
fn get_tag(&self) -> char;
fn get_name(&self) -> &'static str;
fn get_hp(&self) -> u16;
fn get_max_hp(&self) -> u16;
fn get_can_dig(&self) -> bool;
fn get_can_build(&self) -> bool {
false
}
lines
}
fn get_digging_power(&self) -> f32;
fn text(&self) -> Vec<Line<'_>> {
self.base_text()
}
fn get_coords(&self) -> (usize, usize);
fn set_coords(&mut self, coords: (usize, usize));
fn get_ref_tasks(&self) -> &VecDeque<Tasks>;
fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks>;
fn set_task(&mut self, task: Tasks);
fn get_tasks_formatted(&self) -> Vec<Line<'_>>;
fn base_text(&self) -> Vec<Line<'_>> {
let owner: Span<'_> = self.get_owner().get_span();
let hp: u16 = self.get_hp();
let max_hp: u16 = self.get_max_hp();
let hp_percent: u16 = hp * 100 / max_hp;
let can_dig: bool = self.get_can_dig();
let digging_power: f32 = self.get_digging_power();
let have_any_tasks: bool = self.get_ref_tasks().is_empty();
let tasks: Vec<Line<'_>> = self.get_tasks_formatted();
let mut lines: Vec<Line<'_>> = Vec::from([
Line::from_iter(["Owner: ".fg(Color::Rgb(192, 192, 192)), owner]),
Line::from_iter([
"HP: ".fg(Color::Rgb(192, 192, 192)),
hp.fg(Color::Rgb(0, 128, 0)),
"/".fg(Color::Rgb(192, 192, 192)),
max_hp.fg(Color::Rgb(0, 128, 0)),
" ( ".fg(Color::Rgb(192, 192, 192)),
hp_percent.fg(Color::Rgb(0, 128, 0)),
"% )".fg(Color::Rgb(192, 192, 192)),
]),
]);
if can_dig {
lines.push(Line::from_iter([
"Digging power: ".fg(Color::Rgb(192, 192, 192)),
digging_power.fg(Color::Rgb(0, 128, 0)),
]));
}
if self.get_can_build() {
lines.push(Line::from("Can build: Yes").fg(Color::Rgb(0, 128, 0)));
}
if !have_any_tasks {
lines.push(Line::from("Tasks Queue: ".fg(Color::Rgb(192, 192, 192))));
for task in tasks {
lines.push(task);
}
}
lines
}
fn text(&self) -> Vec<Line<'_>> {
self.base_text()
}
}
+117 -113
View File
@@ -1,150 +1,154 @@
use crate::app::states::skirmish_states::{
Players,
tasks::Tasks,
units::{MinerUnit, Unit},
Players,
tasks::Tasks,
units::{MinerUnit, Unit},
};
use ratatui::text::Line;
use std::collections::VecDeque;
#[derive(Debug, Clone, PartialEq)]
pub enum Units {
Miner(MinerUnit),
Miner(MinerUnit),
}
impl Units {
#[inline]
pub fn u(&self) -> &dyn Unit {
match self {
Self::Miner(m) => m,
#[inline]
pub fn u(&self) -> &dyn Unit {
match self {
Self::Miner(m) => m,
}
}
}
#[inline]
pub fn u_mut(&mut self) -> &mut dyn Unit {
match self {
Self::Miner(m) => m,
#[inline]
pub fn u_mut(&mut self) -> &mut dyn Unit {
match self {
Self::Miner(m) => m,
}
}
}
}
impl Unit for Units {
fn get_tag(&self) -> char {
self.u().get_tag()
}
fn get_can_build(&self) -> bool {
self.u().get_can_build()
}
fn get_tag(&self) -> char {
self.u().get_tag()
}
fn get_name(&self) -> &'static str {
self.u().get_name()
}
fn get_name(&self) -> &'static str {
self.u().get_name()
}
fn get_owner(&self) -> Players {
self.u().get_owner()
}
fn get_owner(&self) -> Players {
self.u().get_owner()
}
fn get_hp(&self) -> u16 {
self.u().get_hp()
}
fn get_hp(&self) -> u16 {
self.u().get_hp()
}
fn get_max_hp(&self) -> u16 {
self.u().get_max_hp()
}
fn get_max_hp(&self) -> u16 {
self.u().get_max_hp()
}
fn get_can_dig(&self) -> bool {
self.u().get_can_dig()
}
fn get_can_dig(&self) -> bool {
self.u().get_can_dig()
}
fn get_digging_power(&self) -> f32 {
self.u().get_digging_power()
}
fn get_digging_power(&self) -> f32 {
self.u().get_digging_power()
}
fn get_coords(&self) -> (usize, usize) {
self.u().get_coords()
}
fn get_coords(&self) -> (usize, usize) {
self.u().get_coords()
}
fn set_coords(&mut self, coords: (usize, usize)) {
self.u_mut().set_coords(coords);
}
fn set_coords(&mut self, coords: (usize, usize)) {
self.u_mut().set_coords(coords);
}
fn get_ref_tasks(&self) -> &VecDeque<Tasks> {
self.u().get_ref_tasks()
}
fn get_ref_tasks(&self) -> &VecDeque<Tasks> {
self.u().get_ref_tasks()
}
fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks> {
self.u_mut().get_mut_tasks()
}
fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks> {
self.u_mut().get_mut_tasks()
}
fn get_tasks_formatted(&self) -> Vec<Line<'_>> {
self.u().get_tasks_formatted()
}
fn get_tasks_formatted(&self) -> Vec<Line<'_>> {
self.u().get_tasks_formatted()
}
fn set_task(&mut self, task: Tasks) {
self.u_mut().set_task(task);
}
fn set_task(&mut self, task: Tasks) {
self.u_mut().set_task(task);
}
}
impl Unit for Option<Units> {
fn is_unit(&self) -> bool {
self.as_ref().map_or(false, |u| u.is_unit())
}
fn get_tag(&self) -> char {
self.as_ref().map_or(' ', |u| u.get_tag())
}
fn get_name(&self) -> &'static str {
self.as_ref().map_or("", |u| u.get_name())
}
fn get_owner(&self) -> Players {
self.as_ref().map_or(Players::Unclaimed, |u| u.get_owner())
}
fn get_hp(&self) -> u16 {
self.as_ref().map_or(0, |u| u.get_hp())
}
fn get_max_hp(&self) -> u16 {
self.as_ref().map_or(0, |u| u.get_max_hp())
}
fn get_can_dig(&self) -> bool {
self.as_ref().map_or(false, |u| u.get_can_dig())
}
fn get_digging_power(&self) -> f32 {
self.as_ref().map_or(0.0, |u| u.get_digging_power())
}
fn get_coords(&self) -> (usize, usize) {
self.as_ref().map_or((0, 0), |u| u.get_coords())
}
fn set_coords(&mut self, coords: (usize, usize)) {
if let Some(unit) = self.as_mut() {
unit.set_coords(coords);
fn get_can_build(&self) -> bool {
self.as_ref()
.is_some_and(|unit: &Units| -> bool { unit.get_can_build() })
}
fn is_unit(&self) -> bool {
self.as_ref().map_or(false, |u| u.is_unit())
}
}
fn get_ref_tasks(&self) -> &VecDeque<Tasks> {
self
.as_ref()
.expect("attempted to get referenced tasks from `None`")
.get_ref_tasks()
}
fn get_tag(&self) -> char {
self.as_ref().map_or(' ', |u| u.get_tag())
}
fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks> {
self
.as_mut()
.expect("attempted to get mutable tasks from `None`")
.get_mut_tasks()
}
fn get_name(&self) -> &'static str {
self.as_ref().map_or("", |u| u.get_name())
}
fn get_tasks_formatted(&self) -> Vec<Line<'_>> {
self
.as_ref()
.map_or(Vec::new(), |u| u.get_tasks_formatted())
}
fn get_owner(&self) -> Players {
self.as_ref().map_or(Players::Unclaimed, |u| u.get_owner())
}
fn set_task(&mut self, task: Tasks) {
self.as_mut().map_or((), |u| u.set_task(task))
}
fn get_hp(&self) -> u16 {
self.as_ref().map_or(0, |u| u.get_hp())
}
fn get_max_hp(&self) -> u16 {
self.as_ref().map_or(0, |u| u.get_max_hp())
}
fn get_can_dig(&self) -> bool {
self.as_ref().map_or(false, |u| u.get_can_dig())
}
fn get_digging_power(&self) -> f32 {
self.as_ref().map_or(0.0, |u| u.get_digging_power())
}
fn get_coords(&self) -> (usize, usize) {
self.as_ref().map_or((0, 0), |u| u.get_coords())
}
fn set_coords(&mut self, coords: (usize, usize)) {
if let Some(unit) = self.as_mut() {
unit.set_coords(coords);
}
}
fn get_ref_tasks(&self) -> &VecDeque<Tasks> {
self.as_ref()
.expect("attempted to get referenced tasks from `None`")
.get_ref_tasks()
}
fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks> {
self.as_mut()
.expect("attempted to get mutable tasks from `None`")
.get_mut_tasks()
}
fn get_tasks_formatted(&self) -> Vec<Line<'_>> {
self.as_ref()
.map_or(Vec::new(), |u| u.get_tasks_formatted())
}
fn set_task(&mut self, task: Tasks) {
self.as_mut().map_or((), |u| u.set_task(task))
}
}
+43 -43
View File
@@ -3,56 +3,56 @@ use ratatui::text::{Line, Span};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
pub enum ZoomLevel {
ZoomedIn,
Default,
ZoomedOut,
ZoomedIn,
Default,
ZoomedOut,
}
pub enum CellSizes {
Width,
Height,
Width,
Height,
}
impl ZoomLevel {
pub fn get_cell_size(&self, cell_size: CellSizes) -> usize {
match (cell_size, self) {
(CellSizes::Width, ZoomLevel::ZoomedIn) => 13,
(CellSizes::Width, ZoomLevel::Default) => 9,
(CellSizes::Width, ZoomLevel::ZoomedOut) => 5,
(CellSizes::Height, ZoomLevel::ZoomedIn) => 7,
(CellSizes::Height, ZoomLevel::Default) => 5,
(CellSizes::Height, ZoomLevel::ZoomedOut) => 3,
pub fn get_cell_size(&self, cell_size: CellSizes) -> usize {
match (cell_size, self) {
(CellSizes::Width, ZoomLevel::ZoomedIn) => 13,
(CellSizes::Width, ZoomLevel::Default) => 9,
(CellSizes::Width, ZoomLevel::ZoomedOut) => 5,
(CellSizes::Height, ZoomLevel::ZoomedIn) => 7,
(CellSizes::Height, ZoomLevel::Default) => 5,
(CellSizes::Height, ZoomLevel::ZoomedOut) => 3,
}
}
}
pub fn get_cell_text_area<'a>(
&self,
tag: char,
structure_level: char,
unit: Span<'a>,
) -> Vec<Line<'a>> {
match self {
ZoomLevel::ZoomedIn => vec![
Line::from(vec![
Span::raw(format!(" {} ", structure_level)),
unit,
Span::raw(" "),
]),
Line::from(format!(" ")),
Line::from(format!(" {} ", tag)),
Line::from(format!(" ")),
Line::from(format!(" ")),
],
ZoomLevel::Default => vec![
Line::from(vec![
Span::raw(format!(" {} ", structure_level)),
unit,
Span::raw(" "),
]),
Line::from(format!(" {} ", tag)),
Line::from(format!(" ")),
],
ZoomLevel::ZoomedOut => vec![Line::from(format!(" {} ", tag))],
pub fn get_cell_text_area<'a>(
&self,
tag: char,
structure_level: char,
unit: Span<'a>,
) -> Vec<Line<'a>> {
match self {
ZoomLevel::ZoomedIn => vec![
Line::from(vec![
Span::raw(format!(" {} ", structure_level)),
unit,
Span::raw(" "),
]),
Line::from(format!(" ")),
Line::from(format!(" {} ", tag)),
Line::from(format!(" ")),
Line::from(format!(" ")),
],
ZoomLevel::Default => vec![
Line::from(vec![
Span::raw(format!(" {} ", structure_level)),
unit,
Span::raw(" "),
]),
Line::from(format!(" {} ", tag)),
Line::from(format!(" ")),
],
ZoomLevel::ZoomedOut => vec![Line::from(format!(" {} ", tag))],
}
}
}
}
+135 -125
View File
@@ -1,175 +1,185 @@
use clap::ValueEnum;
use once_cell::sync::Lazy;
use rodio::{
Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, cpal::BufferSize,
decoder::LoopedDecoder, source::Amplify,
Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, cpal::BufferSize,
decoder::LoopedDecoder, source::Amplify,
};
use std::{
collections::HashMap,
fs::read,
io::{BufReader, Cursor},
process::exit,
sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, mpsc::Receiver},
collections::HashMap,
fs::read,
io::{BufReader, Cursor},
path::{Path, PathBuf},
process::exit,
sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, mpsc::Receiver},
};
#[derive(Debug)]
pub enum AudioCmd {
Mute,
VolumeUp,
VolumeDown,
ChangePart(SoundrackParts),
ChangeSoundtrack(Soundtrack),
Mute,
VolumeUp,
VolumeDown,
ChangePart(SoundrackParts),
ChangeSoundtrack(Soundtrack),
}
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, ValueEnum)]
pub enum Soundtrack {
Default,
Default,
}
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy)]
pub enum SoundrackParts {
Calm,
Buildup,
Assault,
Outro,
Calm,
Buildup,
Assault,
Outro,
}
type SoundtrackCache = HashMap<SoundrackParts, Vec<u8>>;
static PRELOADED_SOUNDTRACKS: Lazy<RwLock<HashMap<Soundtrack, Arc<SoundtrackCache>>>> =
Lazy::new(|| {
let mut map: HashMap<Soundtrack, Arc<HashMap<SoundrackParts, Vec<u8>>>> = HashMap::new();
Lazy::new(|| {
let mut map: HashMap<Soundtrack, Arc<HashMap<SoundrackParts, Vec<u8>>>> = HashMap::new();
map.insert(
Soundtrack::Default,
Arc::new(load_soundtrack_folder("default")),
);
map.insert(
Soundtrack::Default,
Arc::new(load_soundtrack_folder("default")),
);
RwLock::new(map)
});
RwLock::new(map)
});
fn load_soundtrack_folder(folder: &str) -> SoundtrackCache {
let base: String = format!("soundtracks/{}", folder);
let mut cache: HashMap<SoundrackParts, Vec<u8>> = HashMap::new();
let soundtrack_root: PathBuf = std::env::current_exe()
.ok()
.and_then(|executable: PathBuf| -> Option<PathBuf> {
executable
.parent()
.map(|directory: &Path| -> PathBuf { directory.join("soundtracks") })
})
.filter(|directory: &PathBuf| -> bool { directory.is_dir() })
.unwrap_or_else(|| -> PathBuf { PathBuf::from("soundtracks") });
let base: PathBuf = soundtrack_root.join(folder);
let mut cache: HashMap<SoundrackParts, Vec<u8>> = HashMap::new();
macro_rules! load {
($part:ident, $file:expr) => {
let path = format!("{}/{}", base, $file);
cache.insert(
SoundrackParts::$part,
read(&path).expect(&format!("cannot read {}", path)),
);
};
}
macro_rules! load {
($part:ident, $file:expr) => {
let path: PathBuf = base.join($file);
cache.insert(
SoundrackParts::$part,
read(&path).expect(&format!("cannot read {}", path.display())),
);
};
}
load!(Calm, "calm.ogg"); // calm.ogg
load!(Buildup, "test.ogg"); // buildup.ogg
load!(Assault, "test.ogg"); // assault.ogg
load!(Outro, "test.ogg"); // outro.ogg
load!(Calm, "calm.ogg"); // calm.ogg
load!(Buildup, "test.ogg"); // buildup.ogg
load!(Assault, "test.ogg"); // assault.ogg
load!(Outro, "test.ogg"); // outro.ogg
cache
cache
}
pub fn load_audio(
soundtrack: &Soundtrack,
part: &SoundrackParts,
soundtrack: &Soundtrack,
part: &SoundrackParts,
) -> Amplify<LoopedDecoder<BufReader<Cursor<Vec<u8>>>>> {
{
let guard: RwLockReadGuard<'_, HashMap<Soundtrack, Arc<HashMap<SoundrackParts, Vec<u8>>>>> =
PRELOADED_SOUNDTRACKS
.read()
.map_err(|e| {
eprintln!("RwLock poisoned: {}", e);
exit(1);
})
.expect("lock issue");
{
let guard: RwLockReadGuard<'_, HashMap<Soundtrack, Arc<HashMap<SoundrackParts, Vec<u8>>>>> =
PRELOADED_SOUNDTRACKS
.read()
.map_err(|e| {
eprintln!("RwLock poisoned: {}", e);
exit(1);
})
.expect("lock issue");
if let Some(soundtrack_cache) = guard.get(soundtrack) {
let data: &Vec<u8> = soundtrack_cache.get(part).expect("part missing in cache");
if let Some(soundtrack_cache) = guard.get(soundtrack) {
let data: &Vec<u8> = soundtrack_cache.get(part).expect("part missing in cache");
return Decoder::new_looped(BufReader::new(Cursor::new(data.clone())))
.expect("decoder issue")
.amplify(0.20);
}
}
{
let mut guard: RwLockWriteGuard<
'_,
HashMap<Soundtrack, Arc<HashMap<SoundrackParts, Vec<u8>>>>,
> = PRELOADED_SOUNDTRACKS
.write()
.map_err(|e| {
eprintln!("RwLock poisoned: {}", e);
exit(1);
})
.expect("lock issue");
if let Some(soundtrack_cache) = guard.get(soundtrack) {
let data: &Vec<u8> = soundtrack_cache.get(part).expect("part missing in cache");
return Decoder::new_looped(BufReader::new(Cursor::new(data.clone())))
.expect("decoder issue")
.amplify(0.20);
return Decoder::new_looped(BufReader::new(Cursor::new(data.clone())))
.expect("decoder issue")
.amplify(0.20);
}
}
let folder_name: &str = match soundtrack {
Soundtrack::Default => "default",
};
{
let mut guard: RwLockWriteGuard<
'_,
HashMap<Soundtrack, Arc<HashMap<SoundrackParts, Vec<u8>>>>,
> = PRELOADED_SOUNDTRACKS
.write()
.map_err(|e| {
eprintln!("RwLock poisoned: {}", e);
exit(1);
})
.expect("lock issue");
let new_cache: Arc<HashMap<SoundrackParts, Vec<u8>>> =
Arc::new(load_soundtrack_folder(folder_name));
if let Some(soundtrack_cache) = guard.get(soundtrack) {
let data: &Vec<u8> = soundtrack_cache.get(part).expect("part missing in cache");
let data: &Vec<u8> = new_cache.get(&part).expect("part missing after load");
return Decoder::new_looped(BufReader::new(Cursor::new(data.clone())))
.expect("decoder issue")
.amplify(0.20);
}
guard.insert(*soundtrack, Arc::clone(&new_cache));
let folder_name: &str = match soundtrack {
Soundtrack::Default => "default",
};
Decoder::new_looped(BufReader::new(Cursor::new(data.clone())))
.expect("decoder issue")
.amplify(0.20)
}
let new_cache: Arc<HashMap<SoundrackParts, Vec<u8>>> =
Arc::new(load_soundtrack_folder(folder_name));
let data: &Vec<u8> = new_cache.get(&part).expect("part missing after load");
guard.insert(*soundtrack, Arc::clone(&new_cache));
Decoder::new_looped(BufReader::new(Cursor::new(data.clone())))
.expect("decoder issue")
.amplify(0.20)
}
}
pub fn handle_audio(rx: Receiver<AudioCmd>, mute: bool, soundtrack: Soundtrack) {
let mut handle: MixerDeviceSink = DeviceSinkBuilder::from_default_device()
.expect("get default device")
.with_buffer_size(BufferSize::Fixed(4096))
.open_sink_or_fallback()
.expect("open device audio stream");
handle.log_on_drop(false);
let mut handle: MixerDeviceSink = DeviceSinkBuilder::from_default_device()
.expect("get default device")
.with_buffer_size(BufferSize::Fixed(4096))
.open_sink_or_fallback()
.expect("open device audio stream");
handle.log_on_drop(false);
let player: Player = Player::connect_new(&handle.mixer());
let player: Player = Player::connect_new(&handle.mixer());
let mut volume: f32 = player.volume();
player.set_volume(if mute { 0.0 } else { volume });
let mut volume: f32 = player.volume();
player.set_volume(if mute { 0.0 } else { volume });
let mut current_soundtrack: Soundtrack = soundtrack;
let mut current_part: SoundrackParts = SoundrackParts::Calm;
player.append(load_audio(&current_soundtrack, &current_part));
let mut current_soundtrack: Soundtrack = soundtrack;
let mut current_part: SoundrackParts = SoundrackParts::Calm;
player.append(load_audio(&current_soundtrack, &current_part));
for cmd in rx {
match cmd {
AudioCmd::Mute => {
player.set_volume(if player.volume() == 0.0 { volume } else { 0.0 });
}
AudioCmd::VolumeUp => {
volume = (volume + 0.1).min(1.0);
player.set_volume(volume);
}
AudioCmd::VolumeDown => {
volume = (volume - 0.1).max(0.0);
player.set_volume(volume);
}
AudioCmd::ChangePart(part) => {
current_part = part;
player.append(load_audio(&current_soundtrack, &current_part));
player.skip_one();
}
AudioCmd::ChangeSoundtrack(st) => {
current_soundtrack = st;
player.append(load_audio(&current_soundtrack, &current_part));
player.skip_one();
}
for cmd in rx {
match cmd {
AudioCmd::Mute => {
player.set_volume(if player.volume() == 0.0 { volume } else { 0.0 });
}
AudioCmd::VolumeUp => {
volume = (volume + 0.1).min(1.0);
player.set_volume(volume);
}
AudioCmd::VolumeDown => {
volume = (volume - 0.1).max(0.0);
player.set_volume(volume);
}
AudioCmd::ChangePart(part) => {
current_part = part;
player.append(load_audio(&current_soundtrack, &current_part));
player.skip_one();
}
AudioCmd::ChangeSoundtrack(st) => {
current_soundtrack = st;
player.append(load_audio(&current_soundtrack, &current_part));
player.skip_one();
}
}
}
}
}
+14 -14
View File
@@ -2,20 +2,20 @@ use ratatui::crossterm::event::{Event, KeyEvent, read};
use std::sync::mpsc::Sender;
pub fn handle_ct_events(input_tx: Sender<KeyEvent>, resize_tx: Sender<(u16, u16)>) {
loop {
match read() {
Ok(Event::Key(k)) => {
if input_tx.send(k).is_err() {
break;
loop {
match read() {
Ok(Event::Key(k)) => {
if input_tx.send(k).is_err() {
break;
}
}
Ok(Event::Resize(cols, rows)) => {
if resize_tx.send((cols, rows)).is_err() {
break;
}
}
Ok(_) => {}
Err(_) => continue,
}
}
Ok(Event::Resize(cols, rows)) => {
if resize_tx.send((cols, rows)).is_err() {
break;
}
}
Ok(_) => {}
Err(_) => continue,
}
}
}
+12 -12
View File
@@ -1,21 +1,21 @@
use std::{
sync::mpsc::Sender,
thread,
time::{Duration, Instant},
sync::mpsc::Sender,
thread,
time::{Duration, Instant},
};
pub const TICK_MS: u8 = 33;
pub fn handle_tick_event(tx: Sender<()>) {
let interval: Duration = Duration::from_millis(TICK_MS as u64);
loop {
if tx.send(()).is_err() {
break;
}
let interval: Duration = Duration::from_millis(TICK_MS as u64);
loop {
if tx.send(()).is_err() {
break;
}
let elapsed: Duration = Instant::now().elapsed();
if interval > elapsed {
thread::sleep(interval - elapsed);
let elapsed: Duration = Instant::now().elapsed();
if interval > elapsed {
thread::sleep(interval - elapsed);
}
}
}
}
+15 -15
View File
@@ -1,27 +1,27 @@
use crate::app::{
App,
views::{default_view, main_menu_view, skirmish_view},
App,
views::{default_view, main_menu_view, skirmish_view},
};
use clap::ValueEnum;
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
pub enum View {
MainMenu,
Skirmish,
PerkDecks,
SkillsConfig,
MainMenu,
Skirmish,
PerkDecks,
SkillsConfig,
}
impl Widget for &mut App {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
match self.view {
View::MainMenu => main_menu_view(self, area, buf),
View::Skirmish => skirmish_view(self, area, buf),
_ => default_view(area, buf),
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
match self.view {
View::MainMenu => main_menu_view(self, area, buf),
View::Skirmish => skirmish_view(self, area, buf),
_ => default_view(area, buf),
}
}
}
}
+26 -26
View File
@@ -1,40 +1,40 @@
use crate::app::{
keybindings::{Action, count_largest_group},
widgets::KeybindingsWidget,
keybindings::{Action, count_largest_group},
widgets::KeybindingsWidget,
};
use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Rect},
style::Stylize,
widgets::{Block, Borders, Paragraph, Widget},
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Stylize},
widgets::{Block, Borders, Paragraph, Widget},
};
const ACTIONS: &[Action] = &[Action::Quit, Action::Quit2, Action::Esc];
fn default_view_layout(area: Rect) -> [Rect; 2] {
Layout::vertical([
Constraint::Fill(1),
Constraint::Length(count_largest_group(&ACTIONS) + 2),
])
.areas(area)
Layout::vertical([
Constraint::Fill(1),
Constraint::Length(count_largest_group(&ACTIONS) + 2),
])
.areas(area)
}
pub fn default_view(area: Rect, buf: &mut Buffer) {
let [main_area, keybindings_area] = default_view_layout(area);
let [main_area, keybindings_area] = default_view_layout(area);
{
Paragraph::new("Work in progress")
.alignment(Alignment::Center)
.yellow()
.block(
Block::new()
.gray()
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT),
)
.render(main_area, buf);
}
{
Paragraph::new("Work in progress")
.alignment(Alignment::Center)
.fg(Color::Rgb(128, 128, 0))
.block(
Block::new()
.fg(Color::Rgb(192, 192, 192))
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT),
)
.render(main_area, buf);
}
{
KeybindingsWidget::new(ACTIONS, keybindings_area.height).render(keybindings_area, buf);
}
{
KeybindingsWidget::new(ACTIONS, keybindings_area.height).render(keybindings_area, buf);
}
}
+86 -74
View File
@@ -1,100 +1,112 @@
use crate::app::{
App, View,
helpers::main_menu_option_helper,
keybindings::{Action, count_largest_group},
widgets::KeybindingsWidget,
App, View,
helpers::main_menu_option_helper,
keybindings::{Action, count_largest_group},
widgets::KeybindingsWidget,
};
use clap::ValueEnum;
use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Rect},
style::Stylize,
text::Line,
widgets::{Block, Borders, Paragraph, Widget},
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Stylize},
text::Line,
widgets::{Block, Borders, Paragraph, Widget},
};
const ACTIONS: &[Action] = &[
Action::Up,
Action::Down,
Action::Space,
Action::VolumeUp,
Action::VolumeDown,
Action::Mute,
Action::Quit,
Action::Quit2,
Action::Up,
Action::Down,
Action::Space,
Action::VolumeUp,
Action::VolumeDown,
Action::Mute,
Action::Quit,
Action::Quit2,
];
fn main_menu_layout(area: Rect) -> [Rect; 2] {
Layout::vertical([
Constraint::Fill(1),
Constraint::Length(count_largest_group(&ACTIONS) + 2),
])
.areas(area)
Layout::vertical([
Constraint::Fill(1),
Constraint::Length(count_largest_group(&ACTIONS) + 2),
])
.areas(area)
}
pub fn main_menu_view(app: &App, area: Rect, buf: &mut Buffer) {
let Some(states) = app.states_ref() else {
return;
};
let Some(states) = app.states_ref() else {
return;
};
let [main_menu_area, keybindings_area] = main_menu_layout(area);
let [main_menu_area, keybindings_area] = main_menu_layout(area);
Block::new()
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT)
.render(main_menu_area, buf);
Block::new()
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT)
.render(main_menu_area, buf);
let main_menu_areas: Vec<Rect> = main_menu_area.layout_vec(&Layout::vertical([
Constraint::Percentage(50),
Constraint::Percentage(50),
]));
let main_menu_areas: Vec<Rect> = main_menu_area.layout_vec(&Layout::vertical([
Constraint::Percentage(50),
Constraint::Percentage(50),
]));
{
let title_area: Rect = main_menu_areas[0].centered_vertically(Constraint::Percentage(50));
{
let title_area: Rect = main_menu_areas[0].centered_vertically(Constraint::Percentage(50));
let title_text: String = vec![
r" __ __ _ _____ _ ",
r"/ / /\ \ \__ _ _ __ (_)_ __ /__ \_ _ _ __ _ __ ___| |___ ",
r"\ \/ \/ / _` | '__| | | '_ \ / /\/ | | | '_ \| '_ \ / _ \ / __|",
r" \ /\ / (_| | | | | | | | / / | |_| | | | | | | | __/ \__ \",
r" \/ \/ \__,_|_| |_|_| |_| \/ \__,_|_| |_|_| |_|\___|_|___/",
]
.join("\n");
let title_text: String = vec![
r" __ __ _ _____ _ ",
r"/ / /\ \ \__ _ _ __ (_)_ __ /__ \_ _ _ __ _ __ ___| |___ ",
r"\ \/ \/ / _` | '__| | | '_ \ / /\/ | | | '_ \| '_ \ / _ \ / __|",
r" \ /\ / (_| | | | | | | | / / | |_| | | | | | | | __/ \__ \",
r" \/ \/ \__,_|_| |_|_| |_| \/ \__,_|_| |_|_| |_|\___|_|___/",
]
.join("\n");
Paragraph::new(title_text)
.alignment(Alignment::Center)
.yellow()
.block(Block::new().gray().borders(Borders::LEFT | Borders::RIGHT))
.render(title_area, buf);
}
Paragraph::new(title_text)
.alignment(Alignment::Center)
.fg(Color::Rgb(128, 128, 0))
.block(
Block::new()
.fg(Color::Rgb(192, 192, 192))
.borders(Borders::LEFT | Borders::RIGHT),
)
.render(title_area, buf);
}
{
let options_area: Rect = main_menu_areas[1];
{
let options_area: Rect = main_menu_areas[1];
let lines: Vec<Line<'_>> = View::value_variants()
.iter()
.enumerate()
.filter(|(_, v)| v != &&View::MainMenu)
.map(|(i, view)| {
let view_string: String = main_menu_option_helper(format!("{:?}", view));
let lines: Vec<Line<'_>> = View::value_variants()
.iter()
.enumerate()
.filter(|(_, v)| v != &&View::MainMenu)
.map(|(i, view)| {
let view_string: String = main_menu_option_helper(format!("{:?}", view));
let styled: Line<'_> = if states.main_menu.selected_view == i {
Line::from_iter(["> ".cyan(), view_string.yellow()]).yellow()
} else {
Line::from(format!(" {view_string}")).white()
};
let styled: Line<'_> = if states.main_menu.selected_view == i {
Line::from_iter([
"> ".fg(Color::Rgb(0, 128, 128)),
view_string.fg(Color::Rgb(128, 128, 0)),
])
.fg(Color::Rgb(128, 128, 0))
} else {
Line::from(format!(" {view_string}")).fg(Color::Rgb(255, 255, 255))
};
styled
})
.collect();
styled
})
.collect();
Paragraph::new(lines)
.alignment(Alignment::Center)
.green()
.block(Block::new().gray().borders(Borders::LEFT | Borders::RIGHT))
.render(options_area, buf);
}
Paragraph::new(lines)
.alignment(Alignment::Center)
.fg(Color::Rgb(0, 128, 0))
.block(
Block::new()
.fg(Color::Rgb(192, 192, 192))
.borders(Borders::LEFT | Borders::RIGHT),
)
.render(options_area, buf);
}
{
KeybindingsWidget::new(ACTIONS, keybindings_area.height).render(keybindings_area, buf);
}
{
KeybindingsWidget::new(ACTIONS, keybindings_area.height).render(keybindings_area, buf);
}
}
+436 -133
View File
@@ -1,153 +1,456 @@
use crate::app::{
App,
helpers::block_title_helper,
keybindings::{Action, count_largest_group},
states::skirmish_states::BoardState,
widgets::{BoardWidget, KeybindingsWidget, SidePanelWidget},
App,
helpers::block_title_helper,
keybindings::{Action, available_skirmish_actions},
states::SkirmishState,
states::skirmish_states::{
BoardState, Players,
resources::{WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_INTERVAL_MS},
structures::{Mine, Structure, Structures, Tunnel},
units::MinerUnit,
},
widgets::{BoardWidget, CellWidget, KeybindingsWidget, SidePanelWidget},
};
use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Margin, Rect},
style::{Color, Stylize},
text::Line,
widgets::{Block, Borders, Padding, Paragraph, Widget},
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Margin, Rect},
style::{Color, Style, Stylize},
text::Line,
widgets::{Block, Borders, Clear, Padding, Paragraph, Tabs, Widget},
};
const ACTIONS: &[Action] = &[
Action::Up,
Action::Down,
Action::Left,
Action::Right,
Action::ScrollUp,
Action::ScrollDown,
Action::ScrollLeft,
Action::ScrollRight,
Action::ZoomIn,
Action::ZoomOut,
Action::VolumeUp,
Action::VolumeDown,
Action::Space,
Action::Backspace,
Action::Delete,
Action::Mute,
Action::Tab,
Action::Quit,
Action::Quit2,
Action::Esc,
];
pub fn skirmish_layout(area: Rect) -> [Rect; 3] {
Layout::vertical([
Constraint::Length(4),
Constraint::Fill(1),
Constraint::Length(count_largest_group(&ACTIONS) + 2),
])
.areas(area)
Layout::vertical([
Constraint::Length(4),
Constraint::Fill(1),
Constraint::Length(6),
])
.areas(area)
}
pub fn skirmish_main_area_layout(area: Rect, side_panel: bool) -> [Rect; 2] {
let constraint: [Constraint; 2] = if side_panel {
[Constraint::Fill(1), Constraint::Length(39)]
} else {
[Constraint::Percentage(100), Constraint::Percentage(0)]
};
let constraint: [Constraint; 2] = if side_panel {
[Constraint::Fill(1), Constraint::Length(39)]
} else {
[Constraint::Percentage(100), Constraint::Percentage(0)]
};
Layout::horizontal(constraint).areas(area)
Layout::horizontal(constraint).areas(area)
}
pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
let Some(states) = app.states_ref() else {
return;
};
let Some(states) = app.states_ref() else {
return;
};
let board: &BoardState = &states.skirmish.board;
let board: &BoardState = &states.skirmish.board;
let [title_area, main_area, keybindings_area] = skirmish_layout(area);
let [title_area, main_area, keybindings_area] = skirmish_layout(area);
{
let lines: Vec<Line<'_>> = Vec::from_iter([
Line::raw("War in Tunnels").yellow(),
Line::from_iter([
format!("Wood: {} (+{}) | ", states.settings.starting_wood, 5),
format!("Iron: {} (+{}) | ", states.settings.starting_iron, 1),
format!("Supply Limit: {}/{} | ", 10, states.settings.supply_limit),
format!(
"Skills points: {} ({}/{}) | ",
1, 20, states.settings.skill_points_limit
),
format!("Perk Deck: {}/9 | ", 5),
format!("Tick: {}", states.skirmish.turn_counter),
]),
]);
{
let lines: Vec<Line<'_>> = Vec::from_iter([
Line::raw("War in Tunnels").fg(Color::Rgb(128, 128, 0)),
Line::from_iter([
format!(
"Wood: {} (+{}/{}s) | ",
states.skirmish.player_resources.wood,
WOOD_DELIVERY_AMOUNT,
WOOD_DELIVERY_INTERVAL_MS / 1000
),
format!("Metal: {} | ", states.skirmish.player_resources.metal),
format!("Supply Limit: {}/{} | ", 10, states.settings.supply_limit),
format!(
"Skills points: {} ({}/{}) | ",
1, 20, states.settings.skill_points_limit
),
format!("Perk Deck: {}/9 | ", 5),
format!("Tick: {}", states.skirmish.turn_counter),
]),
]);
Paragraph::new(lines)
.alignment(Alignment::Left)
.block(
Block::new()
.gray()
.padding(Padding {
left: 1,
right: 1,
top: 0,
bottom: 1,
})
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT),
)
.render(title_area, buf);
}
{
let main_block: Block = Block::new()
.gray()
.title(block_title_helper(
&[
("Skirmish".to_string(), Color::Magenta),
("Map".to_string(), Color::Green),
],
Some(" - "),
))
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT);
let main_inner_area: Rect = main_block.inner(main_area);
main_block.render(main_area, buf);
let [board_area, side_panel_area] =
skirmish_main_area_layout(main_inner_area, states.skirmish.side_panel);
let cells_area: Rect = board_area.inner(Margin {
horizontal: 1,
vertical: 1,
});
// .centered(
// Constraint::Length(
// (board.cell_width * board.cols) as u16,
// ),
// Constraint::Length(
// (board.cell_height * board.rows) as u16,
// ),
// );
BoardWidget::new(&board).render(cells_area, buf);
if states.skirmish.side_panel {
let (row, col) = board.get_focused_cell().get_coords();
let mut is_unit_marked: bool = false;
if let Some((first_row, first_col)) = board.marked_cells.marked_cells.get(0) {
is_unit_marked = first_row == &row && first_col == &col;
}
SidePanelWidget::new(
(row, col),
&board.get_ref_cell(row, col).get_structure(),
&board.get_ref_cell(row, col).get_ref_option_unit(),
is_unit_marked,
)
.render(side_panel_area, buf);
Paragraph::new(lines)
.alignment(Alignment::Left)
.block(
Block::new()
.fg(Color::Rgb(192, 192, 192))
.padding(Padding {
left: 1,
right: 1,
top: 0,
bottom: 1,
})
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT),
)
.render(title_area, buf);
}
}
{
KeybindingsWidget::new(ACTIONS, keybindings_area.height).render(keybindings_area, buf);
}
{
let main_block: Block = Block::new()
.fg(Color::Rgb(192, 192, 192))
.title(block_title_helper(
&[
("Skirmish".to_string(), Color::Rgb(128, 0, 128)),
("Map".to_string(), Color::Rgb(0, 128, 0)),
],
Some(" - "),
))
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT);
let main_inner_area: Rect = main_block.inner(main_area);
main_block.render(main_area, buf);
let [board_area, side_panel_area] = skirmish_main_area_layout(
main_inner_area,
states.skirmish.side_panel || states.skirmish.task_view,
);
let cells_area: Rect = board_area.inner(Margin {
horizontal: 1,
vertical: 1,
});
// .centered(
// Constraint::Length(
// (board.cell_width * board.cols) as u16,
// ),
// Constraint::Length(
// (board.cell_height * board.rows) as u16,
// ),
// );
BoardWidget::new(&board, states.skirmish.stress_view).render(cells_area, buf);
if states.skirmish.task_view {
let mut lines: Vec<Line<'_>> = vec![
Line::from("Enter: draw route").fg(Color::Rgb(0, 128, 0)),
Line::from(format!(
"Auto supports: {}",
if board.auto_supports { "ON" } else { "OFF" }
))
.fg(Color::Rgb(0, 128, 128)),
Line::from("a: toggle auto supports").fg(Color::Rgb(128, 128, 0)),
Line::from("Support: 10 Wood + 2 Metal").fg(Color::Rgb(128, 128, 0)),
Line::from("Placed at 80% stress").fg(Color::Rgb(192, 192, 192)),
Line::from("No funds: collapse risk!").fg(Color::Rgb(128, 0, 0)),
Line::from("t: close task selection").fg(Color::Rgb(192, 192, 192)),
];
if states.skirmish.selected_task == 1 {
lines = vec![
Line::from("Enter: draw movement route").fg(Color::Rgb(0, 128, 0)),
Line::from("Walkable cells only; no digging").fg(Color::Rgb(0, 128, 128)),
Line::from("Collapsed route: wait, do not dig").fg(Color::Rgb(192, 192, 192)),
Line::from("t: close task selection").fg(Color::Rgb(192, 192, 192)),
];
}
let block: Block<'_> = Block::bordered()
.title("Unit tasks")
.padding(Padding::uniform(1));
let [tabs_area, details_area]: [Rect; 2] =
Layout::vertical([Constraint::Length(2), Constraint::Min(0)])
.areas(block.inner(side_panel_area));
block.render(side_panel_area, buf);
Tabs::new(SkirmishState::TASK_OPTIONS.iter().copied())
.select(states.skirmish.selected_task)
.highlight_style(Style::default().fg(Color::Rgb(255, 0, 255)).bold())
.render(tabs_area, buf);
Paragraph::new(lines).render(details_area, buf);
} else if states.skirmish.side_panel {
let (row, col) = board.get_focused_cell().get_coords();
let mut is_unit_marked: bool = false;
if let Some((first_row, first_col)) = board.marked_cells.marked_cells.get(0) {
is_unit_marked = first_row == &row && first_col == &col;
}
SidePanelWidget::new(
(row, col),
&board.get_ref_cell(row, col).get_structure(),
&board.get_ref_cell(row, col).get_ref_option_unit(),
is_unit_marked,
)
.render(side_panel_area, buf);
}
}
{
let actions: Vec<Action> = available_skirmish_actions(&states.skirmish);
let descriptions: [(Action, &'static str); 17] = [
(
Action::Tasks,
if states.skirmish.task_view {
"Close tasks"
} else {
"Unit tasks"
},
),
(
Action::AutoSupports,
if board.auto_supports {
"Auto supports: ON"
} else {
"Auto supports: OFF"
},
),
(
Action::Stress,
if states.skirmish.stress_view {
"Normal view"
} else {
"Stress view"
},
),
(
Action::Left,
if states.skirmish.building_view
|| states.skirmish.recruitment_view
|| states.skirmish.task_view
{
"Previous option"
} else {
"Left"
},
),
(
Action::Right,
if states.skirmish.building_view
|| states.skirmish.recruitment_view
|| states.skirmish.task_view
{
"Next option"
} else {
"Right"
},
),
(
Action::Backspace,
if board.marked_cells.marking_cells {
"Undo last cell"
} else {
"Cancel last task"
},
),
(
Action::Enter,
if states.skirmish.task_view {
"Draw route"
} else if states.skirmish.recruitment_view {
"Queue Miner"
} else {
if states.skirmish.selected_building == 1 {
"Build support"
} else {
"Build Mine"
}
},
),
(
Action::Recruitment,
if states.skirmish.recruitment_view {
"Close recruitment"
} else {
"Recruit"
},
),
(
Action::Delete,
if states.skirmish.building_view {
if states.skirmish.selected_building == 1 {
"Remove support"
} else {
"Cancel construction"
}
} else if !board.marked_cells.marking_cells {
"Cancel all tasks"
} else {
"Cancel marking"
},
),
(
Action::Space,
if board.marked_cells.marking_cells {
"Confirm route"
} else {
"Select miner"
},
),
(
Action::Esc,
if board.marked_cells.marking_cells {
"Cancel route"
} else {
"Menu"
},
),
(
Action::Building,
if states.skirmish.building_view {
"Close buildings"
} else {
"Build"
},
),
(Action::Tab, "Details"),
(Action::ScrollUp, "Scroll"),
(Action::ScrollDown, "Scroll"),
(Action::ScrollLeft, "Scroll"),
(Action::ScrollRight, "Scroll"),
];
KeybindingsWidget::with_descriptions(&actions, keybindings_area.height, &descriptions)
.render(keybindings_area, buf);
}
if states.skirmish.building_view {
let popup: Rect = main_area.centered(Constraint::Length(64), Constraint::Length(14));
let coords: (usize, usize) = board.get_focused_cell().get_coords();
let structure: Structures = board.get_ref_cell(coords.0, coords.1).get_structure();
let status: Vec<Line<'_>> = if let Structures::MineConstruction(site) = structure
&& site.get_owner() == Players::Player
{
vec![
Line::from(format!("Construction: {}%", site.progress_percent()))
.fg(Color::Rgb(0, 128, 128)),
if board.has_adjacent_builder(coords, Players::Player) {
Line::from("Builder working").fg(Color::Rgb(0, 128, 0))
} else {
Line::from("Paused: adjacent friendly builder required")
.fg(Color::Rgb(128, 128, 0))
},
Line::from("Delete: cancel and refund full cost").fg(Color::Rgb(128, 128, 0)),
]
} else if matches!(structure, Structures::Mine(_))
&& structure.get_owner() == Players::Player
{
vec![Line::from("Mine completed").fg(Color::Rgb(0, 128, 0))]
} else if states
.skirmish
.can_start_mine_construction(coords, Players::Player)
{
vec![Line::from("Ready to build: Enter").fg(Color::Rgb(0, 128, 0))]
} else {
vec![
Line::from("Construction unavailable: check site, builder and resources")
.fg(Color::Rgb(128, 0, 0)),
]
};
let mut lines: Vec<Line<'_>> = vec![
Line::from(format!(
"Cost: {} Wood + {} Metal | Build time: 10s",
Mine::COST.wood,
Mine::COST.metal
))
.fg(Color::Rgb(128, 128, 0)),
Line::from("Requires: neutral Ore + adjacent friendly builder")
.fg(Color::Rgb(192, 192, 192)),
Line::from("Produces: 1 Metal/s (2 with a Miner inside)").fg(Color::Rgb(0, 128, 128)),
];
lines.extend(status);
if states.skirmish.selected_building == 1 {
lines = vec![
Line::from(format!(
"Cost: {} Wood + {} Metal | Work time: 3s",
Tunnel::SUPPORT_COST.wood,
Tunnel::SUPPORT_COST.metal
))
.fg(Color::Rgb(128, 128, 0)),
Line::from("Tunnel + nearby friendly builder required")
.fg(Color::Rgb(192, 192, 192)),
Line::from("Stress: 20% at support, +20% per tile").fg(Color::Rgb(0, 128, 128)),
if matches!(structure, Structures::Tunnel(tunnel) if tunnel.support_work().is_some())
{
Line::from("Builder occupied until completion").fg(Color::Rgb(128, 128, 0))
} else if matches!(structure, Structures::Tunnel(tunnel) if tunnel.has_support())
&& states.skirmish.can_manage_support(coords, Players::Player)
{
Line::from("Delete: remove support (no refund)").fg(Color::Rgb(128, 128, 0))
} else if states.skirmish.can_build_support(coords, Players::Player) {
Line::from("Enter: build support").fg(Color::Rgb(0, 128, 0))
} else {
Line::from("Support unavailable: site, builder or resources")
.fg(Color::Rgb(128, 0, 0))
},
Line::from("WARNING: removal can cause fatal collapses").fg(Color::Rgb(128, 0, 0)),
];
if let Structures::Tunnel(tunnel) = structure
&& let Some(work) = tunnel.support_work()
{
lines.push(
Line::from(format!(
"{}: {}%",
if work.removing {
"Removing support"
} else {
"Building support"
},
tunnel.support_progress().unwrap_or(0)
))
.fg(Color::Rgb(0, 128, 128)),
);
if board.support_worker(coords, work.owner).is_none() {
lines.push(
Line::from("Paused: friendly builder required").fg(Color::Rgb(128, 128, 0)),
);
}
}
}
Clear.render(popup, buf);
let block: Block<'_> = Block::bordered()
.title(Line::from("Buildings").fg(Color::Rgb(128, 0, 128)).bold())
.padding(Padding::uniform(1));
let [tabs_area, details_area]: [Rect; 2] =
Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(block.inner(popup));
block.render(popup, buf);
Tabs::new(SkirmishState::BUILDING_OPTIONS.iter().copied())
.select(states.skirmish.selected_building)
.highlight_style(Style::default().fg(Color::Rgb(255, 0, 255)).bold())
.render(tabs_area, buf);
Paragraph::new(lines).render(details_area, buf);
}
if states.skirmish.recruitment_view {
let popup: Rect = main_area.centered(Constraint::Length(64), Constraint::Length(14));
let coords: (usize, usize) = board.get_focused_cell().get_coords();
let cell: &CellWidget = board.get_ref_cell(coords.0, coords.1);
let lines: Vec<Line<'_>> = if let Structures::Base(base) = cell.get_structure()
&& base.get_owner() == Players::Player
{
let status: &'static str =
base.recruitment_status(cell.get_ref_option_unit().is_some());
let action: Line<'_> = if states.skirmish.can_recruit_miner(coords, Players::Player) {
Line::from("Enter: queue Miner").fg(Color::Rgb(0, 128, 0))
} else {
Line::from("Insufficient resources or queue full").fg(Color::Rgb(128, 0, 0))
};
vec![
Line::from(format!(
"Cost: {} Wood + {} Metal | Recruit time: 10s",
MinerUnit::RECRUIT_COST.wood,
MinerUnit::RECRUIT_COST.metal
))
.fg(Color::Rgb(128, 128, 0)),
Line::from(format!(
"Queued Miners: {} (includes active)",
base.queued_miners()
))
.fg(Color::Rgb(0, 128, 128)),
Line::from(format!(
"Current progress: {}%",
base.recruitment_progress()
))
.fg(Color::Rgb(0, 128, 128)),
Line::from(status).fg(Color::Rgb(128, 128, 0)),
action,
]
} else {
vec![Line::from("Select your Base to recruit Miners").fg(Color::Rgb(128, 0, 0))]
};
Clear.render(popup, buf);
let block: Block<'_> = Block::bordered()
.title(Line::from("Recruitment").fg(Color::Rgb(128, 0, 128)).bold())
.padding(Padding::uniform(1));
let [tabs_area, details_area]: [Rect; 2] =
Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(block.inner(popup));
block.render(popup, buf);
Tabs::new(SkirmishState::RECRUITMENT_OPTIONS.iter().copied())
.select(states.skirmish.selected_recruit)
.highlight_style(Style::default().fg(Color::Rgb(255, 0, 255)).bold())
.render(tabs_area, buf);
Paragraph::new(lines).render(details_area, buf);
}
}
+62 -32
View File
@@ -1,46 +1,76 @@
use crate::app::states::skirmish_states::BoardState;
use crate::app::states::skirmish_states::{BoardState, Players, tasks::Task, units::Unit};
use crate::app::widgets::CellWidget;
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
widgets::Widget,
buffer::Buffer,
layout::{Constraint, Layout, Rect},
widgets::Widget,
};
use std::rc::Rc;
use std::{collections::HashSet, rc::Rc};
pub struct BoardWidget<'a> {
state: &'a BoardState,
state: &'a BoardState,
stress: bool,
}
impl<'a> BoardWidget<'a> {
pub fn new(state: &'a BoardState) -> Self {
Self { state }
}
pub fn new(state: &'a BoardState, stress: bool) -> Self {
Self { state, stress }
}
}
impl Widget for BoardWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let horizontal: Rc<[Rect]> =
Layout::horizontal(vec![
Constraint::Length(self.state.cell_width as u16);
self.state.cols
])
.split(area);
fn render(self, area: Rect, buf: &mut Buffer) {
let mut queued_path: HashSet<(usize, usize)> = HashSet::new();
for row in &self.state.cells {
for cell in row {
if let Some(unit) = cell.get_ref_option_unit()
&& unit.get_owner() == Players::Player
{
for task in unit.get_ref_tasks() {
queued_path.extend(task.get_path().iter().copied());
}
}
}
}
let horizontal: Rc<[Rect]> =
Layout::horizontal(vec![
Constraint::Length(self.state.cell_width as u16);
self.state.cols
])
.split(area);
for (col_idx, col_area) in horizontal.iter().enumerate() {
let vertical: Rc<[Rect]> =
Layout::vertical(vec![
Constraint::Length(self.state.cell_height as u16);
self.state.rows
])
.split(*col_area);
for (col_idx, col_area) in horizontal.iter().enumerate() {
let vertical: Rc<[Rect]> =
Layout::vertical(vec![
Constraint::Length(self.state.cell_height as u16);
self.state.rows
])
.split(*col_area);
for (row_idx, cell_area) in vertical.iter().enumerate() {
self
.state
.cells
.get(row_idx + self.state.vertical_offset.get_value())
.and_then(|rows| rows.get(col_idx + self.state.horizontal_offset.get_value()))
.map(|cell| cell.clone().render(*cell_area, buf));
}
for (row_idx, cell_area) in vertical.iter().enumerate() {
self.state
.cells
.get(row_idx + self.state.vertical_offset.get_value())
.and_then(|rows| rows.get(col_idx + self.state.horizontal_offset.get_value()))
.map(|cell: &CellWidget| -> () {
let mut cell: CellWidget = cell.clone();
let coords: (usize, usize) = (
row_idx + self.state.vertical_offset.get_value(),
col_idx + self.state.horizontal_offset.get_value(),
);
if queued_path.contains(&coords)
&& (coords != self.state.get_focused_cell().get_coords()
|| self.state.marked_cells.marking_cells)
{
cell.set_marked(true);
}
if self.stress {
cell.render_stress(*cell_area, buf);
} else {
cell.render(*cell_area, buf);
}
});
}
}
}
}
}
+207 -140
View File
@@ -1,162 +1,229 @@
use crate::app::states::skirmish_states::{
ZoomLevel,
structures::{Stone, Structure, Structures},
tasks::Tasks,
units::{Unit, Units},
ZoomLevel,
structures::{Stone, Structure, Structures},
tasks::Tasks,
units::{Unit, Units},
};
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::{Color, Style, Stylize},
text::{Line, Span, ToSpan},
widgets::{Block, BorderType, Borders, Paragraph, Widget},
buffer::Buffer,
layout::{Alignment, Rect},
style::{Color, Style, Stylize},
text::{Line, Span, ToSpan},
widgets::{Block, BorderType, Borders, Paragraph, Widget},
};
#[derive(Debug, Clone, PartialEq)]
pub struct CellWidget {
row: usize,
col: usize,
selected: bool,
zoom_level: ZoomLevel,
structure: Structures,
marked: bool,
unit: Option<Units>,
row: usize,
col: usize,
selected: bool,
zoom_level: ZoomLevel,
structure: Structures,
marked: bool,
unit: Option<Units>,
}
impl CellWidget {
pub fn new(
row: usize,
col: usize,
zoom_level: ZoomLevel,
selected: bool,
structure: Structures,
unit: Option<Units>,
) -> Self {
Self {
row,
col,
selected,
zoom_level,
marked: false,
structure,
unit,
}
}
pub fn set_selected(&mut self, selected: bool) -> &mut Self {
self.selected = selected;
self
}
pub fn set_zoom_level(&mut self, zoom_level: ZoomLevel) -> &mut Self {
self.zoom_level = zoom_level;
self
}
pub fn get_structure(&self) -> Structures {
self.structure
}
pub fn get_option_unit(&self) -> Option<Units> {
self.unit.clone()
}
pub fn get_ref_option_unit(&self) -> &Option<Units> {
&self.unit
}
pub fn get_mut_option_unit(&mut self) -> &mut Option<Units> {
&mut self.unit
}
pub fn set_structure(&mut self, structure: Structures) -> &mut Self {
self.structure = structure;
self
}
pub fn get_marked(&self) -> bool {
self.marked
}
pub fn set_marked(&mut self, marked: bool) -> &mut Self {
self.marked = marked;
self
}
fn col_to_letters(&self) -> String {
let mut col: usize = self.col + 1;
let mut letters: Vec<char> = Vec::new();
while col > 0 {
letters.push((b'A' + ((col - 1) % 26) as u8) as char);
col = (col - 1) / 26;
pub fn render_stress(&self, area: Rect, buf: &mut Buffer) -> () {
if matches!(self.structure, Structures::Stone(_)) {
self.clone()
.set_marked(self.selected && self.marked)
.clone()
.render(area, buf);
return;
}
let stress: u8 = self.structure.get_stress();
let red: u8 = (u16::from(stress) * 255 / 100) as u8;
let color: Color = if self.selected {
self.fg_color()
} else {
Color::Rgb(red, 255 - red, 30)
};
let block: Block<'_> = Block::bordered()
.border_type(BorderType::Rounded)
.fg(color)
.title(format!("{}{}", self.col_to_letters(), self.row));
let inner: Rect = block.inner(area);
block.render(area, buf);
if inner.height == 0 || inner.width == 0 {
return;
}
let text: String = if stress == 100 && inner.width < 4 {
"100".to_string()
} else {
format!("{stress}%")
};
Paragraph::new(text)
.fg(color)
.alignment(Alignment::Center)
.render(
Rect::new(inner.x, inner.y + inner.height / 2, inner.width, 1),
buf,
);
}
letters.iter().rev().collect()
}
fn display_coords(&self) -> Span<'_> {
if self.selected || self.structure != Structures::Stone(Stone::new()) || self.marked {
format!("{}{}", self.col_to_letters(), self.row).green()
} else {
"".to_span()
pub fn new(
row: usize,
col: usize,
zoom_level: ZoomLevel,
selected: bool,
structure: Structures,
unit: Option<Units>,
) -> Self {
Self {
row,
col,
selected,
zoom_level,
marked: false,
structure,
unit,
}
}
}
fn fg_color(&self) -> Color {
if self.marked && self.selected {
Color::Magenta
} else if self.marked && !self.selected {
Color::LightMagenta
} else if !self.marked && self.selected {
Color::LightYellow
} else {
self.structure.get_color()
pub fn set_selected(&mut self, selected: bool) -> &mut Self {
self.selected = selected;
self
}
}
fn get_text_area(&self) -> Vec<Line<'_>> {
let mut lines: Vec<Line<'_>> = self.zoom_level.get_cell_text_area(
self.structure.get_tag(),
self.structure.get_level(),
self
.unit
.get_tag()
.to_string()
.fg(self.unit.get_owner().get_color()),
);
if let Structures::Stone(stone) = self.structure
&& stone.get_durability() < stone.get_max_durability()
{
let percent: u32 =
u32::from(stone.get_durability()) * 100 / u32::from(stone.get_max_durability());
let middle = lines.len() / 2;
lines[middle] = Line::from(format!("{percent}%")).cyan();
pub fn set_zoom_level(&mut self, zoom_level: ZoomLevel) -> &mut Self {
self.zoom_level = zoom_level;
self
}
if let Some(unit) = &self.unit
&& let Some(Tasks::Moving(task)) = unit.get_ref_tasks().front()
&& let Some(percent) = task.get_movement_progress()
{
let middle = lines.len() / 2;
lines[middle] = Line::from(format!("{percent}%")).fg(unit.get_owner().get_color());
}
lines
}
fn get_block(&self) -> Block<'_> {
Block::default()
.borders(Borders::ALL)
.style(Style::default().fg(self.fg_color()))
.title(self.display_coords())
.border_type(BorderType::Rounded)
}
pub fn get_structure(&self) -> Structures {
self.structure
}
pub fn get_option_unit(&self) -> Option<Units> {
self.unit.clone()
}
pub fn get_ref_option_unit(&self) -> &Option<Units> {
&self.unit
}
pub fn get_mut_option_unit(&mut self) -> &mut Option<Units> {
&mut self.unit
}
pub fn set_structure(&mut self, structure: Structures) -> &mut Self {
self.structure = structure;
self
}
pub fn get_marked(&self) -> bool {
self.marked
}
pub fn set_marked(&mut self, marked: bool) -> &mut Self {
self.marked = marked;
self
}
fn col_to_letters(&self) -> String {
let mut col: usize = self.col + 1;
let mut letters: Vec<char> = Vec::new();
while col > 0 {
letters.push((b'A' + ((col - 1) % 26) as u8) as char);
col = (col - 1) / 26;
}
letters.iter().rev().collect()
}
fn display_coords(&self) -> Span<'_> {
if self.selected || self.structure != Structures::Stone(Stone::new()) || self.marked {
format!("{}{}", self.col_to_letters(), self.row).fg(Color::Rgb(0, 128, 0))
} else {
"".to_span()
}
}
fn fg_color(&self) -> Color {
if self.marked && self.selected {
Color::Rgb(128, 0, 128)
} else if self.marked && !self.selected {
Color::Rgb(255, 0, 255)
} else if !self.marked && self.selected {
Color::Rgb(255, 255, 0)
} else {
match self.structure.get_color() {
Color::Rgb(red, green, blue) => {
Color::Rgb(red - red / 4, green - green / 4, blue - blue / 4)
}
color => color,
}
}
}
fn get_text_area(&self) -> Vec<Line<'_>> {
let mut lines: Vec<Line<'_>> = self.zoom_level.get_cell_text_area(
self.structure.get_tag(),
self.structure.get_level(),
self.unit
.get_tag()
.to_string()
.fg(self.unit.get_owner().get_color()),
);
if matches!(
self.structure,
Structures::Stone(_) | Structures::CollapsedTunnel(_)
) && self.structure.get_durability() < self.structure.get_max_durability()
{
let percent: u32 = u32::from(self.structure.get_durability()) * 100
/ u32::from(self.structure.get_max_durability());
let middle: usize = lines.len() / 2;
lines[middle] = Line::from(format!("{percent}%")).fg(Color::Rgb(0, 128, 128));
}
if let Structures::MineConstruction(site) = self.structure {
let middle: usize = lines.len() / 2;
lines[middle] =
Line::from(format!("{}%", site.progress_percent())).fg(Color::Rgb(0, 128, 128));
}
if let Some(unit) = &self.unit
&& let Some(Tasks::Moving(task)) = unit.get_ref_tasks().front()
&& let Some(percent) = task.get_movement_progress()
{
let middle = lines.len() / 2;
lines[middle] = Line::from(format!("{percent}%")).fg(unit.get_owner().get_color());
}
if let Structures::Base(base) = self.structure
&& base.queued_miners() > 0
{
let middle: usize = lines.len() / 2;
let percent: u16 = base.recruitment_progress();
let label: String = if self.zoom_level == ZoomLevel::ZoomedOut && percent == 100 {
percent.to_string()
} else {
format!("{percent}%")
};
lines[middle] = Line::from(label).fg(Color::Rgb(0, 128, 128));
}
if let Structures::Tunnel(tunnel) = self.structure
&& let Some(percent) = tunnel.support_progress()
{
let middle: usize = lines.len() / 2;
lines[middle] = Line::from(format!("{percent}%")).fg(Color::Rgb(0, 128, 128));
}
lines
}
fn get_block(&self) -> Block<'_> {
Block::default()
.borders(Borders::ALL)
.style(Style::default().fg(self.fg_color()))
.title(self.display_coords())
.border_type(BorderType::Rounded)
}
}
impl Widget for CellWidget {
fn render(self, area: Rect, buf: &mut Buffer) {
Paragraph::new(self.get_text_area())
.alignment(Alignment::Center)
.block(self.get_block())
.render(area, buf);
}
fn render(self, area: Rect, buf: &mut Buffer) {
Paragraph::new(self.get_text_area())
.alignment(Alignment::Center)
.block(self.get_block())
.render(area, buf);
}
}
+137 -110
View File
@@ -1,14 +1,14 @@
use crate::app::{
helpers::block_single_title_helper,
keybindings::{Action, Group, KeyBinding, binding_for},
helpers::block_single_title_helper,
keybindings::{Action, Group, KeyBinding, binding_for},
};
use clap::ValueEnum;
use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Stylize},
text::Line,
widgets::{Block, Borders, Padding, Paragraph, Widget},
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Stylize},
text::Line,
widgets::{Block, Borders, Padding, Paragraph, Widget},
};
use std::collections::HashSet;
@@ -18,118 +18,145 @@ use std::collections::HashSet;
/// logical group of keybindings (e.g. navigation, editing, etc.). The paragraphs
/// are rendered side‑by‑side inside a bordered container.
pub struct KeybindingsWidget {
/// Paragraphs, one per used `Group`, containing the formatted keybinding lines.
grouped: Vec<Paragraph<'static>>,
/// Paragraphs, one per used `Group`, containing the formatted keybinding lines.
grouped: Vec<Paragraph<'static>>,
widths: Vec<u16>,
}
impl KeybindingsWidget {
/// Create a new widget from a list of actions.
///
/// For each action, the corresponding static [`KeyBinding`] is looked up
/// using [`binding_for`]. Only groups that have at least one binding are
/// displayed. The resulting widget contains a paragraph for each group,
/// formatted with the key symbol (red & bold) followed by its description.
///
/// # Parameters
///
/// * `actions` – A slice of actions for which keybindings should be shown.
/// * `area_height` – The height of the area in which the widget will be rendered.
/// This value is used to calculate vertical padding so that the keybinding rows
/// are vertically centered when the widget's allocated height is larger than the
/// number of lines needed to display the bindings.
///
/// # Returns
///
/// A fully‑initialised `KeybindingsWidget` ready for rendering.
pub fn new(actions: &[Action], area_height: u16) -> Self {
let keybindings: Vec<Option<&'static KeyBinding>> =
actions.iter().map(|a| binding_for(*a)).collect();
let used_groups: HashSet<Group> = keybindings
.iter()
.filter_map(|b| b.map(|binding| binding.group))
.collect();
let mut grouped_keybindings: Vec<Paragraph<'static>> = Vec::new();
for group in Group::value_variants().iter() {
if !used_groups.contains(group) {
continue;
}
let grouped_lines: Vec<Line<'_>> = keybindings
.iter()
.filter(|b| b.as_ref().map_or(false, |kb| kb.group == *group))
.filter_map(|b| {
b.as_ref().map(|kb| {
Line::from_iter([kb.symbol.red().bold(), " - ".into(), kb.description.into()])
})
})
.collect();
let height_diff: u16 =
area_height.saturating_sub(grouped_lines.len().saturating_add(2) as u16);
let padding_top: u16 = if height_diff <= 1 {
height_diff
} else {
height_diff / 2
};
let mut block: Block<'_> = Block::default().padding(Padding::new(1, 1, padding_top, 0));
if !grouped_keybindings.is_empty() {
block = block.borders(Borders::LEFT);
}
grouped_keybindings.push(
Paragraph::new(grouped_lines)
.alignment(Alignment::Center)
.block(block),
);
/// Create a new widget from a list of actions.
///
/// For each action, the corresponding static [`KeyBinding`] is looked up
/// using [`binding_for`]. Only groups that have at least one binding are
/// displayed. The resulting widget contains a paragraph for each group,
/// formatted with the key symbol (red & bold) followed by its description.
///
/// # Parameters
///
/// * `actions` – A slice of actions for which keybindings should be shown.
/// * `area_height` – The height of the area in which the widget will be rendered.
/// This value is used to calculate vertical padding so that the keybinding rows
/// are vertically centered when the widget's allocated height is larger than the
/// number of lines needed to display the bindings.
///
/// # Returns
///
/// A fully‑initialised `KeybindingsWidget` ready for rendering.
pub fn new(actions: &[Action], area_height: u16) -> Self {
Self::with_descriptions(actions, area_height, &[])
}
Self {
grouped: grouped_keybindings,
pub fn with_descriptions(
actions: &[Action],
area_height: u16,
descriptions: &[(Action, &'static str)],
) -> Self {
let keybindings: Vec<Option<&'static KeyBinding>> =
actions.iter().map(|a| binding_for(*a)).collect();
let used_groups: HashSet<Group> = keybindings
.iter()
.filter_map(|b| b.map(|binding| binding.group))
.collect();
let mut grouped_keybindings: Vec<Paragraph<'static>> = Vec::new();
let mut widths: Vec<u16> = Vec::new();
for group in Group::value_variants().iter() {
if !used_groups.contains(group) {
continue;
}
let grouped_lines: Vec<Line<'_>> = keybindings
.iter()
.filter(|b| b.as_ref().map_or(false, |kb| kb.group == *group))
.filter_map(|b| {
b.as_ref().map(|kb| {
let description: &'static str = descriptions
.iter()
.find(|(action, _): &&(Action, &'static str)| -> bool {
*action == kb.action
})
.map_or(
kb.description,
|(_, description): &(Action, &'static str)| -> &'static str {
description
},
);
Line::from_iter([
kb.symbol.fg(Color::Rgb(128, 0, 0)).bold(),
" - ".into(),
description.into(),
])
})
})
.collect();
let width: usize = grouped_lines.iter().map(Line::width).max().unwrap_or(0);
widths.push(u16::try_from(width).unwrap_or(u16::MAX).saturating_add(3));
let height_diff: u16 =
area_height.saturating_sub(grouped_lines.len().saturating_add(2) as u16);
let padding_top: u16 = if height_diff <= 1 {
height_diff
} else {
height_diff / 2
};
let mut block: Block<'_> = Block::default().padding(Padding::new(1, 1, padding_top, 0));
if !grouped_keybindings.is_empty() {
block = block.borders(Borders::LEFT);
}
grouped_keybindings.push(
Paragraph::new(grouped_lines)
.alignment(Alignment::Center)
.block(block),
);
}
Self {
grouped: grouped_keybindings,
widths,
}
}
}
}
impl Widget for KeybindingsWidget {
/// Render the widget inside the given rectangular area.
///
/// The widget first draws a surrounding border with the title *Keybindings*.
/// It then divides the inner area into equal horizontal slices—one per
/// used group—and renders each group's paragraph into its slice.
///
/// If no groups are present, the function returns early without drawing
/// anything.
fn render(self, area: Rect, buf: &mut Buffer) {
let count: u16 = self.grouped.len() as u16;
/// Render the widget inside the given rectangular area.
///
/// The widget first draws a surrounding border with the title *Keybindings*.
/// It then divides the inner area proportionally to each group's text width—one per
/// used group—and renders each group's paragraph into its slice.
///
/// If no groups are present, the function returns early without drawing
/// anything.
fn render(self, area: Rect, buf: &mut Buffer) {
let count: u16 = self.grouped.len() as u16;
if count == 0 {
return;
if count == 0 {
return;
}
let block: Block<'_> =
Block::default()
.borders(Borders::ALL)
.title(block_single_title_helper(
"Keybindings".to_string(),
Color::Rgb(128, 0, 128),
));
let inner: Rect = block.inner(area);
block.render(area, buf);
let constraints: Vec<Constraint> = self.widths.into_iter().map(Constraint::Fill).collect();
let chunks: Vec<Rect> = Layout::horizontal(constraints).split(inner).to_vec();
for (paragraph, chunk) in self.grouped.into_iter().zip(chunks.into_iter()) {
paragraph.render(chunk, buf);
}
}
let block: Block<'_> = Block::default()
.borders(Borders::ALL)
.title(block_single_title_helper(
"Keybindings".to_string(),
Color::Magenta,
));
let inner: Rect = block.inner(area);
block.render(area, buf);
let base: u16 = if count == 0 { 0 } else { 100u16 / count };
let constraints: Vec<Constraint> = vec![Constraint::Percentage(base); count as usize];
let chunks: Vec<Rect> = Layout::horizontal(constraints).split(inner).to_vec();
for (paragraph, chunk) in self.grouped.into_iter().zip(chunks.into_iter()) {
paragraph.render(chunk, buf);
}
}
}
+102 -80
View File
@@ -1,108 +1,130 @@
use crate::app::{
helpers::block_single_title_helper,
states::skirmish_states::{
structures::{Structure, Structures},
units::{Unit, Units},
},
helpers::block_single_title_helper,
states::skirmish_states::{
structures::{Structure, Structures},
units::{Unit, Units},
},
};
use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Margin, Rect},
style::Color,
widgets::{Block, BorderType, Borders, Padding, Paragraph, Widget},
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Margin, Rect},
style::Color,
text::Line,
widgets::{Block, BorderType, Borders, Padding, Paragraph, Widget},
};
#[derive(Debug, Clone, PartialEq)]
pub struct SidePanelWidget<'a> {
coords: (usize, usize),
structure: &'a Structures,
unit: &'a Option<Units>,
is_unit_marked: bool,
}
impl<'a> SidePanelWidget<'a> {
pub fn new(
coords: (usize, usize),
structure: &'a Structures,
unit: &'a Option<Units>,
is_unit_marked: bool,
) -> Self {
Self {
coords,
structure,
unit,
is_unit_marked,
}
}
}
fn col_to_letters(&self) -> String {
let mut col: usize = self.coords.1 + 1;
let mut letters: Vec<char> = Vec::new();
while col > 0 {
letters.push((b'A' + ((col - 1) % 26) as u8) as char);
col = (col - 1) / 26;
impl<'a> SidePanelWidget<'a> {
pub fn new(
coords: (usize, usize),
structure: &'a Structures,
unit: &'a Option<Units>,
is_unit_marked: bool,
) -> Self {
Self {
coords,
structure,
unit,
is_unit_marked,
}
}
letters.iter().rev().collect()
}
fn col_to_letters(&self) -> String {
let mut col: usize = self.coords.1 + 1;
let mut letters: Vec<char> = Vec::new();
fn get_outer_block(&self) -> Block<'_> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Double)
.title(block_single_title_helper(
format!("{}{}", self.col_to_letters(), self.coords.0),
Color::Yellow,
))
.title_alignment(Alignment::Center)
}
while col > 0 {
letters.push((b'A' + ((col - 1) % 26) as u8) as char);
col = (col - 1) / 26;
}
fn get_inner_block<'b>(&self, title: String, color: Color) -> Block<'b> {
Block::default()
.borders(Borders::TOP)
.title(block_single_title_helper(title, color))
.title_alignment(Alignment::Center)
.padding(Padding::symmetric(1, 1))
}
letters.iter().rev().collect()
}
fn get_area_constraints(&self) -> [Constraint; 2] {
if self.unit.is_unit() {
[Constraint::Percentage(50), Constraint::Percentage(50)]
} else {
[Constraint::Percentage(100), Constraint::Percentage(0)]
fn get_outer_block(&self) -> Block<'_> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Double)
.title(block_single_title_helper(
format!("{}{}", self.col_to_letters(), self.coords.0),
Color::Rgb(128, 128, 0),
))
.title_alignment(Alignment::Center)
}
fn get_inner_block<'b>(&self, title: String, color: Color) -> Block<'b> {
Block::default()
.borders(Borders::TOP)
.title(block_single_title_helper(title, color))
.title_alignment(Alignment::Center)
.padding(Padding::symmetric(1, 1))
}
fn get_area_constraints(&self) -> [Constraint; 2] {
if self.unit.is_unit() {
[Constraint::Percentage(50), Constraint::Percentage(50)]
} else {
[Constraint::Percentage(100), Constraint::Percentage(0)]
}
}
}
}
impl Widget for SidePanelWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let block: Block<'_> = self.get_outer_block();
let inner_area: Rect = block.inner(area).inner(Margin {
horizontal: 2,
vertical: 1,
});
block.render(area, buf);
fn render(self, area: Rect, buf: &mut Buffer) {
let block: Block<'_> = self.get_outer_block();
let inner_area: Rect = block.inner(area).inner(Margin {
horizontal: 2,
vertical: 1,
});
block.render(area, buf);
let [structure_area, unit_area] =
Layout::vertical(self.get_area_constraints()).areas(inner_area);
let [structure_area, unit_area] =
Layout::vertical(self.get_area_constraints()).areas(inner_area);
Paragraph::new(self.structure.text())
.alignment(Alignment::Left)
.block(self.get_inner_block(self.structure.get_name().to_string(), Color::Cyan))
.render(structure_area, buf);
let mut structure_text: Vec<Line<'_>> = self.structure.text();
if let Structures::Base(base) = self.structure
&& base.queued_miners() > 0
&& self.unit.is_some()
{
structure_text.push(Line::from(base.recruitment_status(true)));
}
if let Structures::Mine(mine) = self.structure {
let staffed: bool = self
.unit
.as_ref()
.is_some_and(|unit: &Units| -> bool { mine.accepts_unit(unit) });
structure_text.push(Line::from(format!(
"Metal: {}/s{}",
mine.production_per_cycle(staffed),
if staffed { " (Miner)" } else { "" }
)));
}
Paragraph::new(structure_text)
.alignment(Alignment::Left)
.block(self.get_inner_block(
self.structure.get_name().to_string(),
Color::Rgb(0, 128, 128),
))
.render(structure_area, buf);
if self.unit.is_unit() {
let mut unit_name: String = self.unit.get_name().to_string();
if self.unit.is_unit() {
let mut unit_name: String = self.unit.get_name().to_string();
if self.is_unit_marked {
unit_name = format!("> {} <", unit_name);
}
if self.is_unit_marked {
unit_name = format!("> {} <", unit_name);
}
Paragraph::new(self.unit.text())
.alignment(Alignment::Left)
.block(self.get_inner_block(unit_name, Color::Green))
.render(unit_area, buf);
Paragraph::new(self.unit.text())
.alignment(Alignment::Left)
.block(self.get_inner_block(unit_name, Color::Rgb(0, 128, 0)))
.render(unit_area, buf);
}
}
}
}
+95 -94
View File
@@ -1,13 +1,13 @@
use crate::{
app::{
states::{
PerkDecks,
skirmish_states::{GameMode, ZoomLevel},
app::{
states::{
PerkDecks,
skirmish_states::{GameMode, ZoomLevel},
},
threads::Soundtrack,
view::View,
},
threads::Soundtrack,
view::View,
},
logs::init_logger,
logs::init_logger,
};
use clap::{Error, Parser, error::ErrorKind, value_parser};
use std::num::ParseFloatError;
@@ -21,8 +21,8 @@ use std::num::ParseFloatError;
#[derive(Parser, Debug, Clone)]
#[command(version, about = "War in Tunnels", long_about = "War in Tunnels")]
pub struct Cli {
/// The initial view/window to display.
#[arg(
/// The initial view/window to display.
#[arg(
long,
short,
help = "Default window",
@@ -30,20 +30,20 @@ pub struct Cli {
default_value_t = View::MainMenu,
value_enum
)]
pub view: View,
pub view: View,
/// Player name shown in the UI.
#[arg(
long,
short,
help = "Username",
value_name = "String",
default_value = "Player"
)]
pub username: String,
/// Player name shown in the UI.
#[arg(
long,
short,
help = "Username",
value_name = "String",
default_value = "Player"
)]
pub username: String,
/// Game mode selection.
#[arg(
/// Game mode selection.
#[arg(
long,
short,
help = "Game mode",
@@ -51,10 +51,10 @@ pub struct Cli {
default_value_t = GameMode::LastManStanding,
value_enum
)]
pub game_mode: GameMode,
pub game_mode: GameMode,
/// Width of the generated map (36–100 tiles).
#[arg(
/// Width of the generated map (36–100 tiles).
#[arg(
long,
short = 'W',
help = "Map width",
@@ -62,10 +62,10 @@ pub struct Cli {
default_value = "42",
value_parser = value_parser!(u8).range(36..=108)
)]
pub map_width: u8,
pub map_width: u8,
/// Height of the generated map (11–50 tiles).
#[arg(
/// Height of the generated map (11–50 tiles).
#[arg(
long,
short = 'H',
help = "Map height",
@@ -73,10 +73,10 @@ pub struct Cli {
default_value = "11",
value_parser = value_parser!(u8).range(11..=39)
)]
pub map_height: u8,
pub map_height: u8,
/// Zoom level used for the UI.
#[arg(
/// Zoom level used for the UI.
#[arg(
long,
short,
help = "Zoom level",
@@ -84,10 +84,10 @@ pub struct Cli {
default_value_t = ZoomLevel::Default,
value_enum
)]
pub zoom_level: ZoomLevel,
pub zoom_level: ZoomLevel,
/// Which perk deck to use.
#[arg(
/// Which perk deck to use.
#[arg(
long,
short,
help = "Perk Deck",
@@ -95,10 +95,10 @@ pub struct Cli {
default_value_t = PerkDecks::Silesian,
value_enum
)]
pub perk_deck: PerkDecks,
pub perk_deck: PerkDecks,
/// Starting amount of wood (1–150).
#[arg(
/// Starting amount of wood (1–150).
#[arg(
long,
short = 'w',
help = "Starting wood",
@@ -106,21 +106,22 @@ pub struct Cli {
default_value = "50",
value_parser = value_parser!(u16).range(1..=150)
)]
pub starting_wood: u16,
pub starting_wood: u16,
/// Starting amount of iron (1–150).
#[arg(
/// Starting amount of metal (1–150).
#[arg(
long,
short = 'i',
help = "Starting iron",
help = "Starting metal",
alias = "starting-iron",
value_name = "Positive integer [1; 150]",
default_value = "25",
value_parser = value_parser!(u16).range(1..=150)
)]
pub starting_iron: u16,
pub starting_metal: u16,
/// Maximum amount of units (49–149).
#[arg(
/// Maximum amount of units (49–149).
#[arg(
long,
short = 'L',
help = "Supply limit",
@@ -128,10 +129,10 @@ pub struct Cli {
default_value = "99",
value_parser = value_parser!(u8).range(49..=149)
)]
pub supply_limit: u8,
pub supply_limit: u8,
/// Modifier applied to experience points (0.5–2.0).
#[arg(
/// Modifier applied to experience points (0.5–2.0).
#[arg(
long,
short,
help = "XP modifier",
@@ -139,10 +140,10 @@ pub struct Cli {
default_value = "1.0",
value_parser = parse_xp
)]
pub xp_modifier: f32,
pub xp_modifier: f32,
/// Upper bound for skill points that can be earned (90–690).
#[arg(
/// Upper bound for skill points that can be earned (90–690).
#[arg(
long,
short = 'P',
help = "Skill points limit",
@@ -150,10 +151,10 @@ pub struct Cli {
default_value = "120",
value_parser = value_parser!(u16).range(90..=690)
)]
pub skill_points_limit: u16,
pub skill_points_limit: u16,
/// Which soundtrack to play (default: default)
#[arg(
/// Which soundtrack to play (default: default)
#[arg(
long,
short = 'S',
help = "Soundtrack",
@@ -161,34 +162,34 @@ pub struct Cli {
default_value_t = Soundtrack::Default,
value_enum
)]
pub soundtrack: Soundtrack,
pub soundtrack: Soundtrack,
/// Disable soundtrack (default: disabled).
#[arg(
long,
short = 'D',
help = "Disable soundtrack (default: disable)",
default_value_t = false
)]
pub disable_audio: bool,
/// Disable soundtrack (default: disabled).
#[arg(
long,
short = 'D',
help = "Disable soundtrack (default: disable)",
default_value_t = false
)]
pub disable_audio: bool,
/// Mute soundtrack (default: disabled).
#[arg(
long,
short = 'm',
help = "Mute soundtrack (default: disabled)",
default_value_t = false
)]
pub mute: bool,
/// Mute soundtrack (default: disabled).
#[arg(
long,
short = 'm',
help = "Mute soundtrack (default: disabled)",
default_value_t = false
)]
pub mute: bool,
/// Enable logging to a file (default: disabled).
#[arg(
long,
short = 'l',
help = "Enable logging to file (default: disabled)",
default_value_t = false
)]
pub log: bool,
/// Enable logging to a file (default: disabled).
#[arg(
long,
short = 'l',
help = "Enable logging to file (default: disabled)",
default_value_t = false
)]
pub log: bool,
}
/// Parses the command line arguments and returns a fully populated `Cli`
@@ -196,13 +197,13 @@ pub struct Cli {
/// handles argument validation and displays helpful error messages if
/// the user supplies invalid input.
pub fn get_args() -> Cli {
let args: Cli = Cli::parse();
let args: Cli = Cli::parse();
if args.log {
init_logger();
}
if args.log {
init_logger();
}
args
args
}
/// Parses a string into a floating‑point XP modifier and validates that it
@@ -217,15 +218,15 @@ pub fn get_args() -> Cli {
/// * Returns an error if the string cannot be parsed as `f32`.
/// * Returns an error if the parsed value is outside the allowed range.
fn parse_xp(s: &str) -> Result<f32, Error> {
let v: f32 = s
.parse()
.map_err(|e: ParseFloatError| Error::raw(ErrorKind::InvalidValue, e.to_string()))?;
if (0.5..=2.0).contains(&v) {
Ok(v)
} else {
Err(Error::raw(
ErrorKind::InvalidValue,
format!("Value {} is out of range 0.5..=2.0", v),
))
}
let v: f32 = s
.parse()
.map_err(|e: ParseFloatError| Error::raw(ErrorKind::InvalidValue, e.to_string()))?;
if (0.5..=2.0).contains(&v) {
Ok(v)
} else {
Err(Error::raw(
ErrorKind::InvalidValue,
format!("Value {} is out of range 0.5..=2.0", v),
))
}
}
+6 -6
View File
@@ -28,10 +28,10 @@ use std::fs::File;
/// }
/// ```
pub fn init_logger() {
WriteLogger::init(
LevelFilter::Info,
Config::default(),
File::create("war_in_tunnels.log").expect("Failed to create log file"),
)
.expect("Failed to initialize logger");
WriteLogger::init(
LevelFilter::Info,
Config::default(),
File::create("war_in_tunnels.log").expect("Failed to create log file"),
)
.expect("Failed to initialize logger");
}
+23 -23
View File
@@ -1,15 +1,15 @@
use ratatui::{Terminal, crossterm::event::KeyEvent, prelude::CrosstermBackend};
use std::{
io::{Result, Stdout},
sync::mpsc::channel,
thread,
io::{Result, Stdout},
sync::mpsc::channel,
thread,
};
use war_in_tunnels::{
app::{
App,
threads::{AudioCmd, handle_audio, handle_ct_events, handle_tick_event},
},
cli::{Cli, get_args},
app::{
App,
threads::{AudioCmd, handle_audio, handle_ct_events, handle_tick_event},
},
cli::{Cli, get_args},
};
/// Starts the terminal UI application.
@@ -18,25 +18,25 @@ use war_in_tunnels::{
/// It returns any `std::io::Error` encountered while initializing or restoring the
/// terminal, or while running the `App`.
fn main() -> Result<()> {
let args: Cli = get_args();
let args: Cli = get_args();
let (input_tx, input_rx) = channel::<KeyEvent>();
let (resize_tx, resize_rx) = channel::<(u16, u16)>();
let (tick_tx, tick_rx) = channel::<()>();
let (audio_tx, audio_rx) = channel::<AudioCmd>();
let (input_tx, input_rx) = channel::<KeyEvent>();
let (resize_tx, resize_rx) = channel::<(u16, u16)>();
let (tick_tx, tick_rx) = channel::<()>();
let (audio_tx, audio_rx) = channel::<AudioCmd>();
thread::spawn(move || handle_ct_events(input_tx, resize_tx));
thread::spawn(move || handle_tick_event(tick_tx));
if !args.disable_audio {
thread::spawn(move || handle_audio(audio_rx, args.mute, args.soundtrack));
}
thread::spawn(move || handle_ct_events(input_tx, resize_tx));
thread::spawn(move || handle_tick_event(tick_tx));
if !args.disable_audio {
thread::spawn(move || handle_audio(audio_rx, args.mute, args.soundtrack));
}
let mut terminal: Terminal<CrosstermBackend<Stdout>> = ratatui::init();
let mut app: App = App::new(args, audio_tx);
let mut terminal: Terminal<CrosstermBackend<Stdout>> = ratatui::init();
let mut app: App = App::new(args, audio_tx);
let app_result: Result<()> = app.run(&mut terminal, input_rx, resize_rx, tick_rx);
let app_result: Result<()> = app.run(&mut terminal, input_rx, resize_rx, tick_rx);
ratatui::restore();
ratatui::restore();
app_result
app_result
}
+118 -119
View File
@@ -1,162 +1,161 @@
use clap::Parser;
use ratatui::{
Terminal,
backend::TestBackend,
crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
layout::Rect,
Terminal,
backend::TestBackend,
crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
layout::Rect,
};
use std::sync::mpsc::{Receiver, channel};
use war_in_tunnels::{
app::{App, GameStates, View, handle_keybindings, threads::AudioCmd},
cli::Cli,
app::{App, GameStates, View, handle_keybindings, threads::AudioCmd},
cli::Cli,
};
fn app() -> (App, Receiver<AudioCmd>) {
let args = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
let (tx, rx) = channel();
let mut app = App::new(args, tx);
app.window_area = Rect::new(0, 0, 120, 40);
app.states = Some(GameStates::new(&app.args, &app.window_area));
(app, rx)
let args = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
let (tx, rx) = channel();
let mut app = App::new(args, tx);
app.window_area = Rect::new(0, 0, 120, 40);
app.states = Some(GameStates::new(&app.args, &app.window_area));
(app, rx)
}
fn press(app: &mut App, code: KeyCode) {
handle_keybindings(app, KeyEvent::new(code, KeyModifiers::NONE));
handle_keybindings(app, KeyEvent::new(code, KeyModifiers::NONE));
}
#[test]
fn menu_selection_stays_in_bounds_and_opens_each_view() {
let (mut app, _) = app();
for _ in 0..10 {
press(&mut app, KeyCode::Up);
}
assert_eq!(app.states_ref().unwrap().main_menu.selected_view, 1);
for view in [View::Skirmish, View::PerkDecks, View::SkillsConfig] {
press(&mut app, KeyCode::Char(' '));
assert_eq!(app.view, view);
press(&mut app, KeyCode::Esc);
assert_eq!(app.view, View::MainMenu);
press(&mut app, KeyCode::Down);
}
for _ in 0..10 {
press(&mut app, KeyCode::Down);
}
assert_eq!(app.states_ref().unwrap().main_menu.selected_view, 3);
let (mut app, _) = app();
for _ in 0..10 {
press(&mut app, KeyCode::Up);
}
assert_eq!(app.states_ref().unwrap().main_menu.selected_view, 1);
for view in [View::Skirmish, View::PerkDecks, View::SkillsConfig] {
press(&mut app, KeyCode::Char(' '));
assert_eq!(app.view, view);
press(&mut app, KeyCode::Esc);
assert_eq!(app.view, View::MainMenu);
press(&mut app, KeyCode::Down);
}
for _ in 0..10 {
press(&mut app, KeyCode::Down);
}
assert_eq!(app.states_ref().unwrap().main_menu.selected_view, 3);
}
#[test]
fn quit_keys_work_in_every_view() {
for view in [
View::MainMenu,
View::Skirmish,
View::PerkDecks,
View::SkillsConfig,
] {
for key in [
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
for view in [
View::MainMenu,
View::Skirmish,
View::PerkDecks,
View::SkillsConfig,
] {
let (mut app, _) = app();
app.view = view;
handle_keybindings(&mut app, key);
assert!(app.exit);
for key in [
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
] {
let (mut app, _) = app();
app.view = view;
handle_keybindings(&mut app, key);
assert!(app.exit);
}
}
}
}
#[test]
fn key_release_does_not_trigger_quit() {
let (mut app, _) = app();
handle_keybindings(
&mut app,
KeyEvent::new_with_kind(
KeyCode::Char('q'),
KeyModifiers::NONE,
KeyEventKind::Release,
),
);
assert!(!app.exit);
let (mut app, _) = app();
handle_keybindings(
&mut app,
KeyEvent::new_with_kind(
KeyCode::Char('q'),
KeyModifiers::NONE,
KeyEventKind::Release,
),
);
assert!(!app.exit);
}
#[test]
fn music_keys_send_commands_without_opening_audio_device() {
let (mut app, rx) = app();
press(&mut app, KeyCode::Char('m'));
press(&mut app, KeyCode::Char('b'));
press(&mut app, KeyCode::Char('n'));
assert!(matches!(rx.try_recv().unwrap(), AudioCmd::Mute));
assert!(matches!(rx.try_recv().unwrap(), AudioCmd::VolumeUp));
assert!(matches!(rx.try_recv().unwrap(), AudioCmd::VolumeDown));
drop(rx);
press(&mut app, KeyCode::Char('m'));
assert!(!app.exit);
let (mut app, rx) = app();
press(&mut app, KeyCode::Char('m'));
press(&mut app, KeyCode::Char('+'));
press(&mut app, KeyCode::Char('-'));
assert!(matches!(rx.try_recv().unwrap(), AudioCmd::Mute));
assert!(matches!(rx.try_recv().unwrap(), AudioCmd::VolumeUp));
assert!(matches!(rx.try_recv().unwrap(), AudioCmd::VolumeDown));
drop(rx);
press(&mut app, KeyCode::Char('m'));
assert!(!app.exit);
}
#[test]
fn skirmish_keys_move_focus_toggle_panel_and_limit_zoom() {
let (mut app, _) = app();
app.view = View::Skirmish;
press(&mut app, KeyCode::Right);
assert_eq!(
app
.states_ref()
.unwrap()
.skirmish
.board
.get_focused_cell()
.get_coords(),
(5, 4)
);
press(&mut app, KeyCode::Tab);
assert!(app.states_ref().unwrap().skirmish.side_panel);
press(&mut app, KeyCode::Tab);
assert!(!app.states_ref().unwrap().skirmish.side_panel);
for _ in 0..4 {
press(&mut app, KeyCode::Char(','));
}
assert_eq!(
app.states_ref().unwrap().skirmish.board.zoom_level,
war_in_tunnels::app::states::skirmish_states::ZoomLevel::ZoomedIn
);
for _ in 0..4 {
press(&mut app, KeyCode::Char('.'));
}
assert_eq!(
app.states_ref().unwrap().skirmish.board.zoom_level,
war_in_tunnels::app::states::skirmish_states::ZoomLevel::ZoomedOut
);
let (mut app, _) = app();
app.view = View::Skirmish;
press(&mut app, KeyCode::Right);
assert_eq!(
app.states_ref()
.unwrap()
.skirmish
.board
.get_focused_cell()
.get_coords(),
(5, 4)
);
press(&mut app, KeyCode::Tab);
assert!(app.states_ref().unwrap().skirmish.side_panel);
press(&mut app, KeyCode::Tab);
assert!(!app.states_ref().unwrap().skirmish.side_panel);
for _ in 0..4 {
press(&mut app, KeyCode::Char(','));
}
assert_eq!(
app.states_ref().unwrap().skirmish.board.zoom_level,
war_in_tunnels::app::states::skirmish_states::ZoomLevel::ZoomedIn
);
for _ in 0..4 {
press(&mut app, KeyCode::Char('.'));
}
assert_eq!(
app.states_ref().unwrap().skirmish.board.zoom_level,
war_in_tunnels::app::states::skirmish_states::ZoomLevel::ZoomedOut
);
}
#[test]
fn all_views_render_without_real_terminal() {
let (mut app, _) = app();
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
for view in [
View::MainMenu,
View::Skirmish,
View::PerkDecks,
View::SkillsConfig,
] {
app.view = view;
let before = app.states.clone();
terminal
.draw(|frame| frame.render_widget(&mut app, frame.area()))
.unwrap();
let buffer = terminal.backend().buffer();
let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
assert!(!text.trim().is_empty(), "{view:?}");
if matches!(view, View::PerkDecks | View::SkillsConfig) {
assert!(text.contains("Work in progress"));
let (mut app, _) = app();
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
for view in [
View::MainMenu,
View::Skirmish,
View::PerkDecks,
View::SkillsConfig,
] {
app.view = view;
let before = app.states.clone();
terminal
.draw(|frame| frame.render_widget(&mut app, frame.area()))
.unwrap();
let buffer = terminal.backend().buffer();
let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
assert!(!text.trim().is_empty(), "{view:?}");
if matches!(view, View::PerkDecks | View::SkillsConfig) {
assert!(text.contains("Work in progress"));
}
assert_eq!(app.states, before);
}
assert_eq!(app.states, before);
}
}
#[test]
fn common_keys_work_before_states_are_initialized() {
let (mut app, _) = app();
app.states = None;
press(&mut app, KeyCode::Down);
press(&mut app, KeyCode::Char('q'));
assert!(app.exit);
let (mut app, _) = app();
app.states = None;
press(&mut app, KeyCode::Down);
press(&mut app, KeyCode::Char('q'));
assert!(app.exit);
}
+56 -10
View File
@@ -1,19 +1,65 @@
use rodio::Decoder;
use std::{
fs::read,
io::{BufReader, Cursor},
fs::{self, read},
io::{BufReader, Cursor},
path::PathBuf,
process::{Command, Output},
};
use war_in_tunnels::app::threads::{SoundrackParts, Soundtrack, load_audio};
#[test]
fn audio_repeats_after_end_of_file() {
let data = read("soundtracks/default/calm.ogg").unwrap();
let sample_count = Decoder::new(BufReader::new(Cursor::new(data)))
.unwrap()
.count();
assert!(sample_count > 0);
let data = read("soundtracks/default/calm.ogg").unwrap();
let sample_count = Decoder::new(BufReader::new(Cursor::new(data)))
.unwrap()
.count();
assert!(sample_count > 0);
let mut source = load_audio(&Soundtrack::Default, &SoundrackParts::Calm);
assert_eq!(source.by_ref().take(sample_count).count(), sample_count);
assert_eq!(source.take(1024).count(), 1024);
let mut source = load_audio(&Soundtrack::Default, &SoundrackParts::Calm);
assert_eq!(source.by_ref().take(sample_count).count(), sample_count);
assert_eq!(source.take(1024).count(), 1024);
}
#[test]
fn packaged_audio_loads_without_project_working_directory() -> () {
if std::env::var_os("WAR_IN_TUNNELS_PACKAGED_AUDIO_TEST").is_some() {
let mut source: rodio::source::Amplify<
rodio::decoder::LoopedDecoder<BufReader<Cursor<Vec<u8>>>>,
> = load_audio(&Soundtrack::Default, &SoundrackParts::Calm);
assert_eq!(source.by_ref().take(1024).count(), 1024);
return;
}
let directory: PathBuf = std::env::temp_dir().join(format!(
"war-in-tunnels-packaged-audio-{}",
std::process::id()
));
fs::create_dir_all(directory.join("soundtracks/default")).unwrap();
fs::create_dir_all(directory.join("working-directory")).unwrap();
let executable: PathBuf = directory.join("audio-test");
fs::copy(std::env::current_exe().unwrap(), &executable).unwrap();
for filename in ["calm.ogg", "test.ogg"] {
fs::copy(
PathBuf::from("soundtracks/default").join(filename),
directory.join("soundtracks/default").join(filename),
)
.unwrap();
}
let output: Output = Command::new(executable)
.args([
"--exact",
"packaged_audio_loads_without_project_working_directory",
"--nocapture",
])
.env("WAR_IN_TUNNELS_PACKAGED_AUDIO_TEST", "1")
.current_dir(directory.join("working-directory"))
.output()
.unwrap();
fs::remove_dir_all(directory).unwrap();
assert!(
output.status.success(),
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
+119 -115
View File
@@ -1,151 +1,155 @@
use ratatui::layout::Rect;
use war_in_tunnels::app::states::skirmish_states::{
BoardState, FocusedCell, MoveFocusedCell, Offset, Players, ZoomLevel,
structures::{Structure, Structures},
tasks::Task,
units::Unit,
BoardState, FocusedCell, MoveFocusedCell, Offset, Players, ZoomLevel,
structures::{Structure, Structures},
tasks::Task,
units::Unit,
};
fn board() -> BoardState {
BoardState::new(&Rect::new(0, 0, 120, 40), 42, 11, ZoomLevel::Default, false)
BoardState::new(&Rect::new(0, 0, 120, 40), 42, 11, ZoomLevel::Default, false)
}
#[test]
fn map_has_opposing_bases_ore_and_two_miners() {
let board = board();
assert_eq!(board.cells.len(), 11);
assert!(board.cells.iter().all(|row| row.len() == 42));
for (col, owner, ore_col) in [(2, Players::Player, 1), (39, Players::Enemy, 40)] {
let cell = board.get_ref_cell(5, col);
assert!(matches!(cell.get_structure(), Structures::Base(_)));
assert_eq!(cell.get_structure().get_owner(), owner);
let unit = cell.get_ref_option_unit().as_ref().unwrap();
assert_eq!(unit.get_coords(), (5, col));
assert_eq!(unit.get_owner(), owner);
let board = board();
assert_eq!(board.cells.len(), 11);
assert!(board.cells.iter().all(|row| row.len() == 42));
for (col, owner, ore_col) in [(2, Players::Player, 1), (39, Players::Enemy, 40)] {
let cell = board.get_ref_cell(5, col);
assert!(matches!(cell.get_structure(), Structures::Base(_)));
assert_eq!(cell.get_structure().get_owner(), owner);
let unit = cell.get_ref_option_unit().as_ref().unwrap();
assert_eq!(unit.get_coords(), (5, col));
assert_eq!(unit.get_owner(), owner);
assert!(matches!(
board.get_ref_cell(5, ore_col).get_structure(),
Structures::Ore(_)
));
assert_eq!(
board.get_ref_cell(5, ore_col).get_structure().get_owner(),
Players::Unclaimed
);
}
assert_eq!(
board
.cells
.iter()
.flatten()
.filter(|c| c.get_ref_option_unit().is_some())
.count(),
2
);
assert!(matches!(
board.get_ref_cell(5, ore_col).get_structure(),
Structures::Ore(_)
board.get_ref_cell(0, 0).get_structure(),
Structures::Stone(_)
));
}
assert_eq!(
board
.cells
.iter()
.flatten()
.filter(|c| c.get_ref_option_unit().is_some())
.count(),
2
);
assert!(matches!(
board.get_ref_cell(0, 0).get_structure(),
Structures::Stone(_)
));
}
#[test]
fn focus_cannot_leave_map_even_with_extreme_coordinates() {
let mut focus = FocusedCell::new(0, 0, 11, 42);
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Up), (0, 0));
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Left), (0, 0));
focus.set_focused_cell((usize::MAX, usize::MAX));
assert_eq!(focus.get_coords(), (10, 41));
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Down), (10, 41));
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Right), (10, 41));
let mut focus = FocusedCell::new(0, 0, 11, 42);
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Up), (0, 0));
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Left), (0, 0));
focus.set_focused_cell((usize::MAX, usize::MAX));
assert_eq!(focus.get_coords(), (10, 41));
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Down), (10, 41));
assert_eq!(focus.move_focused_cell(MoveFocusedCell::Right), (10, 41));
}
#[test]
fn offsets_remain_within_bounds_when_limits_change() {
let mut offset = Offset::new(Some(100), Some(5));
assert_eq!(offset.get_value(), 5);
offset.next();
assert_eq!(offset.get_value(), 5);
offset.set_max(2);
assert_eq!(offset.get_value(), 2);
offset.set_value(usize::MAX);
assert_eq!(offset.get_value(), 2);
for _ in 0..4 {
offset.prev();
}
assert_eq!(offset.get_value(), 0);
let mut offset = Offset::new(Some(100), Some(5));
assert_eq!(offset.get_value(), 5);
offset.next();
assert_eq!(offset.get_value(), 5);
offset.set_max(2);
assert_eq!(offset.get_value(), 2);
offset.set_value(usize::MAX);
assert_eq!(offset.get_value(), 2);
for _ in 0..4 {
offset.prev();
}
assert_eq!(offset.get_value(), 0);
}
#[test]
fn confirming_path_queues_task_without_including_start_cell() {
let mut board = board();
board.change_focused_cell(MoveFocusedCell::Left);
board.toggle_marking();
board.change_focused_cell(MoveFocusedCell::Right);
board.change_focused_cell(MoveFocusedCell::Down);
board.toggle_marking();
let unit = board
.get_ref_cell(5, 2)
.get_ref_option_unit()
.as_ref()
.unwrap();
let tasks = unit.get_ref_tasks();
assert_eq!(tasks.len(), 1);
assert_eq!(
tasks[0].get_path().iter().copied().collect::<Vec<_>>(),
[(5, 3), (6, 3)]
);
assert!(!board.marked_cells.marking_cells);
assert!(board.get_marked_cells().is_empty());
assert!(board.cells.iter().flatten().all(|c| !c.get_marked()));
let mut board = board();
board.change_focused_cell(MoveFocusedCell::Left);
board.toggle_marking();
board.change_focused_cell(MoveFocusedCell::Right);
board.change_focused_cell(MoveFocusedCell::Down);
board.toggle_marking();
let unit = board
.get_ref_cell(5, 2)
.get_ref_option_unit()
.as_ref()
.unwrap();
let tasks = unit.get_ref_tasks();
assert_eq!(tasks.len(), 1);
assert_eq!(
tasks[0].get_path().iter().copied().collect::<Vec<_>>(),
[(5, 3), (6, 3)]
);
assert!(!board.marked_cells.marking_cells);
assert!(board.get_marked_cells().is_empty());
assert!(board.cells.iter().flatten().all(|c| !c.get_marked()));
}
#[test]
fn cancelling_path_clears_marks_without_queuing_task() {
let mut board = board();
board.change_focused_cell(MoveFocusedCell::Left);
board.toggle_marking();
board.change_focused_cell(MoveFocusedCell::Right);
board.marked_cells.marking_cells = false;
board.end_marking_cells(true);
assert!(
board
.get_ref_cell(5, 2)
.get_ref_option_unit()
.as_ref()
.unwrap()
.get_ref_tasks()
.is_empty()
);
assert!(board.get_marked_cells().is_empty());
assert!(board.marked_cells.selected_unit.is_none());
assert!(board.cells.iter().flatten().all(|c| !c.get_marked()));
let mut board = board();
board.change_focused_cell(MoveFocusedCell::Left);
board.toggle_marking();
board.change_focused_cell(MoveFocusedCell::Right);
board.marked_cells.marking_cells = false;
board.end_marking_cells(true);
assert!(
board
.get_ref_cell(5, 2)
.get_ref_option_unit()
.as_ref()
.unwrap()
.get_ref_tasks()
.is_empty()
);
assert!(board.get_marked_cells().is_empty());
assert!(board.marked_cells.selected_unit.is_none());
assert!(board.cells.iter().flatten().all(|c| !c.get_marked()));
}
#[test]
fn backtracking_revisited_cell_preserves_earlier_mark() {
let mut board = board();
board.change_focused_cell(MoveFocusedCell::Left);
board.toggle_marking();
board.change_focused_cell(MoveFocusedCell::Right);
board.change_focused_cell(MoveFocusedCell::Left);
board.pop_marked_cell();
assert_eq!(board.get_focused_cell().get_coords(), (5, 3));
assert!(board.get_ref_cell(5, 2).get_marked());
board.pop_marked_cell();
board.pop_marked_cell();
assert_eq!(board.get_focused_cell().get_coords(), (5, 2));
assert_eq!(board.get_marked_cells().len(), 1);
assert!(!board.get_ref_cell(5, 3).get_marked());
let mut board = board();
board.change_focused_cell(MoveFocusedCell::Left);
board.toggle_marking();
board.change_focused_cell(MoveFocusedCell::Right);
board.change_focused_cell(MoveFocusedCell::Left);
board.pop_marked_cell();
assert_eq!(board.get_focused_cell().get_coords(), (5, 3));
assert!(board.get_ref_cell(5, 2).get_marked());
board.pop_marked_cell();
board.pop_marked_cell();
assert_eq!(board.get_focused_cell().get_coords(), (5, 2));
assert_eq!(board.get_marked_cells().len(), 1);
assert!(!board.get_ref_cell(5, 3).get_marked());
}
#[test]
fn large_viewport_and_zoom_keep_camera_inside_map() {
for zoom in [
ZoomLevel::ZoomedIn,
ZoomLevel::Default,
ZoomLevel::ZoomedOut,
] {
let mut board = BoardState::new(&Rect::new(0, 0, 240, 120), 36, 11, zoom, false);
assert_eq!(board.vertical_offset.get_value(), 0);
board.horizontal_offset.set_value(usize::MAX);
board.change_resize(&Rect::new(0, 0, 500, 120), false);
assert_eq!(board.horizontal_offset.get_value(), 0);
board.change_zoom(ZoomLevel::ZoomedOut);
assert!(board.is_focused_cell_visible());
assert_eq!(board.cells.len(), 11);
}
for zoom in [
ZoomLevel::ZoomedIn,
ZoomLevel::Default,
ZoomLevel::ZoomedOut,
] {
let mut board = BoardState::new(&Rect::new(0, 0, 240, 120), 36, 11, zoom, false);
assert_eq!(board.vertical_offset.get_value(), 0);
board.horizontal_offset.set_value(usize::MAX);
board.change_resize(&Rect::new(0, 0, 500, 120), false);
assert_eq!(board.horizontal_offset.get_value(), 0);
board.change_zoom(ZoomLevel::ZoomedOut);
assert!(board.is_focused_cell_visible());
assert_eq!(board.cells.len(), 11);
}
}
+72 -72
View File
@@ -4,94 +4,94 @@ use war_in_tunnels::{app::View, cli::Cli};
#[test]
fn defaults_start_in_menu_without_logging() {
let args = Cli::try_parse_from(["game"]).unwrap();
assert_eq!(args.view, View::MainMenu);
assert_eq!((args.map_width, args.map_height), (42, 11));
assert_eq!(args.username, "Player");
assert!(!args.log);
assert!(!args.disable_audio);
assert!(!args.mute);
let args = Cli::try_parse_from(["game"]).unwrap();
assert_eq!(args.view, View::MainMenu);
assert_eq!((args.map_width, args.map_height), (42, 11));
assert_eq!(args.username, "Player");
assert!(!args.log);
assert!(!args.disable_audio);
assert!(!args.mute);
}
#[test]
fn numeric_options_accept_boundaries_and_reject_out_of_range_values() {
for (flag, min, max) in [
("--map-width", 36, 108),
("--map-height", 11, 39),
("--starting-wood", 1, 150),
("--starting-iron", 1, 150),
("--supply-limit", 49, 149),
("--skill-points-limit", 90, 690),
] {
for value in [min, max] {
assert!(
Cli::try_parse_from(["game", flag, &value.to_string()]).is_ok(),
"{flag}={value}"
);
for (flag, min, max) in [
("--map-width", 36, 108),
("--map-height", 11, 39),
("--starting-wood", 1, 150),
("--starting-metal", 1, 150),
("--supply-limit", 49, 149),
("--skill-points-limit", 90, 690),
] {
for value in [min, max] {
assert!(
Cli::try_parse_from(["game", flag, &value.to_string()]).is_ok(),
"{flag}={value}"
);
}
for value in [min - 1, max + 1] {
assert!(
Cli::try_parse_from(["game", flag, &value.to_string()]).is_err(),
"{flag}={value}"
);
}
}
for value in [min - 1, max + 1] {
assert!(
Cli::try_parse_from(["game", flag, &value.to_string()]).is_err(),
"{flag}={value}"
);
}
}
}
#[test]
fn xp_modifier_rejects_non_finite_malformed_and_out_of_range_values() {
for value in ["0.5", "1", "2.0"] {
let args = Cli::try_parse_from(["game", "--xp-modifier", value]).unwrap();
assert_eq!(args.xp_modifier, value.parse::<f32>().unwrap());
}
for value in ["0.49", "2.01", "NaN", "inf", "text"] {
assert!(
Cli::try_parse_from(["game", "--xp-modifier", value]).is_err(),
"{value}"
);
}
for value in ["0.5", "1", "2.0"] {
let args = Cli::try_parse_from(["game", "--xp-modifier", value]).unwrap();
assert_eq!(args.xp_modifier, value.parse::<f32>().unwrap());
}
for value in ["0.49", "2.01", "NaN", "inf", "text"] {
assert!(
Cli::try_parse_from(["game", "--xp-modifier", value]).is_err(),
"{value}"
);
}
}
#[test]
fn enum_options_and_audio_flags_are_parsed() {
let args =
Cli::try_parse_from(["game", "--view", "skirmish", "--disable-audio", "--mute"]).unwrap();
assert_eq!(args.view, View::Skirmish);
assert!(args.disable_audio && args.mute);
for flag in [
"--view",
"--game-mode",
"--zoom-level",
"--perk-deck",
"--soundtrack",
] {
assert_eq!(
Cli::try_parse_from(["game", flag, "unknown"])
.unwrap_err()
.kind(),
ErrorKind::InvalidValue
);
}
let args =
Cli::try_parse_from(["game", "--view", "skirmish", "--disable-audio", "--mute"]).unwrap();
assert_eq!(args.view, View::Skirmish);
assert!(args.disable_audio && args.mute);
for flag in [
"--view",
"--game-mode",
"--zoom-level",
"--perk-deck",
"--soundtrack",
] {
assert_eq!(
Cli::try_parse_from(["game", flag, "unknown"])
.unwrap_err()
.kind(),
ErrorKind::InvalidValue
);
}
}
#[test]
fn binary_help_and_version_work_without_terminal_or_audio() {
for (flag, expected) in [("--help", "Usage:"), ("--version", "0.1.0")] {
for (flag, expected) in [("--help", "Usage:"), ("--version", "0.1.0")] {
let output = Command::new(env!("CARGO_BIN_EXE_war_in_tunnels"))
.arg(flag)
.output()
.unwrap();
assert!(output.status.success());
assert!(String::from_utf8(output.stdout).unwrap().contains(expected));
}
let output = Command::new(env!("CARGO_BIN_EXE_war_in_tunnels"))
.arg(flag)
.output()
.unwrap();
assert!(output.status.success());
assert!(String::from_utf8(output.stdout).unwrap().contains(expected));
}
let output = Command::new(env!("CARGO_BIN_EXE_war_in_tunnels"))
.args(["--map-width", "0"])
.output()
.unwrap();
assert!(!output.status.success());
assert!(
String::from_utf8(output.stderr)
.unwrap()
.contains("--map-width")
);
.args(["--map-width", "0"])
.output()
.unwrap();
assert!(!output.status.success());
assert!(
String::from_utf8(output.stderr)
.unwrap()
.contains("--map-width")
);
}
+429
View File
@@ -0,0 +1,429 @@
use clap::Parser;
use ratatui::{
Terminal,
backend::TestBackend,
buffer::Cell,
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect,
};
use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{
app::{
Action, App, GameStates, View, available_skirmish_actions, handle_keybindings,
states::{
SkirmishState,
skirmish_states::{
BoardState, MoveFocusedCell, Players, Resources, ZoomLevel,
resources::MINE_PRODUCTION_TICKS,
structures::{
BaseBuilding, BuildingTerrain, Mine, MineConstruction, Ore, Structure,
Structures, Tunnel,
},
tasks::Tasks,
units::{MinerUnit, Unit, Units},
},
},
threads::{AudioCmd, TICK_MS},
},
cli::Cli,
};
fn board() -> BoardState {
BoardState::new(&Rect::new(0, 0, 160, 50), 42, 11, ZoomLevel::Default, false)
}
fn complete_mine(board: &mut BoardState, coords: (usize, usize)) -> () {
assert!(board.start_mine_construction(coords, Players::Player));
for _ in 0..MineConstruction::BUILD_TICKS {
board.advance_construction();
}
assert!(matches!(
board.cells[coords.0][coords.1].get_structure(),
Structures::Mine(_)
));
}
fn app() -> App {
let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, tx);
app.view = View::Skirmish;
app.window_area = Rect::new(0, 0, 160, 50);
app.states = Some(GameStates::new(&app.args, &app.window_area));
app
}
fn travel(board: &mut BoardState, direction: MoveFocusedCell) -> () {
board.toggle_marking();
board.change_focused_cell(direction);
board.toggle_marking();
for _ in 0..3000_u16.div_ceil(u16::from(TICK_MS)) {
board.advance_turn();
}
}
#[test]
fn miner_has_builder_permission_through_all_unit_wrappers() -> () {
let miner: MinerUnit = MinerUnit::new(Players::Player, (5, 2));
assert!(miner.get_can_build());
let unit: Units = Units::Miner(miner);
assert!(unit.get_can_build());
assert!(Some(unit).get_can_build());
let no_unit: Option<Units> = None;
assert!(!no_unit.get_can_build());
}
#[test]
fn mine_requires_a_cardinal_friendly_builder_and_preserves_target_on_failure() -> () {
let mut board: BoardState = board();
let original: Structures = board.cells[5][1].get_structure();
board.cells[5][2].get_mut_option_unit().take();
assert!(!board.start_mine_construction((5, 1), Players::Player));
*board.cells[5][2].get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(Players::Enemy, (5, 2))));
assert!(!board.start_mine_construction((5, 1), Players::Player));
board.cells[5][2].get_mut_option_unit().take();
*board.cells[4][2].get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(Players::Player, (4, 2))));
assert!(!board.start_mine_construction((5, 1), Players::Player));
assert_eq!(board.cells[5][1].get_structure(), original);
assert!(!board.start_mine_construction((usize::MAX, 1), Players::Player));
assert!(!board.start_mine_construction((5, 1), Players::Unclaimed));
}
#[test]
fn ore_exception_does_not_require_a_base_or_tunnel_connection() -> () {
let mut board: BoardState = board();
board.cells[0][0].set_structure(Structures::Ore(Ore::new(Players::Unclaimed, 1)));
*board.cells[0][1].get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(Players::Player, (0, 1))));
assert!(board.can_build_at((0, 0), Players::Player, BuildingTerrain::Ore));
complete_mine(&mut board, (0, 0));
assert_eq!(
board.cells[0][0].get_structure().get_owner(),
Players::Player
);
assert!(!board.start_mine_construction((0, 0), Players::Player));
}
#[test]
fn ordinary_building_sites_require_stone_connected_to_base_or_tunnel() -> () {
let mut board: BoardState = board();
assert!(board.can_build_at((5, 3), Players::Player, BuildingTerrain::Stone));
assert!(!board.can_build_at((5, 3), Players::Player, BuildingTerrain::Ore));
*board.cells[5][5].get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(Players::Player, (5, 5))));
assert!(!board.can_build_at((5, 4), Players::Player, BuildingTerrain::Stone));
board.cells[5][5].set_structure(Structures::Tunnel(Tunnel::new()));
assert!(board.can_build_at((5, 4), Players::Player, BuildingTerrain::Stone));
board.cells[5][5].set_structure(Structures::Base(BaseBuilding::new(Players::Enemy)));
assert!(!board.can_build_at((5, 4), Players::Player, BuildingTerrain::Stone));
board.cells[5][3].set_structure(Structures::Tunnel(Tunnel::new()));
assert!(!board.can_build_at((5, 3), Players::Player, BuildingTerrain::Stone));
}
#[test]
fn occupied_depleted_or_claimed_deposit_cannot_be_built_on() -> () {
let mut board: BoardState = board();
*board.cells[5][1].get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(Players::Player, (5, 1))));
assert!(!board.start_mine_construction((5, 1), Players::Player));
board.cells[5][1].get_mut_option_unit().take();
let mut ore: Ore = Ore::new(Players::Unclaimed, 1);
ore.extract(5000);
board.cells[5][1].set_structure(Structures::Ore(ore));
assert!(!board.start_mine_construction((5, 1), Players::Player));
board.cells[5][1].set_structure(Structures::Ore(Ore::new(Players::Enemy, 1)));
assert!(!board.start_mine_construction((5, 1), Players::Player));
}
#[test]
fn construction_view_starts_paid_build_on_enter_and_stays_open() -> () {
let mut app: App = app();
for _ in 0..2 {
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
}
handle_keybindings(
&mut app,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
);
assert!(
available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Enter)
);
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 50)).unwrap();
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area());
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell: &Cell| -> &str { cell.symbol() })
.collect();
assert!(text.contains("Ready to build: Enter"));
assert!(text.contains("Enter - Build Mine"));
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let state: &SkirmishState = &app.states_ref().unwrap().skirmish;
assert!(state.building_view);
assert!(matches!(
state.board.cells[5][1].get_structure(),
Structures::MineConstruction(_)
));
assert_eq!(state.player_resources.wood, 30);
assert_eq!(state.player_resources.metal, 15);
}
#[test]
fn miner_can_travel_into_and_out_of_friendly_mine_with_moving_tasks() -> () {
let mut board: BoardState = board();
complete_mine(&mut board, (5, 1));
board.change_focused_cell(MoveFocusedCell::Left);
board.toggle_marking();
board.change_focused_cell(MoveFocusedCell::Left);
board.toggle_marking();
let unit: &Units = board.cells[5][2].get_ref_option_unit().as_ref().unwrap();
assert!(matches!(
unit.get_ref_tasks().front(),
Some(Tasks::Moving(_))
));
for _ in 1..3000_u16.div_ceil(u16::from(TICK_MS)) {
board.advance_turn();
}
assert!(board.cells[5][1].get_ref_option_unit().is_none());
board.advance_turn();
assert_eq!(board.cells[5][1].get_ref_option_unit().get_coords(), (5, 1));
assert!(board.cells[5][2].get_ref_option_unit().is_none());
travel(&mut board, MoveFocusedCell::Right);
assert!(board.cells[5][1].get_ref_option_unit().is_none());
assert_eq!(board.cells[5][2].get_ref_option_unit().get_coords(), (5, 2));
}
#[test]
fn staffed_mine_doubles_production_and_bonus_stops_after_departure() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
complete_mine(&mut game.board, (5, 1));
game.turn_counter = MINE_PRODUCTION_TICKS - 1;
game.tick_update();
assert_eq!(game.player_resources.metal, 26);
game.board.change_focused_cell(MoveFocusedCell::Left);
travel(&mut game.board, MoveFocusedCell::Left);
game.turn_counter = MINE_PRODUCTION_TICKS - 1;
game.tick_update();
assert_eq!(game.player_resources.metal, 28);
travel(&mut game.board, MoveFocusedCell::Right);
game.turn_counter = MINE_PRODUCTION_TICKS - 1;
game.tick_update();
assert_eq!(game.player_resources.metal, 29);
let Structures::Mine(mine) = game.board.cells[5][1].get_structure() else {
panic!("mine missing");
};
assert_eq!(mine.get_ore_amount(), 4996);
}
#[test]
fn mine_rejects_enemy_miners_and_staffing_does_not_overdraw_ore() -> () {
let mut ore: Ore = Ore::new(Players::Unclaimed, 1);
ore.extract(4999);
let mut mine: Mine = Mine::new(Players::Player, ore).unwrap();
let player: Units = Units::Miner(MinerUnit::new(Players::Player, (5, 2)));
let enemy: Units = Units::Miner(MinerUnit::new(Players::Enemy, (5, 2)));
assert!(mine.accepts_unit(&player));
assert!(!mine.accepts_unit(&enemy));
assert!(!Structures::Mine(mine).can_enter(&enemy));
assert_eq!(mine.produce_with_miner(true), 1);
assert_eq!(mine.produce_with_miner(true), 0);
}
#[test]
fn remote_ore_cannot_start_construction_or_consume_resources() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
let ore: Structures = Structures::Ore(Ore::new(Players::Unclaimed, 1));
game.board.cells[5][21].set_structure(ore);
let before: Resources = game.player_resources;
assert!(!game.start_mine_construction((5, 21), Players::Player));
assert_eq!(game.player_resources, before);
assert_eq!(game.board.cells[5][21].get_structure(), ore);
}
#[test]
fn construction_payment_is_atomic_and_exact_funds_are_enough() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
for resources in [
Resources {
wood: 19,
metal: 10,
},
Resources { wood: 20, metal: 9 },
] {
game.player_resources = resources;
assert!(!game.can_start_mine_construction((5, 1), Players::Player));
assert!(!game.start_mine_construction((5, 1), Players::Player));
assert_eq!(game.player_resources, resources);
assert!(matches!(
game.board.cells[5][1].get_structure(),
Structures::Ore(_)
));
}
game.player_resources = Mine::COST;
assert!(game.start_mine_construction((5, 1), Players::Player));
assert_eq!(game.player_resources, Resources { wood: 0, metal: 0 });
assert!(!game.start_mine_construction((5, 1), Players::Player));
}
#[test]
fn construction_takes_ten_seconds_and_cannot_produce_or_accept_miners_early() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
assert!(game.start_mine_construction((5, 1), Players::Player));
for _ in 1..MineConstruction::BUILD_TICKS {
game.tick_update();
}
let structure: Structures = game.board.cells[5][1].get_structure();
let Structures::MineConstruction(site) = structure else {
panic!("completed too early");
};
assert_eq!(site.progress_percent(), 99);
assert!(site.into_mine().is_none());
assert_eq!(site.cancel().get_ore_amount(), 5000);
assert!(
!structure.can_enter(
game.board.cells[5][2]
.get_ref_option_unit()
.as_ref()
.unwrap()
)
);
assert_eq!(game.player_resources.metal, 15);
game.tick_update();
assert!(matches!(
game.board.cells[5][1].get_structure(),
Structures::Mine(_)
));
assert_eq!(game.player_resources.metal, 15);
}
#[test]
fn construction_pauses_without_nearby_builder_and_resumes_after_return() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
assert!(game.start_mine_construction((5, 1), Players::Player));
for _ in 0..100 {
game.tick_update();
}
let before: Structures = game.board.cells[5][1].get_structure();
let builder: Option<Units> = game.board.cells[5][2].get_mut_option_unit().take();
for _ in 0..MineConstruction::BUILD_TICKS {
game.tick_update();
}
assert_eq!(game.board.cells[5][1].get_structure(), before);
assert_eq!(game.player_resources.metal, 15);
*game.board.cells[5][2].get_mut_option_unit() = builder;
for _ in 100..MineConstruction::BUILD_TICKS {
game.tick_update();
}
assert!(matches!(
game.board.cells[5][1].get_structure(),
Structures::Mine(_)
));
}
#[test]
fn cancellation_restores_original_ore_and_refunds_cost_exactly_once() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
let mut ore: Ore = Ore::new(Players::Unclaimed, 1);
ore.extract(123);
game.board.cells[5][1].set_structure(Structures::Ore(ore));
let before: Resources = game.player_resources;
assert!(game.start_mine_construction((5, 1), Players::Player));
for _ in 0..100 {
game.tick_update();
}
game.board.cells[5][2].get_mut_option_unit().take();
assert!(game.cancel_mine_construction((5, 1), Players::Player));
assert_eq!(game.player_resources, before);
assert_eq!(game.board.cells[5][1].get_structure(), Structures::Ore(ore));
assert!(!game.cancel_mine_construction((5, 1), Players::Player));
assert_eq!(game.player_resources, before);
}
#[test]
fn panel_shows_progress_delete_cancels_and_escape_is_ignored() -> () {
let mut app: App = app();
for _ in 0..2 {
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
}
handle_keybindings(
&mut app,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
);
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Delete)
);
assert!(
!available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Enter)
);
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.states_ref().unwrap().skirmish.player_resources.wood, 30);
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 50)).unwrap();
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area());
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell: &Cell| -> &str { cell.symbol() })
.collect();
assert!(text.contains("Cost: 20 Wood + 10 Metal"));
assert!(text.contains("Construction: 0%"));
assert!(text.contains("Delete - Cancel construction"));
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(app.states_ref().unwrap().skirmish.building_view);
assert!(matches!(
app.states_ref().unwrap().skirmish.board.cells[5][1].get_structure(),
Structures::MineConstruction(_)
));
handle_keybindings(
&mut app,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
);
assert!(!app.states_ref().unwrap().skirmish.building_view);
handle_keybindings(
&mut app,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
);
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
assert!(matches!(
app.states_ref().unwrap().skirmish.board.cells[5][1].get_structure(),
Structures::Ore(_)
));
assert_eq!(
app.states_ref().unwrap().skirmish.player_resources,
Resources::new(50, 25)
);
}
#[test]
fn player_cannot_cancel_enemy_site_or_refund_a_completed_mine() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
assert!(game.start_mine_construction((5, 40), Players::Enemy));
assert!(!game.cancel_mine_construction((5, 40), Players::Player));
assert_eq!(game.player_resources, Resources::new(50, 25));
for _ in 0..MineConstruction::BUILD_TICKS {
game.tick_update();
}
assert!(!game.cancel_mine_construction((5, 40), Players::Enemy));
assert_eq!(game.enemy_resources.metal, 15);
}
+254
View File
@@ -0,0 +1,254 @@
use clap::Parser;
use ratatui::{
Terminal,
backend::TestBackend,
buffer::Cell,
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect,
style::Color,
};
use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{
app::{
Action, App, GameStates, View, available_skirmish_actions, handle_keybindings,
states::skirmish_states::structures::Structures, threads::AudioCmd,
},
cli::Cli,
};
fn app() -> App {
let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, tx);
app.view = View::Skirmish;
app.window_area = Rect::new(0, 0, 160, 40);
app.states = Some(GameStates::new(&app.args, &app.window_area));
app
}
fn press(app: &mut App, code: KeyCode, modifiers: KeyModifiers) -> () {
handle_keybindings(app, KeyEvent::new(code, modifiers));
}
#[test]
fn selecting_miner_switches_help_and_escape_cancels_only_the_route() -> () {
let mut app: App = app();
assert!(
!available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Space)
);
press(&mut app, KeyCode::Left, KeyModifiers::NONE);
assert!(
available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Space)
);
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
let actions: Vec<Action> = available_skirmish_actions(&app.states_ref().unwrap().skirmish);
assert!(actions.contains(&Action::Backspace));
assert!(actions.contains(&Action::Delete));
assert!(!actions.contains(&Action::Building));
assert!(!actions.contains(&Action::ZoomIn));
press(&mut app, KeyCode::Char('b'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.building_view);
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.view, View::Skirmish);
assert!(
!app.states_ref()
.unwrap()
.skirmish
.board
.marked_cells
.marking_cells
);
assert!(
app.states_ref()
.unwrap()
.skirmish
.board
.get_marked_cells()
.is_empty()
);
}
#[test]
fn building_view_has_its_own_help_and_does_not_build_on_invalid_tile() -> () {
let mut app: App = app();
let before: war_in_tunnels::app::states::skirmish_states::BoardState =
app.states_ref().unwrap().skirmish.board.clone();
press(&mut app, KeyCode::Char('b'), KeyModifiers::NONE);
assert!(app.states_ref().unwrap().skirmish.building_view);
assert_eq!(
available_skirmish_actions(&app.states_ref().unwrap().skirmish),
[
Action::Building,
Action::Left,
Action::Right,
Action::Stress,
Action::Quit2
]
);
press(&mut app, KeyCode::Left, KeyModifiers::NONE);
press(&mut app, KeyCode::Right, KeyModifiers::NONE);
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
assert_eq!(app.states_ref().unwrap().skirmish.board, before);
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 40)).unwrap();
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area());
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell: &Cell| -> &str { cell.symbol() })
.collect();
assert!(text.contains("Construction unavailable"));
assert!(text.contains("Close buildings"));
assert!(!text.contains("Marking cells"));
assert!(!text.contains("Esc -"));
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert!(app.states_ref().unwrap().skirmish.building_view);
assert_eq!(app.view, View::Skirmish);
press(&mut app, KeyCode::Char('b'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.building_view);
assert_eq!(app.view, View::Skirmish);
for row in &app.states_ref().unwrap().skirmish.board.cells {
for cell in row {
assert!(!matches!(cell.get_structure(), Structures::Mine(_)));
}
}
}
#[test]
fn building_key_toggles_panel_and_old_shortcuts_do_not_open_it() -> () {
let mut app: App = app();
press(&mut app, KeyCode::Null, KeyModifiers::NONE);
press(&mut app, KeyCode::Char(' '), KeyModifiers::CONTROL);
press(&mut app, KeyCode::Char(' '), KeyModifiers::ALT);
assert!(!app.states_ref().unwrap().skirmish.building_view);
press(&mut app, KeyCode::Char('b'), KeyModifiers::NONE);
assert!(app.states_ref().unwrap().skirmish.building_view);
press(&mut app, KeyCode::Char('b'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.building_view);
press(&mut app, KeyCode::Char('b'), KeyModifiers::NONE);
press(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(app.exit);
}
#[test]
fn contextual_help_fits_normal_terminal_and_changes_when_route_is_selected() -> () {
let mut app: App = app();
app.window_area = Rect::new(0, 0, 120, 40);
press(&mut app, KeyCode::Left, KeyModifiers::NONE);
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(120, 40)).unwrap();
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area());
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell: &Cell| -> &str { cell.symbol() })
.collect();
assert!(text.contains("b - Build"));
assert!(text.contains("Space - Select miner"));
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area());
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell: &Cell| -> &str { cell.symbol() })
.collect();
assert!(text.contains("Space - Confirm route"));
assert!(text.contains("Esc - Cancel route"));
assert!(!text.contains("b - Build"));
assert!(!text.contains("Zoom in"));
}
#[test]
fn construction_and_recruitment_popups_render_semantic_text_colors() -> () {
for (key, name, status, status_color) in [
(
'b',
"Mine",
"Construction unavailable",
Color::Rgb(128, 0, 0),
),
('r', "Miner", "Enter: queue Miner", Color::Rgb(0, 128, 0)),
] {
let mut app: App = app();
if key == 'r' {
press(&mut app, KeyCode::Left, KeyModifiers::NONE);
}
press(&mut app, KeyCode::Char(key), KeyModifiers::NONE);
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 40)).unwrap();
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area());
})
.unwrap();
let cells: &[Cell] = &terminal.backend().buffer().content;
for (text, color) in [
(name, Color::Rgb(255, 0, 255)),
("Cost:", Color::Rgb(128, 128, 0)),
(status, status_color),
] {
assert!(
cells.windows(text.len()).any(|window: &[Cell]| -> bool {
window
.iter()
.map(|cell: &Cell| -> &str { cell.symbol() })
.collect::<String>()
== text
&& window
.iter()
.all(|cell: &Cell| -> bool { cell.fg == color })
}),
"missing colored text: {text}"
);
}
}
}
#[test]
fn popup_tabs_cycle_without_moving_map_or_changing_queues() -> () {
for key in ['b', 'r'] {
let mut app: App = app();
if key == 'r' {
press(&mut app, KeyCode::Left, KeyModifiers::NONE);
}
let before: war_in_tunnels::app::states::skirmish_states::BoardState =
app.states_ref().unwrap().skirmish.board.clone();
press(&mut app, KeyCode::Char(key), KeyModifiers::NONE);
for (code, index) in [
(KeyCode::Left, 1),
(KeyCode::Right, 0),
(KeyCode::Right, 1),
(KeyCode::Left, 0),
] {
press(&mut app, code, KeyModifiers::NONE);
let state: &war_in_tunnels::app::states::SkirmishState =
&app.states_ref().unwrap().skirmish;
assert_eq!(state.board, before);
assert_eq!(state.selected_building, if key == 'b' { index } else { 0 });
assert_eq!(state.selected_recruit, 0);
assert!(available_skirmish_actions(state).contains(&Action::Left));
assert!(available_skirmish_actions(state).contains(&Action::Right));
}
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.view, View::Skirmish);
press(&mut app, KeyCode::Char(key), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.building_view);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
}
}
+430 -427
View File
@@ -1,534 +1,537 @@
use clap::Parser;
use ratatui::{
Terminal,
backend::TestBackend,
buffer::Buffer,
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect,
widgets::Widget,
Terminal,
backend::TestBackend,
buffer::Buffer,
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect,
widgets::Widget,
};
use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{
app::{
App, GameStates, View, handle_keybindings,
states::{
SkirmishState,
skirmish_states::{
BoardState, CellSizes, MoveFocusedCell, Players, ZoomLevel,
structures::{Structure, Structures, Tunnel},
tasks::{DiggingTask, MovingTask, Task, Tasks},
units::{MinerUnit, Unit, Units},
},
app::{
App, GameStates, View, handle_keybindings,
states::{
SkirmishState,
skirmish_states::{
BoardState, CellSizes, MoveFocusedCell, Players, ZoomLevel,
structures::{Structure, Structures, Tunnel},
tasks::{DiggingTask, MovingTask, Task, Tasks},
units::{MinerUnit, Unit, Units},
},
},
threads::{AudioCmd, TICK_MS},
},
threads::{AudioCmd, TICK_MS},
},
cli::Cli,
cli::Cli,
};
fn game() -> SkirmishState {
let args = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
GameStates::new(&args, &Rect::new(0, 0, 120, 40)).skirmish
let args = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
GameStates::new(&args, &Rect::new(0, 0, 120, 40)).skirmish
}
fn queue(board: &mut BoardState, origin: (usize, usize), path: &[(usize, usize)]) {
let unit = board.cells[origin.0][origin.1]
.get_mut_option_unit()
.as_mut()
.unwrap();
let power = unit.get_digging_power();
unit.set_task(Tasks::Digging(DiggingTask::new(
path.iter().copied().collect(),
power,
)));
let unit = board.cells[origin.0][origin.1]
.get_mut_option_unit()
.as_mut()
.unwrap();
let power = unit.get_digging_power();
unit.set_task(Tasks::Digging(DiggingTask::new(
path.iter().copied().collect(),
power,
)));
}
fn tunnel(board: &mut BoardState, row: usize, col: usize) {
board.cells[row][col].set_structure(Structures::Tunnel(Tunnel::new()));
board.cells[row][col].set_structure(Structures::Tunnel(Tunnel::new()));
}
fn unit(board: &BoardState, row: usize, col: usize) -> &Units {
board.cells[row][col]
.get_ref_option_unit()
.as_ref()
.unwrap()
board.cells[row][col]
.get_ref_option_unit()
.as_ref()
.unwrap()
}
fn travel_ticks() -> u16 {
3000_u16.div_ceil(u16::from(TICK_MS))
3000_u16.div_ceil(u16::from(TICK_MS))
}
fn advance(game: &mut SkirmishState, ticks: u16) {
for _ in 0..ticks {
game.tick_update();
}
for _ in 0..ticks {
game.tick_update();
}
}
#[test]
fn tick_damages_stone_before_moving_and_finishes_entire_route() {
let mut game = game();
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
game.tick_update();
assert_eq!(game.turn_counter, 1);
let durability = game.board.cells[5][3].get_structure().get_durability();
assert!(durability < 1000 && durability > 0);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
assert!(game.board.cells[5][3].get_ref_option_unit().is_none());
for _ in 0..500 {
let mut game = game();
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
game.tick_update();
}
for col in [3, 4] {
assert_eq!(game.turn_counter, 1);
let durability = game.board.cells[5][3].get_structure().get_durability();
assert!(durability < 1000 && durability > 0);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
assert!(game.board.cells[5][3].get_ref_option_unit().is_none());
for _ in 0..500 {
game.tick_update();
}
for col in [3, 4] {
assert!(matches!(
game.board.cells[5][col].get_structure(),
Structures::Tunnel(_)
));
}
assert!(matches!(
game.board.cells[5][col].get_structure(),
Structures::Tunnel(_)
game.board.cells[5][2].get_structure(),
Structures::Base(_)
));
}
assert!(matches!(
game.board.cells[5][2].get_structure(),
Structures::Base(_)
));
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
assert!(game.board.cells[5][2].get_ref_option_unit().is_none());
assert!(game.board.cells[5][3].get_ref_option_unit().is_none());
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
assert!(game.board.cells[5][2].get_ref_option_unit().is_none());
assert!(game.board.cells[5][3].get_ref_option_unit().is_none());
}
#[test]
fn each_tunnel_step_takes_three_seconds_in_both_directions() {
for path in [[(5, 3), (5, 4)], [(4, 2), (3, 2)]] {
let mut game = game();
for (row, col) in path {
tunnel(&mut game.board, row, col);
for path in [[(5, 3), (5, 4)], [(4, 2), (3, 2)]] {
let mut game = game();
for (row, col) in path {
tunnel(&mut game.board, row, col);
}
queue(&mut game.board, (5, 2), &path);
advance(&mut game, travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
game.tick_update();
assert_eq!(
unit(&game.board, path[0].0, path[0].1).get_coords(),
path[0]
);
assert_eq!(
unit(&game.board, path[0].0, path[0].1).get_ref_tasks()[0]
.get_path()
.len(),
1
);
advance(&mut game, travel_ticks() - 1);
assert_eq!(
unit(&game.board, path[0].0, path[0].1).get_coords(),
path[0]
);
game.tick_update();
assert!(
unit(&game.board, path[1].0, path[1].1)
.get_ref_tasks()
.is_empty()
);
}
queue(&mut game.board, (5, 2), &path);
advance(&mut game, travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
game.tick_update();
assert_eq!(
unit(&game.board, path[0].0, path[0].1).get_coords(),
path[0]
);
assert_eq!(
unit(&game.board, path[0].0, path[0].1).get_ref_tasks()[0]
.get_path()
.len(),
1
);
advance(&mut game, travel_ticks() - 1);
assert_eq!(
unit(&game.board, path[0].0, path[0].1).get_coords(),
path[0]
);
game.tick_update();
assert!(
unit(&game.board, path[1].0, path[1].1)
.get_ref_tasks()
.is_empty()
);
}
}
#[test]
fn queued_tasks_execute_in_order_and_keep_unit_coordinates_current() {
let mut game = game();
tunnel(&mut game.board, 5, 3);
tunnel(&mut game.board, 5, 4);
queue(&mut game.board, (5, 2), &[(5, 3)]);
queue(&mut game.board, (5, 2), &[(5, 4)]);
advance(&mut game, travel_ticks());
assert_eq!(unit(&game.board, 5, 3).get_ref_tasks().len(), 1);
advance(&mut game, travel_ticks());
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
queue(&mut game.board, (5, 4), &[(5, 3), (5, 2)]);
advance(&mut game, 2 * travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
game.tick_update();
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
assert!(matches!(
game.board.cells[5][2].get_structure(),
Structures::Base(_)
));
let mut game = game();
tunnel(&mut game.board, 5, 3);
tunnel(&mut game.board, 5, 4);
queue(&mut game.board, (5, 2), &[(5, 3)]);
queue(&mut game.board, (5, 2), &[(5, 4)]);
advance(&mut game, travel_ticks());
assert_eq!(unit(&game.board, 5, 3).get_ref_tasks().len(), 1);
advance(&mut game, travel_ticks());
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
queue(&mut game.board, (5, 4), &[(5, 3), (5, 2)]);
advance(&mut game, 2 * travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
game.tick_update();
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
assert!(matches!(
game.board.cells[5][2].get_structure(),
Structures::Base(_)
));
}
#[test]
fn occupied_tunnel_waits_without_losing_units_or_task() {
let mut game = game();
tunnel(&mut game.board, 5, 3);
*game.board.cells[5][3].get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(Players::Player, (5, 3))));
queue(&mut game.board, (5, 2), &[(5, 3)]);
for _ in 0..3 {
let mut game = game();
tunnel(&mut game.board, 5, 3);
*game.board.cells[5][3].get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(Players::Player, (5, 3))));
queue(&mut game.board, (5, 2), &[(5, 3)]);
for _ in 0..3 {
game.tick_update();
}
assert_eq!(unit(&game.board, 5, 2).get_ref_tasks().len(), 1);
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
game.board.cells[5][3].get_mut_option_unit().take();
advance(&mut game, travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
game.tick_update();
}
assert_eq!(unit(&game.board, 5, 2).get_ref_tasks().len(), 1);
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
game.board.cells[5][3].get_mut_option_unit().take();
advance(&mut game, travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
game.tick_update();
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
}
#[test]
fn ore_is_not_destroyed_by_digging_task() {
let mut game = game();
let ore = game.board.cells[5][1].get_structure();
queue(&mut game.board, (5, 2), &[(5, 1)]);
for _ in 0..200 {
game.tick_update();
}
assert_eq!(game.board.cells[5][1].get_structure(), ore);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
let mut game = game();
let ore = game.board.cells[5][1].get_structure();
queue(&mut game.board, (5, 2), &[(5, 1)]);
for _ in 0..200 {
game.tick_update();
}
assert_eq!(game.board.cells[5][1].get_structure(), ore);
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
}
#[test]
fn invalid_or_empty_routes_do_not_teleport_or_remove_miner() {
for path in [vec![], vec![(usize::MAX, usize::MAX)], vec![(0, 0)]] {
let mut game = game();
queue(&mut game.board, (5, 2), &path);
game.tick_update();
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
assert!(unit(&game.board, 5, 2).get_ref_tasks().is_empty());
}
for path in [vec![], vec![(usize::MAX, usize::MAX)], vec![(0, 0)]] {
let mut game = game();
queue(&mut game.board, (5, 2), &path);
game.tick_update();
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
assert!(unit(&game.board, 5, 2).get_ref_tasks().is_empty());
}
}
#[test]
fn only_player_miner_can_start_drawing_route() {
let mut game = game();
game.board.toggle_marking();
assert!(!game.board.marked_cells.marking_cells);
for _ in 0..36 {
game.board.change_focused_cell(MoveFocusedCell::Right);
}
assert_eq!(game.board.get_focused_cell().get_coords(), (5, 39));
game.board.toggle_marking();
assert!(!game.board.marked_cells.marking_cells);
assert!(game.board.get_marked_cells().is_empty());
let mut game = game();
game.board.toggle_marking();
assert!(!game.board.marked_cells.marking_cells);
for _ in 0..36 {
game.board.change_focused_cell(MoveFocusedCell::Right);
}
assert_eq!(game.board.get_focused_cell().get_coords(), (5, 39));
game.board.toggle_marking();
assert!(!game.board.marked_cells.marking_cells);
assert!(game.board.get_marked_cells().is_empty());
}
#[test]
fn drawing_route_pauses_selected_miner_and_boundary_moves_do_not_repeat_cells() {
let mut game = game();
game.board.change_focused_cell(MoveFocusedCell::Left);
queue(&mut game.board, (5, 2), &[(5, 3)]);
game.board.toggle_marking();
game.tick_update();
assert_eq!(
game.board.cells[5][3].get_structure().get_durability(),
1000
);
for _ in 0..10 {
game.board.change_focused_cell(MoveFocusedCell::Up);
}
let coords: Vec<_> = game.board.get_marked_cells().iter().copied().collect();
assert!(coords.windows(2).all(|pair| pair[0] != pair[1]));
game.board.end_marking_cells(true);
game.tick_update();
assert!(game.board.cells[5][3].get_structure().get_durability() < 1000);
let mut game = game();
game.board.change_focused_cell(MoveFocusedCell::Left);
queue(&mut game.board, (5, 2), &[(5, 3)]);
game.board.toggle_marking();
game.tick_update();
assert_eq!(
game.board.cells[5][3].get_structure().get_durability(),
1000
);
for _ in 0..10 {
game.board.change_focused_cell(MoveFocusedCell::Up);
}
let coords: Vec<_> = game.board.get_marked_cells().iter().copied().collect();
assert!(coords.windows(2).all(|pair| pair[0] != pair[1]));
game.board.end_marking_cells(true);
game.tick_update();
assert!(game.board.cells[5][3].get_structure().get_durability() < 1000);
}
#[test]
fn drawing_another_task_extends_the_end_of_queued_work() {
let mut game = game();
for col in [3, 4, 5] {
tunnel(&mut game.board, 5, col);
}
game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.toggle_marking();
game.board.change_focused_cell(MoveFocusedCell::Right);
game.board.change_focused_cell(MoveFocusedCell::Right);
game.board.toggle_marking();
game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.toggle_marking();
assert_eq!(game.board.get_focused_cell().get_coords(), (5, 4));
game.board.change_focused_cell(MoveFocusedCell::Right);
game.board.toggle_marking();
assert_eq!(unit(&game.board, 5, 2).get_ref_tasks().len(), 2);
advance(&mut game, 3 * travel_ticks());
assert_eq!(unit(&game.board, 5, 5).get_coords(), (5, 5));
assert!(unit(&game.board, 5, 5).get_ref_tasks().is_empty());
let mut game = game();
for col in [3, 4, 5] {
tunnel(&mut game.board, 5, col);
}
game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.toggle_marking();
game.board.change_focused_cell(MoveFocusedCell::Right);
game.board.change_focused_cell(MoveFocusedCell::Right);
game.board.toggle_marking();
game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.toggle_marking();
assert_eq!(game.board.get_focused_cell().get_coords(), (5, 4));
game.board.change_focused_cell(MoveFocusedCell::Right);
game.board.toggle_marking();
assert_eq!(unit(&game.board, 5, 2).get_ref_tasks().len(), 2);
advance(&mut game, 3 * travel_ticks());
assert_eq!(unit(&game.board, 5, 5).get_coords(), (5, 5));
assert!(unit(&game.board, 5, 5).get_ref_tasks().is_empty());
}
#[test]
fn freshly_dug_cell_is_entered_immediately_then_tunnel_travel_takes_three_seconds() {
let mut game = game();
tunnel(&mut game.board, 5, 4);
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
for _ in 0..200 {
game.tick_update();
if matches!(
game.board.cells[5][3].get_structure(),
Structures::Tunnel(_)
) {
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
advance(&mut game, travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
game.tick_update();
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
return;
let mut game = game();
tunnel(&mut game.board, 5, 4);
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
for _ in 0..200 {
game.tick_update();
if matches!(
game.board.cells[5][3].get_structure(),
Structures::Tunnel(_)
) {
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
advance(&mut game, travel_ticks() - 1);
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
game.tick_update();
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
return;
}
}
}
panic!("miner did not finish digging the stone");
panic!("miner did not finish digging the stone");
}
#[test]
fn miner_symbol_in_tunnel_uses_owner_color_even_when_selected_or_marked() {
for owner in [Players::Player, Players::Enemy] {
for zoom in [ZoomLevel::Default, ZoomLevel::ZoomedIn] {
for (selected, marked) in [(false, false), (true, false), (false, true), (true, true)] {
let mut game = game();
let cell = &mut game.board.cells[5][3];
cell.set_structure(Structures::Tunnel(Tunnel::new()));
cell
.set_selected(selected)
.set_marked(marked)
.set_zoom_level(zoom);
*cell.get_mut_option_unit() = Some(Units::Miner(MinerUnit::new(owner, (5, 3))));
let area = Rect::new(
0,
0,
zoom.get_cell_size(CellSizes::Width) as u16,
zoom.get_cell_size(CellSizes::Height) as u16,
);
let mut buffer = Buffer::empty(area);
cell.clone().render(area, &mut buffer);
let marker = buffer
.content
.iter()
.find(|cell| cell.symbol() == "M")
.unwrap();
assert_eq!(marker.fg, owner.get_color(), "{owner:?} {zoom:?}");
assert!(buffer.content.iter().any(|cell| cell.symbol() == "T"));
}
for owner in [Players::Player, Players::Enemy] {
for zoom in [ZoomLevel::Default, ZoomLevel::ZoomedIn] {
for (selected, marked) in [(false, false), (true, false), (false, true), (true, true)] {
let mut game = game();
let cell = &mut game.board.cells[5][3];
cell.set_structure(Structures::Tunnel(Tunnel::new()));
cell.set_selected(selected)
.set_marked(marked)
.set_zoom_level(zoom);
*cell.get_mut_option_unit() = Some(Units::Miner(MinerUnit::new(owner, (5, 3))));
let area = Rect::new(
0,
0,
zoom.get_cell_size(CellSizes::Width) as u16,
zoom.get_cell_size(CellSizes::Height) as u16,
);
let mut buffer = Buffer::empty(area);
cell.clone().render(area, &mut buffer);
let marker = buffer
.content
.iter()
.find(|cell| cell.symbol() == "M")
.unwrap();
assert_eq!(marker.fg, owner.get_color(), "{owner:?} {zoom:?}");
assert!(buffer.content.iter().any(|cell| cell.symbol() == "T"));
}
}
}
}
}
#[test]
fn tui_shows_movement_percentage_and_clears_it_after_each_step() {
for zoom in [
ZoomLevel::ZoomedIn,
ZoomLevel::Default,
ZoomLevel::ZoomedOut,
] {
let mut game = game();
for col in [2, 3, 4] {
tunnel(&mut game.board, 5, col);
for zoom in [
ZoomLevel::ZoomedIn,
ZoomLevel::Default,
ZoomLevel::ZoomedOut,
] {
let mut game = game();
for col in [2, 3, 4] {
tunnel(&mut game.board, 5, col);
}
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
let render = |board: &BoardState, col: usize| {
let area = Rect::new(
0,
0,
zoom.get_cell_size(CellSizes::Width) as u16,
zoom.get_cell_size(CellSizes::Height) as u16,
);
let mut buffer = Buffer::empty(area);
let mut cell = board.get_ref_cell(5, col).clone();
cell.set_zoom_level(zoom);
cell.render(area, &mut buffer);
buffer
};
let text = |buffer: &Buffer| -> String {
buffer.content.iter().map(|cell| cell.symbol()).collect()
};
assert!(!text(&render(&game.board, 2)).contains('%'));
advance(&mut game, 46);
let buffer = render(&game.board, 2);
assert!(text(&buffer).contains("50%"), "{zoom:?}");
if zoom != ZoomLevel::ZoomedOut {
let marker = buffer
.content
.iter()
.find(|cell| cell.symbol() == "M")
.unwrap();
assert_eq!(marker.fg, Players::Player.get_color());
}
advance(&mut game, travel_ticks() - 46);
assert!(!text(&render(&game.board, 2)).contains('%'));
assert!(!text(&render(&game.board, 3)).contains('%'));
game.tick_update();
assert!(text(&render(&game.board, 3)).contains("1%"));
advance(&mut game, travel_ticks() - 1);
assert!(!text(&render(&game.board, 4)).contains('%'));
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
}
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
let render = |board: &BoardState, col: usize| {
let area = Rect::new(
0,
0,
zoom.get_cell_size(CellSizes::Width) as u16,
zoom.get_cell_size(CellSizes::Height) as u16,
);
let mut buffer = Buffer::empty(area);
let mut cell = board.get_ref_cell(5, col).clone();
cell.set_zoom_level(zoom);
cell.render(area, &mut buffer);
buffer
};
let text =
|buffer: &Buffer| -> String { buffer.content.iter().map(|cell| cell.symbol()).collect() };
assert!(!text(&render(&game.board, 2)).contains('%'));
advance(&mut game, 46);
let buffer = render(&game.board, 2);
assert!(text(&buffer).contains("50%"), "{zoom:?}");
if zoom != ZoomLevel::ZoomedOut {
let marker = buffer
.content
.iter()
.find(|cell| cell.symbol() == "M")
.unwrap();
assert_eq!(marker.fg, Players::Player.get_color());
}
advance(&mut game, travel_ticks() - 46);
assert!(!text(&render(&game.board, 2)).contains('%'));
assert!(!text(&render(&game.board, 3)).contains('%'));
game.tick_update();
assert!(text(&render(&game.board, 3)).contains("1%"));
advance(&mut game, travel_ticks() - 1);
assert!(!text(&render(&game.board, 4)).contains('%'));
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
}
}
#[test]
fn confirmed_route_splits_into_moving_and_digging_segments() {
let mut game = game();
for col in [3, 4, 7] {
tunnel(&mut game.board, 5, col);
}
game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.toggle_marking();
for _ in 0..5 {
game.board.change_focused_cell(MoveFocusedCell::Right);
}
game.board.toggle_marking();
let tasks = unit(&game.board, 5, 2).get_ref_tasks();
assert_eq!(tasks.len(), 3);
assert!(matches!(tasks[0], Tasks::Moving(_)));
assert_eq!(
tasks[0].get_path().iter().copied().collect::<Vec<_>>(),
[(5, 3), (5, 4)]
);
assert!(matches!(tasks[1], Tasks::Digging(_)));
assert_eq!(
tasks[1].get_path().iter().copied().collect::<Vec<_>>(),
[(5, 5), (5, 6)]
);
assert!(matches!(tasks[2], Tasks::Moving(_)));
assert_eq!(tasks[0].get_icon(), '→');
assert_eq!(tasks[1].get_icon(), '⛏');
advance(&mut game, 2 * travel_ticks());
assert!(matches!(
unit(&game.board, 5, 4).get_ref_tasks()[0],
Tasks::Digging(_)
));
advance(&mut game, 2 * 182);
assert!(matches!(
unit(&game.board, 5, 6).get_ref_tasks()[0],
Tasks::Moving(_)
));
advance(&mut game, travel_ticks());
assert!(unit(&game.board, 5, 7).get_ref_tasks().is_empty());
let mut game = game();
for col in [3, 4, 7] {
tunnel(&mut game.board, 5, col);
}
let mut supported: Tunnel = Tunnel::new();
supported.set_support(true);
game.board.cells[5][7].set_structure(Structures::Tunnel(supported));
game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.toggle_marking();
for _ in 0..5 {
game.board.change_focused_cell(MoveFocusedCell::Right);
}
game.board.toggle_marking();
let tasks = unit(&game.board, 5, 2).get_ref_tasks();
assert_eq!(tasks.len(), 3);
assert!(matches!(tasks[0], Tasks::Moving(_)));
assert_eq!(
tasks[0].get_path().iter().copied().collect::<Vec<_>>(),
[(5, 3), (5, 4)]
);
assert!(matches!(tasks[1], Tasks::Digging(_)));
assert_eq!(
tasks[1].get_path().iter().copied().collect::<Vec<_>>(),
[(5, 5), (5, 6)]
);
assert!(matches!(tasks[2], Tasks::Moving(_)));
assert_eq!(tasks[0].get_icon(), '→');
assert_eq!(tasks[1].get_icon(), '⛏');
advance(&mut game, 2 * travel_ticks());
assert!(matches!(
unit(&game.board, 5, 4).get_ref_tasks()[0],
Tasks::Digging(_)
));
advance(&mut game, 2 * 182);
assert!(matches!(
unit(&game.board, 5, 6).get_ref_tasks()[0],
Tasks::Moving(_)
));
advance(&mut game, travel_ticks());
assert!(unit(&game.board, 5, 7).get_ref_tasks().is_empty());
}
#[test]
fn moving_task_does_not_damage_walkable_structure() {
let mut game = game();
tunnel(&mut game.board, 5, 3);
let structure = game.board.cells[5][3].get_structure();
game.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
advance(&mut game, travel_ticks());
assert_eq!(game.board.cells[5][3].get_structure(), structure);
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
let mut game = game();
tunnel(&mut game.board, 5, 3);
let structure = game.board.cells[5][3].get_structure();
game.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
advance(&mut game, travel_ticks());
assert_eq!(game.board.cells[5][3].get_structure(), structure);
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
}
#[test]
fn previously_dug_route_becomes_moving_without_instant_steps() {
let mut game = game();
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4), (5, 3)]);
advance(&mut game, 2 * 182);
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
game.tick_update();
assert!(matches!(
unit(&game.board, 5, 4).get_ref_tasks()[0],
Tasks::Moving(_)
));
advance(&mut game, travel_ticks() - 2);
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
game.tick_update();
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
let mut game = game();
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4), (5, 3)]);
advance(&mut game, 2 * 182);
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
game.tick_update();
assert!(matches!(
unit(&game.board, 5, 4).get_ref_tasks()[0],
Tasks::Moving(_)
));
advance(&mut game, travel_ticks() - 2);
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
game.tick_update();
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
}
#[test]
fn route_reclassifies_as_digging_if_target_is_stone() {
let mut game = game();
game.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
game.tick_update();
assert!(matches!(
unit(&game.board, 5, 2).get_ref_tasks()[0],
Tasks::Digging(_)
));
assert!(game.board.cells[5][3].get_structure().get_durability() < 1000);
advance(&mut game, 181);
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
let mut game = game();
game.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
game.tick_update();
assert!(matches!(
unit(&game.board, 5, 2).get_ref_tasks()[0],
Tasks::Digging(_)
));
assert!(game.board.cells[5][3].get_structure().get_durability() < 1000);
advance(&mut game, 181);
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
}
#[test]
fn tab_panel_shows_current_step_and_total_for_active_and_pending_tasks() -> () {
let args: Cli = Cli::try_parse_from(["game", "--disable-audio", "--view", "skirmish"]).unwrap();
let channels: (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, channels.0);
app.window_area = Rect::new(0, 0, 120, 60);
app.states = Some(GameStates::new(&app.args, &app.window_area));
let state: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
tunnel(&mut state.board, 5, 3);
tunnel(&mut state.board, 5, 4);
state.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3), (5, 4)].into())));
state.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Digging(DiggingTask::new([(5, 5)].into(), 0.55)));
advance(state, travel_ticks() + 46);
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(120, 60)).unwrap();
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area());
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell: &ratatui::buffer::Cell| -> &str { cell.symbol() })
.collect();
assert!(text.contains("Step: 50% | Total: 75%"));
assert!(text.contains("Step: 0% | Total: 0%"));
let args: Cli = Cli::try_parse_from(["game", "--disable-audio", "--view", "skirmish"]).unwrap();
let channels: (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, channels.0);
app.window_area = Rect::new(0, 0, 120, 60);
app.states = Some(GameStates::new(&app.args, &app.window_area));
let state: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
tunnel(&mut state.board, 5, 3);
tunnel(&mut state.board, 5, 4);
state.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3), (5, 4)].into())));
state.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Digging(DiggingTask::new([(5, 5)].into(), 0.55)));
advance(state, travel_ticks() + 46);
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(120, 60)).unwrap();
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area());
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell: &ratatui::buffer::Cell| -> &str { cell.symbol() })
.collect();
assert!(text.contains("Step: 50% | Total: 75%"));
assert!(text.contains("Step: 0% | Total: 0%"));
}
#[test]
fn space_route_tick_and_tui_show_remaining_durability_at_every_zoom() {
let args = Cli::try_parse_from(["game", "--disable-audio", "--view", "skirmish"]).unwrap();
let (tx, _) = channel();
let mut app = App::new(args, tx);
app.window_area = Rect::new(0, 0, 120, 40);
app.states = Some(GameStates::new(&app.args, &app.window_area));
for code in [
KeyCode::Left,
KeyCode::Char(' '),
KeyCode::Right,
KeyCode::Char(' '),
] {
handle_keybindings(&mut app, KeyEvent::new(code, KeyModifiers::NONE));
}
app.states_mut().unwrap().skirmish.tick_update();
let durability = app.states_ref().unwrap().skirmish.board.cells[5][3]
.get_structure()
.get_durability();
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
for zoom in [
ZoomLevel::ZoomedIn,
ZoomLevel::Default,
ZoomLevel::ZoomedOut,
] {
let state = &mut app.states_mut().unwrap().skirmish;
state.board.change_zoom(zoom);
state.board.vertical_offset.set_value(5);
let args = Cli::try_parse_from(["game", "--disable-audio", "--view", "skirmish"]).unwrap();
let (tx, _) = channel();
let mut app = App::new(args, tx);
app.window_area = Rect::new(0, 0, 120, 40);
app.states = Some(GameStates::new(&app.args, &app.window_area));
for code in [
KeyCode::Left,
KeyCode::Char(' '),
KeyCode::Right,
KeyCode::Char(' '),
] {
handle_keybindings(&mut app, KeyEvent::new(code, KeyModifiers::NONE));
}
app.states_mut().unwrap().skirmish.tick_update();
let durability = app.states_ref().unwrap().skirmish.board.cells[5][3]
.get_structure()
.get_durability();
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
for zoom in [
ZoomLevel::ZoomedIn,
ZoomLevel::Default,
ZoomLevel::ZoomedOut,
] {
let state = &mut app.states_mut().unwrap().skirmish;
state.board.change_zoom(zoom);
state.board.vertical_offset.set_value(5);
terminal
.draw(|frame| frame.render_widget(&mut app, frame.area()))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("99%"), "{zoom:?}");
}
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
terminal
.draw(|frame| frame.render_widget(&mut app, frame.area()))
.unwrap();
.draw(|frame| frame.render_widget(&mut app, frame.area()))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("99%"), "{zoom:?}");
}
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
terminal
.draw(|frame| frame.render_widget(&mut app, frame.area()))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains(&format!("{durability}/1000")));
assert_eq!(app.view, View::Skirmish);
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains(&format!("{durability}/1000")));
assert_eq!(app.view, View::Skirmish);
}
+416
View File
@@ -0,0 +1,416 @@
use clap::Parser;
use ratatui::{
Terminal,
backend::TestBackend,
buffer::{Buffer, Cell},
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect,
style::Color,
widgets::Widget,
};
use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{
app::{
Action, App, GameStates, View, available_skirmish_actions, handle_keybindings,
states::{
SkirmishState,
skirmish_states::{
CellSizes, MoveFocusedCell, Players, Resources, ZoomLevel,
structures::{BaseBuilding, Structures, Tunnel},
units::{MinerUnit, Unit, Units},
},
},
threads::{AudioCmd, TICK_MS},
},
cli::Cli,
};
fn app() -> App {
let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, tx);
app.view = View::Skirmish;
app.window_area = Rect::new(0, 0, 160, 50);
app.states = Some(GameStates::new(&app.args, &app.window_area));
app
}
fn base(game: &SkirmishState, coords: (usize, usize)) -> BaseBuilding {
let Structures::Base(base) = game.board.cells[coords.0][coords.1].get_structure() else {
panic!("base missing");
};
base
}
fn press(app: &mut App, code: KeyCode, modifiers: KeyModifiers) -> () {
handle_keybindings(app, KeyEvent::new(code, modifiers));
}
#[test]
fn base_cell_shows_recruitment_progress_until_spawn_at_every_zoom() -> () {
for zoom in [
ZoomLevel::ZoomedIn,
ZoomLevel::Default,
ZoomLevel::ZoomedOut,
] {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
let render = |game: &SkirmishState| -> Buffer {
let area: Rect = Rect::new(
0,
0,
zoom.get_cell_size(CellSizes::Width) as u16,
zoom.get_cell_size(CellSizes::Height) as u16,
);
let mut buffer: Buffer = Buffer::empty(area);
game.board.cells[5][2]
.clone()
.set_zoom_level(zoom)
.clone()
.render(area, &mut buffer);
buffer
};
let text = |buffer: &Buffer| -> String {
buffer
.content
.iter()
.map(|cell: &Cell| -> &str { cell.symbol() })
.collect()
};
assert!(!text(&render(game)).contains('%'));
assert!(game.recruit_miner((5, 2), Players::Player));
assert!(text(&render(game)).contains("0%"));
for _ in 0..BaseBuilding::RECRUITMENT_TICKS / 2 {
game.tick_update();
}
let buffer: Buffer = render(game);
assert!(text(&buffer).contains("50%"));
assert_eq!(
buffer
.content
.iter()
.find(|cell: &&Cell| -> bool { cell.symbol() == "%" })
.unwrap()
.fg,
Color::Rgb(0, 128, 128)
);
if zoom != ZoomLevel::ZoomedOut {
assert!(text(&buffer).contains('M'));
}
for _ in 0..BaseBuilding::RECRUITMENT_TICKS {
game.tick_update();
}
assert!(
text(&render(game)).contains(if zoom == ZoomLevel::ZoomedOut {
"100"
} else {
"100%"
})
);
game.board.cells[5][2].get_mut_option_unit().take();
game.tick_update();
assert!(!text(&render(game)).contains('%'));
assert!(game.board.cells[5][2].get_ref_option_unit().is_some());
}
}
#[test]
fn recruitment_is_owned_by_base_and_invalid_requests_do_not_charge() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
let before: Resources = game.player_resources;
for coords in [(5, 39), (0, 0), (usize::MAX, 2)] {
assert!(!game.recruit_miner(coords, Players::Player));
assert_eq!(game.player_resources, before);
}
assert!(!game.recruit_miner((5, 2), Players::Unclaimed));
assert!(game.recruit_miner((5, 2), Players::Player));
assert_eq!(game.player_resources, Resources::new(40, 10));
assert_eq!(game.enemy_resources, Resources::new(50, 25));
assert_eq!(base(game, (5, 2)).queued_miners(), 1);
}
#[test]
fn recruitment_requires_both_resources_and_charges_each_queue_entry() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
for resources in [
Resources { wood: 9, metal: 15 },
Resources {
wood: 10,
metal: 14,
},
] {
game.player_resources = resources;
assert!(!game.can_recruit_miner((5, 2), Players::Player));
assert!(!game.recruit_miner((5, 2), Players::Player));
assert_eq!(game.player_resources, resources);
assert_eq!(base(game, (5, 2)).queued_miners(), 0);
}
game.player_resources = Resources::new(20, 30);
assert!(game.recruit_miner((5, 2), Players::Player));
assert!(game.recruit_miner((5, 2), Players::Player));
assert_eq!(base(game, (5, 2)).queued_miners(), 2);
assert_eq!(game.player_resources, Resources::new(0, 0));
assert!(!game.recruit_miner((5, 2), Players::Player));
}
#[test]
fn first_recruit_waits_at_full_progress_and_following_recruits_pause_until_departure() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
game.player_resources = Resources::new(100, 100);
assert!(game.recruit_miner((5, 2), Players::Player));
assert!(game.recruit_miner((5, 2), Players::Player));
let original: Option<Units> = game.board.cells[5][2].get_option_unit();
for _ in 0..2 * BaseBuilding::RECRUITMENT_TICKS {
game.tick_update();
}
assert_eq!(base(game, (5, 2)).queued_miners(), 2);
assert_eq!(base(game, (5, 2)).recruitment_progress(), 100);
assert_eq!(game.board.cells[5][2].get_option_unit(), original);
game.board.cells[5][2].get_mut_option_unit().take();
game.tick_update();
let spawned: Option<Units> = game.board.cells[5][2].get_option_unit();
assert_eq!(spawned.get_owner(), Players::Player);
assert_eq!(spawned.get_coords(), (5, 2));
assert!(spawned.get_ref_tasks().is_empty());
assert_eq!(base(game, (5, 2)).queued_miners(), 1);
for _ in 0..2 * BaseBuilding::RECRUITMENT_TICKS {
game.tick_update();
}
assert_eq!(base(game, (5, 2)).queued_miners(), 1);
assert_eq!(base(game, (5, 2)).recruitment_progress(), 0);
assert_eq!(game.board.cells[5][2].get_option_unit(), spawned);
game.board.cells[5][2].get_mut_option_unit().take();
for _ in 1..BaseBuilding::RECRUITMENT_TICKS {
game.tick_update();
}
assert!(game.board.cells[5][2].get_ref_option_unit().is_none());
assert_eq!(base(game, (5, 2)).recruitment_progress(), 99);
game.tick_update();
assert_eq!(base(game, (5, 2)).queued_miners(), 0);
assert_eq!(
game.board.cells[5][2].get_ref_option_unit().get_owner(),
Players::Player
);
}
#[test]
fn walking_out_of_base_resumes_recruitment_and_new_miner_can_receive_tasks() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
assert!(game.recruit_miner((5, 2), Players::Player));
game.board.cells[5][3].set_structure(Structures::Tunnel(Tunnel::new()));
game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.toggle_marking();
game.board.change_focused_cell(MoveFocusedCell::Right);
game.board.toggle_marking();
for _ in 0..3000_u16.div_ceil(u16::from(TICK_MS)) {
game.tick_update();
}
assert!(game.board.cells[5][2].get_ref_option_unit().is_none());
for _ in 3000_u16.div_ceil(u16::from(TICK_MS))..BaseBuilding::RECRUITMENT_TICKS - 1 {
game.tick_update();
}
assert!(game.board.cells[5][2].get_ref_option_unit().is_none());
assert_eq!(base(game, (5, 2)).recruitment_progress(), 99);
game.tick_update();
assert_eq!(base(game, (5, 2)).queued_miners(), 0);
assert_eq!(
game.board.cells[5][2].get_ref_option_unit().get_coords(),
(5, 2)
);
assert_eq!(
game.board.cells[5][3].get_ref_option_unit().get_coords(),
(5, 3)
);
game.board.change_focused_cell(MoveFocusedCell::Left);
game.board.toggle_marking();
game.board.change_focused_cell(MoveFocusedCell::Down);
game.board.toggle_marking();
assert_eq!(
game.board.cells[5][2]
.get_ref_option_unit()
.get_ref_tasks()
.len(),
1
);
}
#[test]
fn arrival_during_training_does_not_overwrite_visitor_and_ready_recruit_spawns_after_departure()
-> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
game.board.cells[5][2].get_mut_option_unit().take();
assert!(game.recruit_miner((5, 2), Players::Player));
for _ in 0..100 {
game.tick_update();
}
let visitor: Units = Units::Miner(MinerUnit::new(Players::Enemy, (5, 2)));
*game.board.cells[5][2].get_mut_option_unit() = Some(visitor.clone());
for _ in 0..BaseBuilding::RECRUITMENT_TICKS {
game.tick_update();
}
assert_eq!(base(game, (5, 2)).recruitment_progress(), 100);
assert_eq!(game.board.cells[5][2].get_option_unit(), Some(visitor));
game.board.cells[5][2].get_mut_option_unit().take();
game.tick_update();
assert_eq!(base(game, (5, 2)).queued_miners(), 0);
assert_eq!(
game.board.cells[5][2].get_ref_option_unit().get_owner(),
Players::Player
);
}
#[test]
fn bases_have_independent_queues_costs_and_spawn_ownership() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
for (coords, owner) in [((5, 2), Players::Player), ((5, 39), Players::Enemy)] {
game.board.cells[coords.0][coords.1]
.get_mut_option_unit()
.take();
assert!(game.recruit_miner(coords, owner));
}
assert_eq!(game.player_resources.metal, 10);
assert_eq!(game.enemy_resources.metal, 10);
for _ in 0..BaseBuilding::RECRUITMENT_TICKS {
game.tick_update();
}
for (coords, owner) in [((5, 2), Players::Player), ((5, 39), Players::Enemy)] {
assert_eq!(base(game, coords).queued_miners(), 0);
assert_eq!(
game.board.cells[coords.0][coords.1]
.get_ref_option_unit()
.get_owner(),
owner
);
assert_eq!(
game.board.cells[coords.0][coords.1]
.get_ref_option_unit()
.get_coords(),
coords
);
}
}
#[test]
fn recruitment_panel_queues_with_enter_shows_ready_state_and_closes_without_losing_queue() -> () {
let mut app: App = app();
press(&mut app, KeyCode::Left, KeyModifiers::NONE);
assert!(
available_skirmish_actions(&app.states_ref().unwrap().skirmish)
.contains(&Action::Recruitment)
);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert!(app.states_ref().unwrap().skirmish.recruitment_view);
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
assert_eq!(
base(&app.states_ref().unwrap().skirmish, (5, 2)).queued_miners(),
1
);
assert!(
!available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Enter)
);
press(&mut app, KeyCode::Right, KeyModifiers::NONE);
press(&mut app, KeyCode::Char('b'), KeyModifiers::NONE);
assert_eq!(
app.states_ref()
.unwrap()
.skirmish
.board
.get_focused_cell()
.get_coords(),
(5, 2)
);
assert!(!app.states_ref().unwrap().skirmish.building_view);
for _ in 0..BaseBuilding::RECRUITMENT_TICKS {
app.states_mut().unwrap().skirmish.tick_update();
}
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 50)).unwrap();
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area());
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell: &Cell| -> &str { cell.symbol() })
.collect();
assert!(text.contains("Cost: 10 Wood + 15 Metal"));
assert!(text.contains("Queued Miners: 1"));
assert!(text.contains("Current progress: 100%"));
assert!(text.contains("Ready: waiting for free base cell"));
assert!(text.contains("Close recruitment"));
assert!(!text.contains("Esc -"));
assert!(
!available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Esc)
);
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert!(app.states_ref().unwrap().skirmish.recruitment_view);
assert_eq!(app.view, View::Skirmish);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
assert_eq!(app.view, View::Skirmish);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
assert_eq!(
base(&app.states_ref().unwrap().skirmish, (5, 2)).queued_miners(),
1
);
}
#[test]
fn recruitment_panel_is_unavailable_off_base_and_during_route_selection() -> () {
let mut app: App = app();
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
press(&mut app, KeyCode::Left, KeyModifiers::NONE);
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
assert!(
app.states_ref()
.unwrap()
.skirmish
.board
.marked_cells
.marking_cells
);
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
press(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(app.exit);
}
#[test]
fn recruitment_key_opens_queues_and_closes_on_own_base() -> () {
let mut app: App = app();
press(&mut app, KeyCode::Left, KeyModifiers::NONE);
press(&mut app, KeyCode::Char(' '), KeyModifiers::ALT);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert!(app.states_ref().unwrap().skirmish.recruitment_view);
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
assert_eq!(
base(&app.states_ref().unwrap().skirmish, (5, 2)).queued_miners(),
1
);
assert_eq!(
app.states_ref().unwrap().skirmish.player_resources,
Resources::new(40, 10)
);
}
+163
View File
@@ -0,0 +1,163 @@
use clap::Parser;
use ratatui::{Terminal, backend::TestBackend, buffer::Cell, layout::Rect};
use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{
app::{
App, GameStates, View,
states::{
SkirmishState,
skirmish_states::{
Players, Resources,
resources::{MINE_PRODUCTION_TICKS, WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_TICKS},
structures::{Mine, Ore, Stone, Structure, Structures},
},
},
threads::AudioCmd,
},
cli::Cli,
};
fn game() -> SkirmishState {
let args: Cli =
Cli::try_parse_from(["game", "--starting-wood", "70", "--starting-metal", "40"]).unwrap();
GameStates::new(&args, &Rect::new(0, 0, 160, 40)).skirmish
}
#[test]
fn resources_start_from_cli_and_legacy_iron_flag_remains_an_alias() -> () {
let game: SkirmishState = game();
assert_eq!(game.player_resources, Resources::new(70, 40));
assert_eq!(game.enemy_resources, Resources::new(70, 40));
let args: Cli = Cli::try_parse_from(["game", "--starting-iron", "33"]).unwrap();
assert_eq!(args.starting_metal, 33);
}
#[test]
fn passive_wood_arrives_once_per_interval_without_free_metal() -> () {
let mut game: SkirmishState = game();
for _ in 1..WOOD_DELIVERY_TICKS {
game.tick_update();
}
assert_eq!(game.player_resources.wood, 70);
game.tick_update();
assert_eq!(game.player_resources.wood, 70 + WOOD_DELIVERY_AMOUNT);
for _ in 0..WOOD_DELIVERY_TICKS {
game.tick_update();
}
assert_eq!(game.player_resources.wood, 70 + 2 * WOOD_DELIVERY_AMOUNT);
assert_eq!(game.player_resources.metal, 40);
assert_eq!(game.enemy_resources, Resources::new(70, 40));
}
#[test]
fn ore_is_neutral_and_no_mines_spawn_on_the_board() -> () {
let game: SkirmishState = game();
let mut deposits: usize = 0;
for row in &game.board.cells {
for cell in row {
let structure: Structures = cell.get_structure();
assert!(!matches!(structure, Structures::Mine(_)));
if matches!(structure, Structures::Ore(_)) {
deposits += 1;
assert_eq!(structure.get_owner(), Players::Unclaimed);
}
}
}
assert_eq!(deposits, 2);
}
#[test]
fn mine_can_only_replace_neutral_ore_and_preserves_its_reserve() -> () {
let mut ore: Ore = Ore::new(Players::Unclaimed, 1);
assert_eq!(ore.extract(4999), 4999);
let mut structure: Structures = Structures::Ore(ore);
assert!(structure.build_mine(Players::Player));
let Structures::Mine(mut mine) = structure else {
panic!("expected mine");
};
assert_eq!(mine.get_owner(), Players::Player);
assert_eq!(mine.get_ore_amount(), 1);
assert_eq!(mine.produce(), 1);
assert_eq!(mine.produce(), 0);
assert_eq!(mine.get_ore_amount(), 0);
assert!(!structure.build_mine(Players::Enemy));
let mut stone: Structures = Structures::Stone(Stone::new());
assert!(!stone.build_mine(Players::Player));
assert_eq!(stone, Structures::Stone(Stone::new()));
assert!(Mine::new(Players::Unclaimed, ore).is_none());
assert!(Mine::new(Players::Player, Ore::new(Players::Enemy, 1)).is_none());
}
#[test]
fn mining_credits_each_owner_and_stops_when_deposit_is_empty() -> () {
let mut game: SkirmishState = game();
for (col, owner) in [(1, Players::Player), (40, Players::Enemy)] {
let mut ore: Ore = Ore::new(Players::Unclaimed, 1);
ore.extract(4999);
let mut structure: Structures = Structures::Ore(ore);
assert!(structure.build_mine(owner));
game.board.cells[5][col].set_structure(structure);
}
for _ in 1..MINE_PRODUCTION_TICKS {
game.tick_update();
}
assert_eq!(game.player_resources.metal, 40);
game.tick_update();
assert_eq!(game.player_resources.metal, 41);
assert_eq!(game.enemy_resources.metal, 41);
for _ in 0..MINE_PRODUCTION_TICKS {
game.tick_update();
}
assert_eq!(game.player_resources.metal, 41);
assert_eq!(game.enemy_resources.metal, 41);
let structure: Structures = game.board.cells[5][1].get_structure();
assert!(
structure
.text()
.iter()
.any(|line: &ratatui::text::Line<'_>| -> bool {
line.to_string().contains("Ore: 0/5000")
})
);
}
#[test]
fn resource_additions_do_not_overflow() -> () {
let mut resources: Resources = Resources {
wood: u32::MAX - 1,
metal: u32::MAX - 1,
};
resources.add_wood(5);
resources.add_metal(5);
assert_eq!(resources.wood, u32::MAX);
assert_eq!(resources.metal, u32::MAX);
}
#[test]
fn tui_reads_current_resources_instead_of_starting_settings() -> () {
let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, tx);
app.view = View::Skirmish;
app.states = Some(GameStates::new(&app.args, &Rect::new(0, 0, 200, 40)));
app.states_mut().unwrap().skirmish.player_resources = Resources {
wood: 777,
metal: 888,
};
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(200, 40)).unwrap();
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area());
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell: &Cell| -> &str { cell.symbol() })
.collect();
assert!(text.contains("Wood: 777 (+5/5s)"));
assert!(text.contains("Metal: 888"));
assert!(!text.contains("Iron:"));
}
+253
View File
@@ -0,0 +1,253 @@
use clap::Parser;
use ratatui::{
Terminal,
backend::TestBackend,
buffer::Cell,
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect,
style::Color,
};
use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{
app::{
Action, App, GameStates, View, available_skirmish_actions, handle_keybindings,
states::skirmish_states::{
BoardState, MoveFocusedCell,
structures::{Structure, Structures, Tunnel},
tasks::{DiggingTask, MovingTask, Task, Tasks},
units::Unit,
},
threads::AudioCmd,
},
cli::Cli,
};
fn app() -> App {
let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, tx);
app.view = View::Skirmish;
app.window_area = Rect::new(0, 0, 160, 50);
app.states = Some(GameStates::new(&app.args, &app.window_area));
app.states_mut()
.unwrap()
.skirmish
.board
.change_focused_cell(MoveFocusedCell::Left);
app
}
fn press(app: &mut App, code: KeyCode) -> () {
handle_keybindings(app, KeyEvent::new(code, KeyModifiers::NONE));
}
fn route_color_count(app: &mut App) -> usize {
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 50)).unwrap();
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(app, frame.area());
})
.unwrap();
terminal
.backend()
.buffer()
.content
.iter()
.filter(|cell: &&Cell| -> bool {
matches!(cell.fg, Color::Rgb(128, 0, 128) | Color::Rgb(255, 0, 255))
})
.count()
}
#[test]
fn focused_queued_route_is_yellow_until_drawing_a_task() -> () {
let mut app: App = app();
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][3].set_structure(Structures::Tunnel(Tunnel::new()));
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
board.change_focused_cell(MoveFocusedCell::Right);
for drawing in [false, true] {
if drawing {
press(&mut app, KeyCode::Left);
press(&mut app, KeyCode::Char(' '));
}
let board: &BoardState = &app.states_ref().unwrap().skirmish.board;
assert_eq!(board.get_focused_cell().get_coords(), (5, 3));
for stress in [false, true] {
app.states_mut().unwrap().skirmish.stress_view = stress;
let mut terminal: Terminal<TestBackend> =
Terminal::new(TestBackend::new(160, 50)).unwrap();
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area());
})
.unwrap();
let corner: &Cell = &terminal
.backend()
.buffer()
.content
.windows(3)
.find(|cells: &&[Cell]| -> bool {
cells[0].symbol() == "╭" && cells[1].symbol() == "D" && cells[2].symbol() == "5"
})
.unwrap()[0];
let expected: Color = if drawing {
Color::Rgb(128, 0, 128)
} else {
Color::Rgb(255, 255, 0)
};
assert_eq!(corner.fg, expected);
}
}
}
#[test]
fn queued_route_colors_follow_progress_and_cancellation_without_marking_model() -> () {
let mut app: App = app();
let baseline: usize = route_color_count(&mut app);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][3].set_structure(Structures::Tunnel(Tunnel::new()));
board.cells[5][4].set_structure(Structures::Tunnel(Tunnel::new()));
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 4)].into())));
let queued: usize = route_color_count(&mut app);
assert!(queued > baseline);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
assert!(!board.cells[5][3].get_marked());
assert!(!board.cells[5][4].get_marked());
for _ in 0..91 {
board.advance_turn();
}
let remaining: usize = route_color_count(&mut app);
assert!(remaining > baseline && remaining < queued);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.change_focused_cell(MoveFocusedCell::Right);
assert!(board.cancel_focused_tasks(true));
assert_eq!(route_color_count(&mut app), baseline);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][3]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 4)].into())));
for _ in 0..91 {
board.advance_turn();
}
assert_eq!(route_color_count(&mut app), baseline);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][39]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 4)].into())));
assert_eq!(route_color_count(&mut app), baseline);
}
#[test]
fn cancelling_last_preserves_active_work_and_delete_stops_without_restoring_stone() -> () {
let mut app: App = app();
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
let power: f32 = board.cells[5][2].get_ref_option_unit().get_digging_power();
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Digging(DiggingTask::new([(5, 3)].into(), power)));
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Digging(DiggingTask::new([(5, 4)].into(), power)));
board.advance_turn();
let stone: Structures = board.cells[5][3].get_structure();
assert!(stone.get_durability() < stone.get_max_durability());
let active: Tasks = board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0].clone();
press(&mut app, KeyCode::Delete);
assert_eq!(
app.states_ref().unwrap().skirmish.board.cells[5][2]
.get_ref_option_unit()
.get_ref_tasks()
.len(),
2
);
press(&mut app, KeyCode::Tab);
press(&mut app, KeyCode::Backspace);
let board: &BoardState = &app.states_ref().unwrap().skirmish.board;
assert_eq!(
board.cells[5][2]
.get_ref_option_unit()
.get_ref_tasks()
.len(),
1
);
assert_eq!(
board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0],
active
);
press(&mut app, KeyCode::Delete);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.advance_turn();
assert!(
board.cells[5][2]
.get_ref_option_unit()
.get_ref_tasks()
.is_empty()
);
assert_eq!(board.cells[5][3].get_structure(), stone);
assert!(!board.cancel_focused_tasks(true));
}
#[test]
fn cancelling_active_movement_keeps_unit_on_origin_and_discards_progress() -> () {
let mut app: App = app();
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][3].set_structure(Structures::Tunnel(Tunnel::new()));
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
for _ in 0..40 {
board.advance_turn();
}
assert!(board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0].get_progress() > 0.0);
assert!(board.cancel_focused_tasks(false));
for _ in 0..100 {
board.advance_turn();
}
assert!(board.cells[5][2].get_ref_option_unit().is_some());
assert!(board.cells[5][3].get_ref_option_unit().is_none());
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
board.advance_turn();
assert!(board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0].get_progress() < 2.0);
}
#[test]
fn cancellation_rejects_enemy_empty_cells_and_route_drawing() -> () {
let mut app: App = app();
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
press(&mut app, KeyCode::Tab);
assert!(
available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Delete)
);
press(&mut app, KeyCode::Char(' '));
assert!(
!app.states_mut()
.unwrap()
.skirmish
.board
.cancel_focused_tasks(true)
);
press(&mut app, KeyCode::Esc);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
while board.get_focused_cell().get_coords().1 < 39 {
board.change_focused_cell(MoveFocusedCell::Right);
}
board.cells[5][39]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 38)].into())));
assert!(!board.cancel_focused_tasks(true));
board.change_focused_cell(MoveFocusedCell::Up);
assert!(!board.cancel_focused_tasks(true));
}
+91 -91
View File
@@ -1,123 +1,123 @@
use war_in_tunnels::app::states::skirmish_states::{
Players,
tasks::{DiggingTask, MovingTask, Task, Tasks},
units::{MinerUnit, Unit},
Players,
tasks::{DiggingTask, MovingTask, Task, Tasks},
units::{MinerUnit, Unit},
};
#[test]
fn digging_progress_accumulates_and_stops_at_completion() {
let mut task = DiggingTask::new([(5, 3)].into(), 40.0);
assert!(!task.is_finished());
task.progress();
assert_eq!(task.get_progress(), 40.0);
assert!(!task.is_finished());
for _ in 0..3 {
let mut task = DiggingTask::new([(5, 3)].into(), 40.0);
assert!(!task.is_finished());
task.progress();
}
assert_eq!(task.get_progress(), 100.0);
assert!(task.is_finished());
assert_eq!(task.get_progress(), 40.0);
assert!(!task.is_finished());
for _ in 0..3 {
task.progress();
}
assert_eq!(task.get_progress(), 100.0);
assert!(task.is_finished());
}
#[test]
fn advancing_path_resets_progress_and_handles_empty_path() {
let mut task = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
task.progress();
task.pop_front();
assert_eq!(task.current_target(), Some((5, 4)));
assert_eq!(task.get_progress(), 0.0);
task.pop_front();
task.pop_front();
assert_eq!(task.current_target(), None);
assert!(task.get_path().is_empty());
let mut task = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
task.progress();
task.pop_front();
assert_eq!(task.current_target(), Some((5, 4)));
assert_eq!(task.get_progress(), 0.0);
task.pop_front();
task.pop_front();
assert_eq!(task.current_target(), None);
assert!(task.get_path().is_empty());
}
#[test]
fn miners_keep_tasks_in_submission_order() {
let mut miner = MinerUnit::new(Players::Player, (5, 2));
for target in [(5, 3), (6, 3)] {
miner.set_task(Tasks::Digging(DiggingTask::new([target].into(), 1.0)));
}
assert_eq!(miner.get_ref_tasks().len(), 2);
assert_eq!(
*miner
.get_mut_tasks()
.pop_front()
.unwrap()
.get_path_front_coords(),
(5, 3)
);
assert_eq!(*miner.get_ref_tasks()[0].get_path_front_coords(), (6, 3));
let mut miner = MinerUnit::new(Players::Player, (5, 2));
for target in [(5, 3), (6, 3)] {
miner.set_task(Tasks::Digging(DiggingTask::new([target].into(), 1.0)));
}
assert_eq!(miner.get_ref_tasks().len(), 2);
assert_eq!(
*miner
.get_mut_tasks()
.pop_front()
.unwrap()
.get_path_front_coords(),
(5, 3)
);
assert_eq!(*miner.get_ref_tasks()[0].get_path_front_coords(), (6, 3));
}
#[test]
fn movement_progress_is_separate_from_digging_and_resets_for_next_cell() {
let mut task = MovingTask::new([(5, 3), (5, 4)].into());
assert_eq!(task.get_movement_progress(), None);
assert_eq!(task.get_progress(), 0.0);
assert!(!task.is_finished());
for _ in 0..46 {
let mut task = MovingTask::new([(5, 3), (5, 4)].into());
assert_eq!(task.get_movement_progress(), None);
assert_eq!(task.get_progress(), 0.0);
assert!(!task.is_finished());
for _ in 0..46 {
task.progress();
}
assert_eq!(task.get_movement_progress(), Some(50));
for _ in 46..91 {
task.progress();
}
assert_eq!(task.get_movement_progress(), Some(100));
assert_eq!(task.get_progress(), 100.0);
assert!(task.is_finished());
task.progress();
}
assert_eq!(task.get_movement_progress(), Some(50));
for _ in 46..91 {
task.progress();
}
assert_eq!(task.get_movement_progress(), Some(100));
assert_eq!(task.get_progress(), 100.0);
assert!(task.is_finished());
task.progress();
assert_eq!(task.get_progress(), 100.0);
task.pop_front();
assert_eq!(task.get_movement_progress(), None);
assert_eq!(task.get_progress(), 0.0);
assert!(!task.is_finished());
assert_eq!(task.get_progress(), 100.0);
task.pop_front();
assert_eq!(task.get_movement_progress(), None);
assert_eq!(task.get_progress(), 0.0);
assert!(!task.is_finished());
}
#[test]
fn overall_digging_progress_counts_completed_cells_and_current_step() -> () {
let mut task: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
assert_eq!(task.get_total_progress(), 0.0);
task.progress();
assert_eq!(task.get_progress(), 25.0);
assert_eq!(task.get_total_progress(), 12.5);
task.pop_front();
assert_eq!(task.get_progress(), 0.0);
assert_eq!(task.get_total_progress(), 50.0);
task.progress();
assert_eq!(task.get_total_progress(), 62.5);
task.pop_front();
assert_eq!(task.get_total_progress(), 100.0);
let empty: DiggingTask = DiggingTask::new([].into(), 1.0);
assert_eq!(empty.get_total_progress(), 100.0);
let mut task: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
assert_eq!(task.get_total_progress(), 0.0);
task.progress();
assert_eq!(task.get_progress(), 25.0);
assert_eq!(task.get_total_progress(), 12.5);
task.pop_front();
assert_eq!(task.get_progress(), 0.0);
assert_eq!(task.get_total_progress(), 50.0);
task.progress();
assert_eq!(task.get_total_progress(), 62.5);
task.pop_front();
assert_eq!(task.get_total_progress(), 100.0);
let empty: DiggingTask = DiggingTask::new([].into(), 1.0);
assert_eq!(empty.get_total_progress(), 100.0);
}
#[test]
fn task_conversion_keeps_completed_steps_and_original_length() -> () {
let mut digging: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
digging.pop_front();
let mut moving: MovingTask = MovingTask::from_digging(digging);
assert_eq!(moving.get_total_steps(), 2);
assert_eq!(moving.get_total_progress(), 50.0);
for _ in 0..91 {
moving.progress();
}
assert_eq!(moving.get_total_progress(), 100.0);
let mut digging: DiggingTask = DiggingTask::from_moving(moving, 25.0);
assert_eq!(digging.get_total_steps(), 2);
assert_eq!(digging.get_total_progress(), 50.0);
digging.progress();
assert_eq!(digging.get_total_progress(), 62.5);
let mut digging: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
digging.pop_front();
let mut moving: MovingTask = MovingTask::from_digging(digging);
assert_eq!(moving.get_total_steps(), 2);
assert_eq!(moving.get_total_progress(), 50.0);
for _ in 0..91 {
moving.progress();
}
assert_eq!(moving.get_total_progress(), 100.0);
let mut digging: DiggingTask = DiggingTask::from_moving(moving, 25.0);
assert_eq!(digging.get_total_steps(), 2);
assert_eq!(digging.get_total_progress(), 50.0);
digging.progress();
assert_eq!(digging.get_total_progress(), 62.5);
}
#[test]
fn digging_queue_formats_step_total_and_pending_progress() -> () {
let mut miner: MinerUnit = MinerUnit::new(Players::Player, (5, 2));
let mut task: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
task.pop_front();
task.progress();
miner.set_task(Tasks::Digging(task));
miner.set_task(Tasks::Moving(MovingTask::new([(5, 5)].into())));
let lines: Vec<ratatui::text::Line<'_>> = miner.get_tasks_formatted();
assert!(lines[1].to_string().contains("Step: 25% | Total: 62%"));
assert!(lines[3].to_string().contains("Step: 0% | Total: 0%"));
let mut miner: MinerUnit = MinerUnit::new(Players::Player, (5, 2));
let mut task: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
task.pop_front();
task.progress();
miner.set_task(Tasks::Digging(task));
miner.set_task(Tasks::Moving(MovingTask::new([(5, 5)].into())));
let lines: Vec<ratatui::text::Line<'_>> = miner.get_tasks_formatted();
assert!(lines[1].to_string().contains("Step: 25% | Total: 62%"));
assert!(lines[3].to_string().contains("Step: 0% | Total: 0%"));
}
+614
View File
@@ -0,0 +1,614 @@
use clap::Parser;
use ratatui::{
Terminal,
backend::TestBackend,
buffer::{Buffer, Cell},
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect,
style::Color,
widgets::Widget,
};
use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{
app::{
Action, App, GameStates, View, available_skirmish_actions, handle_keybindings,
states::{
SkirmishState,
skirmish_states::{
BoardState, CellSizes, Players, Resources, ZoomLevel,
structures::{CollapsedTunnel, Structure, Structures, Tunnel},
tasks::{DiggingTask, MovingTask, Task, Tasks},
units::{MinerUnit, Unit, Units},
},
},
threads::AudioCmd,
},
cli::Cli,
};
fn app() -> App {
let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, tx);
app.view = View::Skirmish;
app.window_area = Rect::new(0, 0, 160, 50);
app.states = Some(GameStates::new(&app.args, &app.window_area));
app
}
fn put_tunnel(board: &mut BoardState, coords: (usize, usize), supported: bool) -> () {
let mut tunnel: Tunnel = Tunnel::new();
tunnel.set_support(supported);
board.cells[coords.0][coords.1].set_structure(Structures::Tunnel(tunnel));
}
fn miner(board: &mut BoardState, coords: (usize, usize), owner: Players) -> () {
*board.cells[coords.0][coords.1].get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(owner, coords)));
}
fn press(app: &mut App, code: KeyCode) -> () {
handle_keybindings(app, KeyEvent::new(code, KeyModifiers::NONE));
}
fn finish_support_work(game: &mut SkirmishState) -> () {
for _ in 0..Tunnel::SUPPORT_WORK_TICKS {
game.advance_support_work();
}
}
#[test]
fn support_work_takes_three_seconds_freezes_worker_and_pauses_without_builder() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
put_tunnel(&mut game.board, (5, 3), false);
game.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
assert!(game.build_support((5, 3), Players::Player));
for _ in 1..Tunnel::SUPPORT_WORK_TICKS {
game.tick_update();
}
assert_eq!(game.board.cells[5][3].get_structure().get_level(), ' ');
assert_eq!(
game.board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0].get_progress(),
0.0
);
game.tick_update();
assert_eq!(game.board.cells[5][3].get_structure().get_level(), 'T');
assert!(game.board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0].get_progress() > 0.0);
assert!(game.remove_support((5, 3), Players::Player));
for _ in 0..45 {
game.tick_update();
}
game.board.cells[5][2].get_mut_option_unit().take();
let paused: Structures = game.board.cells[5][3].get_structure();
for _ in 0..20 {
game.tick_update();
}
assert_eq!(game.board.cells[5][3].get_structure(), paused);
assert_eq!(paused.get_level(), 'T');
miner(&mut game.board, (5, 2), Players::Player);
for _ in 45..Tunnel::SUPPORT_WORK_TICKS {
game.tick_update();
}
assert_eq!(game.board.cells[5][3].get_structure().get_level(), ' ');
}
#[test]
fn one_miner_cannot_work_on_two_supports_simultaneously() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
put_tunnel(&mut game.board, (5, 3), false);
put_tunnel(&mut game.board, (5, 4), false);
miner(&mut game.board, (5, 3), Players::Player);
assert!(game.build_support((5, 3), Players::Player));
assert!(game.build_support((5, 4), Players::Player));
finish_support_work(game);
assert_eq!(game.board.cells[5][3].get_structure().get_level(), 'T');
let Structures::Tunnel(tunnel) = game.board.cells[5][4].get_structure() else {
panic!("tunnel missing");
};
assert_eq!(tunnel.support_progress(), Some(0));
finish_support_work(game);
assert_eq!(game.board.cells[5][4].get_structure().get_level(), 'T');
}
#[test]
fn stress_map_renders_stone_normally_at_every_zoom() -> () {
let app: App = app();
for zoom in [
ZoomLevel::Default,
ZoomLevel::ZoomedIn,
ZoomLevel::ZoomedOut,
] {
for (selected, marked) in [(false, false), (false, true), (true, false), (true, true)] {
let area: Rect = Rect::new(
0,
0,
zoom.get_cell_size(CellSizes::Width) as u16,
zoom.get_cell_size(CellSizes::Height) as u16,
);
let mut normal: Buffer = Buffer::empty(area);
let mut stress: Buffer = Buffer::empty(area);
app.states_ref().unwrap().skirmish.board.cells[5][4]
.clone()
.set_zoom_level(zoom)
.set_selected(selected)
.set_marked(selected && marked)
.clone()
.render(area, &mut normal);
app.states_ref().unwrap().skirmish.board.cells[5][4]
.clone()
.set_zoom_level(zoom)
.set_selected(selected)
.set_marked(marked)
.render_stress(area, &mut stress);
assert_eq!(normal, stress);
assert!(
!stress
.content
.iter()
.any(|cell: &Cell| -> bool { cell.symbol() == "%" })
);
}
}
}
#[test]
fn task_key_requires_space_selection_and_moving_choice_resets_invalid_draft() -> () {
let mut app: App = app();
press(&mut app, KeyCode::Left);
assert!(
!available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Tasks)
);
press(&mut app, KeyCode::Char('t'));
assert!(!app.states_ref().unwrap().skirmish.task_view);
press(&mut app, KeyCode::Char(' '));
assert!(
available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Tasks)
);
press(&mut app, KeyCode::Right);
press(&mut app, KeyCode::Char('t'));
assert!(app.states_ref().unwrap().skirmish.task_view);
press(&mut app, KeyCode::Right);
press(&mut app, KeyCode::Enter);
let board: &BoardState = &app.states_ref().unwrap().skirmish.board;
assert!(board.marked_cells.marking_cells && board.movement_only);
assert_eq!(board.get_focused_cell().get_coords(), (5, 2));
assert_eq!(board.get_marked_cells().len(), 1);
assert!(
board.cells[5][2]
.get_ref_option_unit()
.get_ref_tasks()
.is_empty()
);
press(&mut app, KeyCode::Esc);
assert!(
!available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Tasks)
);
}
#[test]
fn stress_follows_cardinal_distance_from_base_and_caps_at_100() -> () {
let mut app: App = app();
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
for col in 3..=9 {
put_tunnel(board, (5, col), false);
}
put_tunnel(board, (4, 3), false);
put_tunnel(board, (6, 3), false);
board.recalculate_stress();
for (col, stress) in [(3, 20), (4, 40), (5, 60), (6, 80), (7, 100), (9, 100)] {
assert_eq!(board.cells[5][col].get_structure().get_stress(), stress);
}
assert_eq!(board.cells[4][3].get_structure().get_stress(), 40);
assert_eq!(board.cells[6][3].get_structure().get_stress(), 40);
}
#[test]
fn support_stabilizes_all_four_directions_and_has_level_marker() -> () {
let mut app: App = app();
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
put_tunnel(board, (5, 12), true);
for coords in [(4, 12), (6, 12), (5, 11), (5, 13)] {
put_tunnel(board, coords, false);
}
board.recalculate_stress();
assert_eq!(board.cells[5][12].get_structure().get_level(), 'T');
assert_eq!(board.cells[5][12].get_structure().get_stress(), 20);
for (row, col) in [(4, 12), (6, 12), (5, 11), (5, 13)] {
assert_eq!(board.cells[row][col].get_structure().get_stress(), 40);
}
assert_eq!(board.resolve_collapses(), 0);
}
#[test]
fn removal_causes_fatal_cascade_and_preserves_safe_tunnels() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
for col in 3..=9 {
put_tunnel(&mut game.board, (5, col), col == 6);
}
miner(&mut game.board, (5, 6), Players::Player);
miner(&mut game.board, (5, 8), Players::Player);
miner(&mut game.board, (5, 9), Players::Enemy);
game.board.resolve_collapses();
assert!(game.remove_support((5, 6), Players::Player));
for _ in 1..Tunnel::SUPPORT_WORK_TICKS {
game.advance_support_work();
}
assert_eq!(game.board.cells[5][6].get_structure().get_level(), 'T');
assert!(game.board.cells[5][9].get_ref_option_unit().is_some());
game.advance_support_work();
for col in 7..=9 {
let structure: Structures = game.board.cells[5][col].get_structure();
assert!(matches!(structure, Structures::CollapsedTunnel(_)));
assert_eq!(structure.get_durability(), 750);
assert!(game.board.cells[5][col].get_ref_option_unit().is_none());
assert!(
!structure.can_enter(
game.board.cells[5][6]
.get_ref_option_unit()
.as_ref()
.unwrap()
)
);
}
assert!(game.board.cells[5][6].get_ref_option_unit().is_some());
assert_eq!(game.board.cells[5][6].get_structure().get_stress(), 80);
assert_eq!(game.board.resolve_collapses(), 0);
}
#[test]
fn alternate_support_prevents_collapse_after_removal() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
for col in 3..=10 {
put_tunnel(&mut game.board, (5, col), col == 6 || col == 9);
}
miner(&mut game.board, (5, 6), Players::Player);
assert!(game.remove_support((5, 6), Players::Player));
finish_support_work(game);
assert!(matches!(
game.board.cells[5][8].get_structure(),
Structures::Tunnel(_)
));
assert_eq!(game.board.cells[5][8].get_structure().get_stress(), 40);
}
#[test]
fn supports_charge_atomically_require_local_builder_and_do_not_refund_removal() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
put_tunnel(&mut game.board, (5, 3), false);
put_tunnel(&mut game.board, (5, 15), false);
let before: Resources = game.player_resources;
assert!(!game.build_support((usize::MAX, 0), Players::Player));
assert!(!game.build_support((5, 15), Players::Player));
assert!(!game.build_support((5, 3), Players::Enemy));
assert_eq!(game.player_resources, before);
game.player_resources = Resources::new(9, 100);
assert!(!game.build_support((5, 3), Players::Player));
assert_eq!(game.player_resources, Resources::new(9, 100));
game.player_resources = Resources::new(10, 2);
assert!(game.build_support((5, 3), Players::Player));
assert_eq!(game.player_resources, Resources::new(0, 0));
assert!(!game.build_support((5, 3), Players::Player));
assert!(!game.remove_support((5, 3), Players::Player));
finish_support_work(game);
assert!(game.remove_support((5, 3), Players::Player));
assert_eq!(game.player_resources, Resources::new(0, 0));
assert!(!game.remove_support((5, 3), Players::Player));
}
#[test]
fn support_building_tab_builds_and_removes_upgrade_using_local_miner() -> () {
let mut app: App = app();
put_tunnel(&mut app.states_mut().unwrap().skirmish.board, (5, 3), false);
press(&mut app, KeyCode::Char('b'));
press(&mut app, KeyCode::Right);
assert_eq!(app.states_ref().unwrap().skirmish.selected_building, 1);
press(&mut app, KeyCode::Enter);
let game: &SkirmishState = &app.states_ref().unwrap().skirmish;
assert_eq!(game.board.cells[5][3].get_structure().get_level(), ' ');
finish_support_work(&mut app.states_mut().unwrap().skirmish);
let game: &SkirmishState = &app.states_ref().unwrap().skirmish;
assert_eq!(game.board.cells[5][3].get_structure().get_level(), 'T');
assert_eq!(game.player_resources, Resources::new(40, 23));
press(&mut app, KeyCode::Delete);
assert_eq!(
app.states_ref().unwrap().skirmish.board.cells[5][3]
.get_structure()
.get_level(),
'T'
);
finish_support_work(&mut app.states_mut().unwrap().skirmish);
assert_eq!(
app.states_ref().unwrap().skirmish.board.cells[5][3]
.get_structure()
.get_level(),
' '
);
assert_eq!(
app.states_ref().unwrap().skirmish.player_resources,
Resources::new(40, 23)
);
assert!(app.states_ref().unwrap().skirmish.building_view);
}
#[test]
fn rubble_requires_less_work_than_stone_and_percent_is_rendered() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
game.board.cells[5][3].set_structure(Structures::CollapsedTunnel(CollapsedTunnel::new()));
game.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Digging(DiggingTask::new([(5, 3)].into(), 25.0)));
game.tick_update();
assert_eq!(game.board.cells[5][3].get_structure().get_durability(), 500);
let area: Rect = Rect::new(0, 0, 9, 5);
let mut buffer: Buffer = Buffer::empty(area);
game.board.cells[5][3].clone().render(area, &mut buffer);
let text: String = buffer
.content
.iter()
.map(|cell: &Cell| -> &str { cell.symbol() })
.collect();
assert!(text.contains("66%"));
for _ in 0..2 {
game.tick_update();
}
assert!(game.board.cells[5][3].get_ref_option_unit().is_some());
assert!(matches!(
game.board.cells[5][3].get_structure(),
Structures::Tunnel(_)
));
}
#[test]
fn automatic_supports_are_spaced_at_last_safe_tiles_and_do_not_waste_final_support() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
game.player_resources = Resources::new(1000, 1000);
game.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Digging(DiggingTask::new(
(3..=12)
.map(|col: usize| -> (usize, usize) { (5, col) })
.collect(),
100.0,
)));
for _ in 0..400 {
game.tick_update();
if game.board.cells[5][12].get_ref_option_unit().is_some() {
break;
}
}
assert!(game.board.cells[5][12].get_ref_option_unit().is_some());
assert_eq!(game.player_resources, Resources::new(985, 996));
assert!(game.turn_counter >= u64::from(2 * Tunnel::SUPPORT_WORK_TICKS));
for col in 3..=12 {
let Structures::Tunnel(tunnel) = game.board.cells[5][col].get_structure() else {
panic!("collapsed at {col}");
};
assert_eq!(tunnel.has_support(), col == 6 || col == 9);
assert!(tunnel.get_stress() < 100);
}
}
#[test]
fn disabled_auto_supports_or_missing_funds_kills_miner_at_fifth_tile() -> () {
for (automatic, metal) in [(false, 100), (true, 0)] {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
game.player_resources = Resources::new(100, metal);
let mut task: DiggingTask = DiggingTask::new(
(3..=9)
.map(|col: usize| -> (usize, usize) { (5, col) })
.collect(),
100.0,
);
task.set_auto_supports(automatic);
game.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Digging(task));
for _ in 0..5 {
game.tick_update();
}
assert!(matches!(
game.board.cells[5][7].get_structure(),
Structures::CollapsedTunnel(_)
));
assert!(game.board.cells[5][7].get_ref_option_unit().is_none());
assert_eq!(game.player_resources, Resources::new(100, metal));
}
}
#[test]
fn rubble_can_be_dug_again_and_auto_setting_survives_task_conversion() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
game.board.cells[5][3].set_structure(Structures::CollapsedTunnel(CollapsedTunnel::new()));
let mut task: MovingTask = MovingTask::new([(5, 3)].into());
task.set_auto_supports(false);
let dig: DiggingTask = DiggingTask::from_moving(task, 100.0);
assert!(!dig.auto_supports());
assert!(!MovingTask::from_digging(dig.clone()).auto_supports());
game.board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Digging(dig));
game.tick_update();
assert!(matches!(
game.board.cells[5][3].get_structure(),
Structures::Tunnel(_)
));
assert!(game.board.cells[5][3].get_ref_option_unit().is_some());
}
#[test]
fn stress_view_shows_coordinates_and_stress_but_keeps_focused_color() -> () {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
put_tunnel(&mut game.board, (5, 3), true);
miner(&mut game.board, (5, 3), Players::Player);
game.board.recalculate_stress();
for zoom in [
ZoomLevel::Default,
ZoomLevel::ZoomedIn,
ZoomLevel::ZoomedOut,
] {
let area: Rect = Rect::new(
0,
0,
zoom.get_cell_size(CellSizes::Width) as u16,
zoom.get_cell_size(CellSizes::Height) as u16,
);
let mut buffer: Buffer = Buffer::empty(area);
game.board.cells[5][3]
.clone()
.set_zoom_level(zoom)
.set_selected(false)
.set_marked(true)
.render_stress(area, &mut buffer);
let text: String = buffer
.content
.iter()
.map(|cell: &Cell| -> &str { cell.symbol() })
.collect();
assert!(text.contains("20%"));
assert!(text.contains("D5"));
assert!(!text.contains('M') && !text.contains('T'));
assert!(!buffer.content.iter().any(|cell: &Cell| -> bool {
matches!(
cell.fg,
Color::Rgb(128, 0, 128) | Color::Rgb(255, 0, 255) | Color::Rgb(0, 0, 128)
)
}));
game.board.cells[5][3]
.clone()
.set_selected(true)
.render_stress(area, &mut buffer);
assert_eq!(buffer[(0, 0)].fg, Color::Rgb(255, 255, 0));
game.board.cells[5][3]
.clone()
.set_selected(true)
.set_marked(true)
.render_stress(area, &mut buffer);
assert_eq!(buffer[(0, 0)].fg, Color::Rgb(128, 0, 128));
}
}
#[test]
fn task_sidebar_selects_auto_supports_and_captures_setting_in_queued_task() -> () {
let mut app: App = app();
press(&mut app, KeyCode::Left);
press(&mut app, KeyCode::Char(' '));
let coords: (usize, usize) = app
.states_ref()
.unwrap()
.skirmish
.board
.get_focused_cell()
.get_coords();
press(&mut app, KeyCode::Char('t'));
assert!(app.states_ref().unwrap().skirmish.task_view);
press(&mut app, KeyCode::Char('a'));
press(&mut app, KeyCode::Char('s'));
assert!(app.states_ref().unwrap().skirmish.stress_view);
assert!(!app.states_ref().unwrap().skirmish.board.auto_supports);
assert_eq!(
app.states_ref()
.unwrap()
.skirmish
.board
.get_focused_cell()
.get_coords(),
coords
);
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 50)).unwrap();
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(&mut app, frame.area());
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell: &Cell| -> &str { cell.symbol() })
.collect();
assert!(text.contains("Auto supports: OFF"));
assert!(text.contains("Unit tasks"));
let title: usize = terminal
.backend()
.buffer()
.content
.windows("Unit tasks".len())
.position(|cells: &[Cell]| -> bool {
cells
.iter()
.map(|cell: &Cell| -> &str { cell.symbol() })
.collect::<String>()
== "Unit tasks"
})
.unwrap();
assert!(title % 160 > 100);
press(&mut app, KeyCode::Esc);
assert!(app.states_ref().unwrap().skirmish.task_view);
press(&mut app, KeyCode::Enter);
assert!(!app.states_ref().unwrap().skirmish.task_view);
assert!(
app.states_ref()
.unwrap()
.skirmish
.board
.marked_cells
.marking_cells
);
press(&mut app, KeyCode::Right);
press(&mut app, KeyCode::Char(' '));
assert!(
!app.states_ref().unwrap().skirmish.board.cells[5][2]
.get_ref_option_unit()
.get_ref_tasks()[0]
.auto_supports()
);
}
#[test]
fn moving_choice_rejects_stone_and_never_digs_collapsed_route() -> () {
let mut app: App = app();
put_tunnel(&mut app.states_mut().unwrap().skirmish.board, (5, 3), false);
press(&mut app, KeyCode::Left);
press(&mut app, KeyCode::Char(' '));
press(&mut app, KeyCode::Char('t'));
press(&mut app, KeyCode::Right);
assert_eq!(app.states_ref().unwrap().skirmish.selected_task, 1);
press(&mut app, KeyCode::Enter);
press(&mut app, KeyCode::Right);
press(&mut app, KeyCode::Right);
assert_eq!(
app.states_ref()
.unwrap()
.skirmish
.board
.get_focused_cell()
.get_coords(),
(5, 3)
);
press(&mut app, KeyCode::Char(' '));
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
let Tasks::Moving(task) = &game.board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0]
else {
panic!("not moving");
};
assert!(!task.dig_if_blocked);
game.board.cells[5][3].set_structure(Structures::CollapsedTunnel(CollapsedTunnel::new()));
for _ in 0..200 {
game.tick_update();
}
assert_eq!(game.board.cells[5][3].get_structure().get_durability(), 750);
assert!(game.board.cells[5][2].get_ref_option_unit().is_some());
}