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.
This commit is contained in:
GarandPLG committed 2026-10-19 17:41:22 +02:00
1 parent 305cc2c4a2
commit a981663152
35 files changed
+2160 -74

No files matched your search

+22 -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. | | `-z` | `--zoom-level` | `Default` | Poziom przybliżenia UI. |
| `-p` | `--perk-deck` | `Silesian` | Zestaw perków. | | `-p` | `--perk-deck` | `Silesian` | Zestaw perków. |
| `-w` | `--starting-wood` | `50` | Początkowa ilość drewna (1‑150). | | `-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). | | `-L` | `--supply-limit` | `99` | Limit jednostek na mapie (49‑149). |
| `-x` | `--xp-modifier` | `1.0` | Mnożnik doświadczenia (0.5‑2.0). | | `-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). | | `-P` | `--skill-points-limit` | `120` | Górny limit punktów umiejętności (90‑690). |
@@ -89,7 +89,7 @@ cargo run -- -h
## Kopanie ## 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. 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,26 @@ 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ń. 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
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 `Ctrl+Space`. 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. Ctrl+Space lub Esc 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.
## Rekrutacja
Ustaw kursor na własnym budynku Base i naciśnij `Alt+Space` lub `r`. Jeśli terminal albo menedżer okien przechwytuje Alt+Space, użyj `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. Esc, Alt+Space lub `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 ## 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. Ś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.
+4 -1
View File
@@ -25,7 +25,7 @@ nix develop -c cargo run -- --disable-audio --view skirmish
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. 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.
Aktualny zestaw obejmuje 46 testów. Stan bazowy Clippy to 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 82 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 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ń.
@@ -42,4 +42,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. - 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. - `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. - Lista skrótów w widokach oraz tabela `KEYBINDINGS` muszą odpowiadać obsłudze akcji.
- Konteksty mapy, trasy i budowania określa `available_skirmish_actions`. Globalne wyjście i muzyka działają niezależnie od legendy. Ctrl+Space 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, Esc 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. Alt+Space 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.
- `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`. - `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`.
+20 -4
View File
@@ -1,6 +1,6 @@
# Mapa kodu i przepływu # 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ść ## Moduły i odpowiedzialność
@@ -17,6 +17,8 @@ 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ń | | 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 | | 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` | | 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 | | 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 | | 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 | | Wątki | `src/app/threads/{events,tick,audio}.rs` | Crossterm, zegar 33 ms, Rodio i komendy audio |
@@ -56,7 +58,7 @@ flowchart TD
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 `board.advance_turn()` i zwiększa `turn_counter`.
`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 ## Renderowanie i dane
@@ -77,7 +79,21 @@ 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. `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 i Mine; 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ę. Ctrl+Space 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. Esc lub Ctrl+Space tylko zamyka panel, nie anuluje budowy. Obsługiwany jest również NUL starszych terminali. 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.
Alt+Space lub 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. Esc, r lub Alt+Space 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 ## Klawisze i zlecanie kopania
@@ -117,7 +133,7 @@ To obserwacje statyczne, nie lista napraw wykonanych w tej sesji:
- **Zaznaczenie:** wybrany górnik czeka podczas rysowania trasy; inne jednostki wykonują zadania normalnie. - **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. - **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. - **Granice UI:** małe terminale, resize i trzy poziomy zoomu wymagają interaktywnego sprawdzenia. Indeksowanie planszy zakłada poprawne współrzędne.
- **Pokrycie:** 46 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:** 82 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ę ## Gdzie zacząć konkretną zmianę
+52
View File
@@ -1,5 +1,57 @@
# Dziennik prac # Dziennik prac
## 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 ## 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. 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.
+7 -3
View File
@@ -17,9 +17,13 @@ Testy korzystają z wbudowanego mechanizmu Rust, Clap i `ratatui::backend::TestB
| `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/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/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` | 2 | Zapętlanie rzeczywistego pliku OGG po jego końcu i ładowanie soundtracków obok binarki poza katalogiem repozytorium | | `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` | 4 | Kontekst mapy i trasy, anulowanie przez Esc, blokada budowania na nieprawidłowym polu, Ctrl+Space i NUL ze starszych terminali, globalne wyjście i czytelność legendy przy 120 kolumnach |
| `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` | 9 | 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, Alt+Space/r/Enter/Esc 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 | | `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: 46 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: 82 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: Wybrane zestawy i pojedynczy przypadek:
@@ -55,7 +59,7 @@ Workflow określa obraz zadania jawnie jako `node:22-bookworm`. Kontener działa
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ę. 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 46 testów można wykonać z kontrolą przepełnień w profilu produkcyjnym: Release ma `overflow-checks = true`. Ten sam zestaw 82 testów można wykonać z kontrolą przepełnień w profilu produkcyjnym:
```sh ```sh
nix develop -c cargo test --all-targets --release --locked nix develop -c cargo test --all-targets --release --locked
@@ -65,4 +69,4 @@ Manifest określa minimalną wspieraną wersję Rust jako 1.88. Potwierdzono `ca
## Ograniczenia ## 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.
+38
View File
@@ -31,6 +31,10 @@ pub enum Action {
ScrollRight, ScrollRight,
/// Toggle selecting cell. /// Toggle selecting cell.
Space, Space,
/// Open or close the construction view.
Building,
/// Open or close the recruitment view.
Recruitment,
/// Unmark last selected cell. /// Unmark last selected cell.
Backspace, Backspace,
/// Cancel marking cell. /// Cancel marking cell.
@@ -108,6 +112,33 @@ pub struct KeyBinding {
/// The slice is ordered primarily for readability; the runtime lookup scans /// The slice is ordered primarily for readability; the runtime lookup scans
/// this slice linearly, which is acceptable given the modest size of the list. /// this slice linearly, which is acceptable given the modest size of the list.
static KEYBINDINGS: &[KeyBinding] = &[ static KEYBINDINGS: &[KeyBinding] = &[
KeyBinding {
action: Action::Recruitment,
code: KeyCode::Char(' '),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::ALT,
group: Group::Opener,
symbol: "Alt + Space / r",
description: "Recruit",
},
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(' '),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::CONTROL,
group: Group::Opener,
symbol: "Ctrl + Space",
description: "Buildings",
},
KeyBinding { KeyBinding {
action: Action::Quit, action: Action::Quit,
code: KeyCode::Char('q'), code: KeyCode::Char('q'),
@@ -330,6 +361,13 @@ pub fn binding_for(action: Action) -> Option<&'static KeyBinding> {
/// was pressed. Returns `None` when the event does not correspond to any known /// was pressed. Returns `None` when the event does not correspond to any known
/// action. /// action.
pub fn event_to_action(event: &KeyEvent) -> Option<Action> { pub fn event_to_action(event: &KeyEvent) -> Option<Action> {
// Legacy terminals encode Ctrl+Space as NUL instead of a modified space.
if event.code == KeyCode::Null
&& event.kind == KeyEventKind::Press
&& (event.modifiers == KeyModifiers::NONE || event.modifiers == KeyModifiers::CONTROL)
{
return Some(Action::Building);
}
if let Some(b) = KEYBINDINGS if let Some(b) = KEYBINDINGS
.iter() .iter()
.find(|b| b.code == event.code && b.kind == event.kind && b.modifiers == event.modifiers) .find(|b| b.code == event.code && b.kind == event.kind && b.modifiers == event.modifiers)
+1 -1
View File
@@ -8,4 +8,4 @@ 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 main_menu::main_menu_keybindings;
pub use skirmish::skirmish_keybindings; pub use skirmish::{available_skirmish_actions, skirmish_keybindings};
+118 -3
View File
@@ -1,13 +1,126 @@
use crate::app::{ use crate::app::{
App, App,
keybindings::{Action, common_keybindings, event_to_action}, keybindings::{Action, common_keybindings, event_to_action},
states::skirmish_states::{BoardState, MoveFocusedCell, ZoomLevel}, states::{
SkirmishState,
skirmish_states::{
BoardState, FocusedCell, MoveFocusedCell, Players, ZoomLevel,
structures::{Structure, Structures},
units::{Unit, Units},
},
},
}; };
use ratatui::{crossterm::event::KeyEvent, layout::Rect}; use ratatui::{crossterm::event::KeyEvent, layout::Rect};
pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) { pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
if let Some(action) = event_to_action(&key_event) { if state.recruitment_view {
let mut actions: Vec<Action> = vec![Action::Recruitment, Action::Esc, 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::Esc, Action::Quit2];
let coords: (usize, usize) = state.board.get_focused_cell().get_coords();
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,
]);
return actions;
}
actions.extend([
Action::ScrollUp,
Action::ScrollDown,
Action::ScrollLeft,
Action::ScrollRight,
Action::Tab,
Action::Building,
Action::Mute,
Action::Quit,
Action::Esc,
]);
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);
}
actions
}
pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () {
if let Some(action) = event_to_action(key_event) {
let Some(states) = app.states_mut() else {
common_keybindings(app, action);
return;
};
if states.skirmish.recruitment_view {
match action {
Action::Recruitment | Action::Esc => states.skirmish.recruitment_view = false,
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 | Action::Esc => states.skirmish.building_view = false,
Action::Enter => {
let coords: (usize, usize) = states.skirmish.board.get_focused_cell().get_coords();
states
.skirmish
.start_mine_construction(coords, Players::Player);
}
Action::Delete => {
let coords: (usize, usize) = states.skirmish.board.get_focused_cell().get_coords();
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); common_keybindings(app, action);
if !available {
return;
}
let window_area: Rect = app.window_area; let window_area: Rect = app.window_area;
@@ -83,6 +196,8 @@ pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) {
ZoomLevel::ZoomedOut => {} ZoomLevel::ZoomedOut => {}
}, },
Action::Space => board.toggle_marking(), Action::Space => board.toggle_marking(),
Action::Building => states.skirmish.building_view = true,
Action::Recruitment => states.skirmish.recruitment_view = true,
Action::Backspace => board.pop_marked_cell(), Action::Backspace => board.pop_marked_cell(),
Action::Delete => { Action::Delete => {
board.marked_cells.marking_cells = false; board.marked_cells.marking_cells = false;
+1
View File
@@ -11,5 +11,6 @@ mod widgets;
pub use app::App; pub use app::App;
pub use keybind::handle_keybindings; pub use keybind::handle_keybindings;
pub use keybindings::{Action, available_skirmish_actions};
pub use state::GameStates; pub use state::GameStates;
pub use view::View; pub use view::View;
+6 -2
View File
@@ -1,7 +1,7 @@
use crate::{ use crate::{
app::states::{ app::states::{
MainMenuState, PerkDecksState, SettingsState, SkillsConfigState, SkirmishState, MainMenuState, PerkDecksState, SettingsState, SkillsConfigState, SkirmishState,
skirmish_states::BoardState, skirmish_states::{BoardState, Resources},
}, },
cli::Cli, cli::Cli,
}; };
@@ -36,6 +36,10 @@ impl GameStates {
), ),
side_panel: false, side_panel: false,
turn_counter: 0, 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,
}, },
perk_decks: PerkDecksState { perk_decks: PerkDecksState {
id: 2, id: 2,
@@ -52,7 +56,7 @@ impl GameStates {
game_mode: args.game_mode.clone(), game_mode: args.game_mode.clone(),
perk_deck: args.perk_deck.clone(), perk_deck: args.perk_deck.clone(),
starting_wood: args.starting_wood.clone(), starting_wood: args.starting_wood.clone(),
starting_iron: args.starting_iron.clone(), starting_metal: args.starting_metal,
supply_limit: args.supply_limit.clone(), supply_limit: args.supply_limit.clone(),
xp_modifier: args.xp_modifier.clone(), xp_modifier: args.xp_modifier.clone(),
skill_points_limit: args.skill_points_limit.clone(), skill_points_limit: args.skill_points_limit.clone(),
+1 -1
View File
@@ -6,7 +6,7 @@ pub struct SettingsState {
pub game_mode: GameMode, pub game_mode: GameMode,
pub perk_deck: PerkDecks, pub perk_deck: PerkDecks,
pub starting_wood: u16, pub starting_wood: u16,
pub starting_iron: u16, pub starting_metal: u16,
pub supply_limit: u8, pub supply_limit: u8,
pub xp_modifier: f32, pub xp_modifier: f32,
pub skill_points_limit: u16, pub skill_points_limit: u16,
+109 -2
View File
@@ -1,4 +1,10 @@
use crate::app::states::skirmish_states::BoardState; use crate::app::states::skirmish_states::{
BoardState, Players, Resources,
resources::{MINE_PRODUCTION_TICKS, WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_TICKS},
structures::{BuildingTerrain, Mine, Structure, Structures},
units::MinerUnit,
};
use crate::app::widgets::CellWidget;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct SkirmishState { pub struct SkirmishState {
@@ -7,14 +13,115 @@ pub struct SkirmishState {
pub board: BoardState, pub board: BoardState,
pub side_panel: bool, pub side_panel: bool,
pub turn_counter: u64, pub turn_counter: u64,
pub player_resources: Resources,
pub enemy_resources: Resources,
pub building_view: bool,
pub recruitment_view: bool,
} }
impl SkirmishState { impl SkirmishState {
pub fn tick_update(&mut self) { 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.board.advance_turn(); self.board.advance_turn();
self.board.advance_recruitment();
self.board.advance_construction();
// if self.board.is_victory() {} // if self.board.is_victory() {}
self.turn_counter += 1; 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));
}
}
}
}
} }
} }
+145 -11
View File
@@ -4,7 +4,9 @@ use crate::app::{
FocusedCell, Offset, FocusedCell, Offset,
skirmish_states::{ skirmish_states::{
CellSizes, MarkedCells, MoveFocusedCell, Players, ZoomLevel, CellSizes, MarkedCells, MoveFocusedCell, Players, ZoomLevel,
structures::{BaseBuilding, Ore, Stone, Structure, Structures, Tunnel}, structures::{
BaseBuilding, BuildingTerrain, MineConstruction, Ore, Stone, Structure, Structures, Tunnel,
},
tasks::{DiggingTask, MovingTask, Task, Tasks}, tasks::{DiggingTask, MovingTask, Task, Tasks},
units::{MinerUnit, Unit, Units}, units::{MinerUnit, Unit, Units},
}, },
@@ -32,6 +34,138 @@ pub struct BoardState {
} }
impl BoardState { impl BoardState {
pub fn advance_recruitment(&mut self) -> () {
for (row, cells) in self.cells.iter_mut().enumerate() {
for (col, cell) in cells.iter_mut().enumerate() {
if let Structures::Base(mut base) = cell.get_structure() {
if base.advance_recruitment(cell.get_ref_option_unit().is_some()) {
*cell.get_mut_option_unit() =
Some(Units::Miner(MinerUnit::new(base.get_owner(), (row, col))));
}
cell.set_structure(Structures::Base(base));
}
}
}
}
fn adjacent_cells(&self, (row, col): (usize, usize)) -> Vec<&CellWidget> {
let coordinates: [(usize, usize); 4] = [
(row.wrapping_sub(1), col),
(row.saturating_add(1), col),
(row, col.wrapping_sub(1)),
(row, col.saturating_add(1)),
];
coordinates
.into_iter()
.filter_map(|(row, col): (usize, usize)| -> Option<&CellWidget> {
self
.cells
.get(row)
.and_then(|cells: &Vec<CellWidget>| -> Option<&CellWidget> { cells.get(col) })
})
.collect()
}
pub fn has_adjacent_builder(&self, coords: (usize, usize), owner: Players) -> bool {
owner != Players::Unclaimed
&& self
.adjacent_cells(coords)
.iter()
.any(|cell: &&CellWidget| -> bool {
let unit: &Option<Units> = cell.get_ref_option_unit();
unit.get_owner() == owner && unit.get_can_build()
})
}
pub fn can_build_at(
&self,
(row, col): (usize, usize),
owner: Players,
terrain: BuildingTerrain,
) -> bool {
if owner == Players::Unclaimed {
return false;
}
let Some(cell) = self
.cells
.get(row)
.and_then(|cells: &Vec<CellWidget>| -> Option<&CellWidget> { cells.get(col) })
else {
return false;
};
if cell.get_ref_option_unit().is_some() {
return false;
}
if !self.has_adjacent_builder((row, col), owner) {
return false;
}
match (terrain, cell.get_structure()) {
(BuildingTerrain::Ore, Structures::Ore(ore)) => {
ore.get_owner() == Players::Unclaimed && ore.get_ore_amount() > 0
}
(BuildingTerrain::Stone, Structures::Stone(_)) => {
self
.adjacent_cells((row, col))
.iter()
.any(|cell: &&CellWidget| -> bool {
matches!(cell.get_structure(), Structures::Tunnel(_))
|| matches!(cell.get_structure(), Structures::Base(_))
&& cell.get_structure().get_owner() == owner
})
}
_ => false,
}
}
pub fn start_mine_construction(&mut self, coords: (usize, usize), owner: Players) -> bool {
if !self.can_build_at(coords, owner, BuildingTerrain::Ore) {
return false;
}
let cell: &mut CellWidget = self.get_mut_cell(coords.0, coords.1);
if let Structures::Ore(ore) = cell.get_structure()
&& let Some(site) = MineConstruction::new(owner, ore)
{
cell.set_structure(Structures::MineConstruction(site));
return true;
}
false
}
pub fn cancel_mine_construction(&mut self, (row, col): (usize, usize), owner: Players) -> bool {
let Some(cell): Option<&mut CellWidget> = self
.cells
.get_mut(row)
.and_then(|cells: &mut Vec<CellWidget>| -> Option<&mut CellWidget> { cells.get_mut(col) })
else {
return false;
};
if let Structures::MineConstruction(site) = cell.get_structure()
&& site.get_owner() == owner
{
cell.set_structure(Structures::Ore(site.cancel()));
return true;
}
false
}
pub fn advance_construction(&mut self) -> () {
for row in 0..self.cells.len() {
for col in 0..self.cells[row].len() {
if let Structures::MineConstruction(mut site) = self.cells[row][col].get_structure()
&& self.has_adjacent_builder((row, col), site.get_owner())
{
site.advance();
let structure: Structures = if let Some(mine) = site.into_mine() {
Structures::Mine(mine)
} else {
Structures::MineConstruction(site)
};
self.cells[row][col].set_structure(structure);
}
}
}
}
pub fn new( pub fn new(
area: &Rect, area: &Rect,
map_width: usize, map_width: usize,
@@ -79,9 +213,9 @@ impl BoardState {
} else if enemy_base { } else if enemy_base {
Structures::Base(BaseBuilding::new(Players::Enemy)) Structures::Base(BaseBuilding::new(Players::Enemy))
} else if player_ore { } else if player_ore {
Structures::Ore(Ore::new(Players::Player, 1)) Structures::Ore(Ore::new(Players::Unclaimed, 1))
} else if enemy_ore { } else if enemy_ore {
Structures::Ore(Ore::new(Players::Enemy, 1)) Structures::Ore(Ore::new(Players::Unclaimed, 1))
} else { } else {
Structures::Stone(Stone::new()) Structures::Stone(Stone::new())
}; };
@@ -286,10 +420,11 @@ impl BoardState {
.get(row) .get(row)
.and_then(|cells| cells.get(col)) .and_then(|cells| cells.get(col))
.is_some_and(|cell| { .is_some_and(|cell| {
matches!( self
cell.get_structure(), .marked_cells
Structures::Tunnel(_) | Structures::Base(_) .selected_unit
) .as_ref()
.is_some_and(|unit: &Units| -> bool { cell.get_structure().can_enter(unit) })
}) })
} }
@@ -419,11 +554,10 @@ impl BoardState {
let cell: &mut CellWidget = self.get_mut_cell(target_row, target_col); let cell: &mut CellWidget = self.get_mut_cell(target_row, target_col);
if cell.get_ref_option_unit().is_none() { if cell.get_ref_option_unit().is_none() {
let structure: Structures = cell.get_structure(); let structure: Structures = cell.get_structure();
let walkable: bool = structure.can_enter(&unit);
// Previously planned stone may already be a tunnel when this task starts. // Previously planned stone may already be a tunnel when this task starts.
task = match (task, structure) { task = match (task, structure) {
(Tasks::Digging(dig), Structures::Tunnel(_) | Structures::Base(_)) => { (Tasks::Digging(dig), _) if walkable => Tasks::Moving(MovingTask::from_digging(dig)),
Tasks::Moving(MovingTask::from_digging(dig))
}
(Tasks::Moving(moving), Structures::Stone(_)) => { (Tasks::Moving(moving), Structures::Stone(_)) => {
Tasks::Digging(DiggingTask::from_moving(moving, unit.get_digging_power())) Tasks::Digging(DiggingTask::from_moving(moving, unit.get_digging_power()))
} }
@@ -444,7 +578,7 @@ impl BoardState {
cell.set_structure(Structures::Stone(stone)); cell.set_structure(Structures::Stone(stone));
} }
} }
(Tasks::Moving(moving), Structures::Tunnel(_) | Structures::Base(_)) => { (Tasks::Moving(moving), _) if walkable => {
moving.progress(); moving.progress();
if moving.is_finished() { if moving.is_finished() {
destination = (target_row, target_col); destination = (target_row, target_col);
+2
View File
@@ -4,6 +4,7 @@ mod game_mode;
mod marked_cells; mod marked_cells;
mod offset; mod offset;
mod players; mod players;
pub mod resources;
pub mod structures; pub mod structures;
pub mod tasks; pub mod tasks;
pub mod units; pub mod units;
@@ -15,4 +16,5 @@ pub use game_mode::GameMode;
pub use marked_cells::MarkedCells; pub use marked_cells::MarkedCells;
pub use offset::Offset; pub use offset::Offset;
pub use players::Players; pub use players::Players;
pub use resources::Resources;
pub use zoom_level::{CellSizes, ZoomLevel}; pub use zoom_level::{CellSizes, ZoomLevel};
@@ -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);
}
}
@@ -1,5 +1,6 @@
use crate::app::states::skirmish_states::{Players, structures::Structure}; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BaseBuilding { pub struct BaseBuilding {
@@ -7,15 +8,78 @@ pub struct BaseBuilding {
stress: u8, stress: u8,
owner: Players, owner: Players,
level: u8, level: u8,
queued_miners: u32,
recruitment_ticks: u16,
waiting_for_departure: bool,
} }
impl BaseBuilding { impl BaseBuilding {
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 { pub fn new(owner: Players) -> Self {
Self { Self {
durability: 1500, durability: 1500,
stress: 0, stress: 0,
owner, owner,
level: 1, level: 1,
queued_miners: 0,
recruitment_ticks: 0,
waiting_for_departure: false,
} }
} }
} }
@@ -56,4 +120,19 @@ impl Structure for BaseBuilding {
fn get_owner(&self) -> Players { fn get_owner(&self) -> Players {
self.owner 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,96 @@
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: ".gray(),
self.get_ore_amount().to_string().cyan(),
format!("/{}", self.ore.get_max_ore_amount()).gray(),
]));
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,6 @@
mod base; mod base;
mod mine;
mod mine_construction;
mod ore; mod ore;
mod stone; mod stone;
mod structure_trait; mod structure_trait;
@@ -6,8 +8,16 @@ mod structures_enum;
mod tunnel; mod tunnel;
pub use base::BaseBuilding; pub use base::BaseBuilding;
pub use mine::Mine;
pub use mine_construction::MineConstruction;
pub use ore::Ore; pub use ore::Ore;
pub use stone::Stone; pub use stone::Stone;
pub use structure_trait::Structure; pub use structure_trait::Structure;
pub use structures_enum::Structures; pub use structures_enum::Structures;
pub use tunnel::Tunnel; pub use tunnel::Tunnel;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuildingTerrain {
Stone,
Ore,
}
@@ -32,6 +32,12 @@ impl Ore {
5000 5000
} }
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 { pub fn set_owner(&mut self, new_owner: Players) -> &mut Self {
self.owner = new_owner; self.owner = new_owner;
self self
@@ -1,6 +1,7 @@
use crate::app::states::skirmish_states::{ use crate::app::states::skirmish_states::{
Players, Players,
structures::{BaseBuilding, Ore, Stone, Structure, Tunnel}, structures::{BaseBuilding, Mine, MineConstruction, Ore, Stone, Structure, Tunnel},
units::Units,
}; };
use ratatui::{ use ratatui::{
style::{Color, Stylize}, style::{Color, Stylize},
@@ -13,9 +14,28 @@ pub enum Structures {
Tunnel(Tunnel), Tunnel(Tunnel),
Stone(Stone), Stone(Stone),
Ore(Ore), Ore(Ore),
Mine(Mine),
MineConstruction(MineConstruction),
} }
impl Structures { impl Structures {
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] #[inline]
fn s(&self) -> &dyn Structure { fn s(&self) -> &dyn Structure {
match self { match self {
@@ -23,6 +43,8 @@ impl Structures {
Self::Tunnel(t) => t, Self::Tunnel(t) => t,
Self::Stone(s) => s, Self::Stone(s) => s,
Self::Ore(o) => o, Self::Ore(o) => o,
Self::Mine(mine) => mine,
Self::MineConstruction(site) => site,
} }
} }
} }
@@ -1,7 +1,7 @@
use ratatui::{style::Stylize, text::Line}; use ratatui::{style::Stylize, text::Line};
use crate::app::states::skirmish_states::{ use crate::app::states::skirmish_states::{
Players, Players, Resources,
tasks::{Task, Tasks}, tasks::{Task, Tasks},
units::Unit, units::Unit,
}; };
@@ -18,6 +18,10 @@ pub struct MinerUnit {
} }
impl MinerUnit { impl MinerUnit {
pub const RECRUIT_COST: Resources = Resources {
wood: 10,
metal: 15,
};
pub fn new(owner: Players, coords: (usize, usize)) -> Self { pub fn new(owner: Players, coords: (usize, usize)) -> Self {
Self { Self {
owner, owner,
@@ -71,6 +75,10 @@ impl Unit for MinerUnit {
self.can_dig self.can_dig
} }
fn get_can_build(&self) -> bool {
true
}
fn get_digging_power(&self) -> f32 { fn get_digging_power(&self) -> f32 {
self.digging_power self.digging_power
} }
@@ -22,6 +22,10 @@ pub trait Unit {
fn get_can_dig(&self) -> bool; fn get_can_dig(&self) -> bool;
fn get_can_build(&self) -> bool {
false
}
fn get_digging_power(&self) -> f32; fn get_digging_power(&self) -> f32;
fn get_coords(&self) -> (usize, usize); fn get_coords(&self) -> (usize, usize);
@@ -66,6 +70,10 @@ pub trait Unit {
])); ]));
} }
if self.get_can_build() {
lines.push(Line::from("Can build: Yes").green());
}
if !have_any_tasks { if !have_any_tasks {
lines.push(Line::from("Tasks Queue: ".gray())); lines.push(Line::from("Tasks Queue: ".gray()));
for task in tasks { for task in tasks {
@@ -28,6 +28,9 @@ impl Units {
} }
impl Unit for Units { impl Unit for Units {
fn get_can_build(&self) -> bool {
self.u().get_can_build()
}
fn get_tag(&self) -> char { fn get_tag(&self) -> char {
self.u().get_tag() self.u().get_tag()
} }
@@ -82,6 +85,11 @@ impl Unit for Units {
} }
impl Unit for Option<Units> { impl Unit for Option<Units> {
fn get_can_build(&self) -> bool {
self
.as_ref()
.is_some_and(|unit: &Units| -> bool { unit.get_can_build() })
}
fn is_unit(&self) -> bool { fn is_unit(&self) -> bool {
self.as_ref().map_or(false, |u| u.is_unit()) self.as_ref().map_or(false, |u| u.is_unit())
} }
+143 -31
View File
@@ -1,46 +1,29 @@
use crate::app::{ use crate::app::{
App, App,
helpers::block_title_helper, helpers::block_title_helper,
keybindings::{Action, count_largest_group}, keybindings::{Action, available_skirmish_actions},
states::skirmish_states::BoardState, states::skirmish_states::{
widgets::{BoardWidget, KeybindingsWidget, SidePanelWidget}, BoardState, Players,
resources::{WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_INTERVAL_MS},
structures::{Mine, Structure, Structures},
units::MinerUnit,
},
widgets::{BoardWidget, CellWidget, KeybindingsWidget, SidePanelWidget},
}; };
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Alignment, Constraint, Layout, Margin, Rect}, layout::{Alignment, Constraint, Layout, Margin, Rect},
style::{Color, Stylize}, style::{Color, Stylize},
text::Line, text::Line,
widgets::{Block, Borders, Padding, Paragraph, Widget}, widgets::{Block, Borders, Clear, Padding, Paragraph, 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] { pub fn skirmish_layout(area: Rect) -> [Rect; 3] {
Layout::vertical([ Layout::vertical([
Constraint::Length(4), Constraint::Length(4),
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(count_largest_group(&ACTIONS) + 2), // Four movement rows plus borders keep the board geometry stable across modes.
Constraint::Length(6),
]) ])
.areas(area) .areas(area)
} }
@@ -68,8 +51,13 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
let lines: Vec<Line<'_>> = Vec::from_iter([ let lines: Vec<Line<'_>> = Vec::from_iter([
Line::raw("War in Tunnels").yellow(), Line::raw("War in Tunnels").yellow(),
Line::from_iter([ Line::from_iter([
format!("Wood: {} (+{}) | ", states.settings.starting_wood, 5), format!(
format!("Iron: {} (+{}) | ", states.settings.starting_iron, 1), "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!("Supply Limit: {}/{} | ", 10, states.settings.supply_limit),
format!( format!(
"Skills points: {} ({}/{}) | ", "Skills points: {} ({}/{}) | ",
@@ -148,6 +136,130 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
} }
{ {
KeybindingsWidget::new(ACTIONS, keybindings_area.height).render(keybindings_area, buf); let actions: Vec<Action> = available_skirmish_actions(&states.skirmish);
let descriptions: [(Action, &'static str); 11] = [
(
Action::Enter,
if states.skirmish.recruitment_view {
"Queue Miner"
} else {
"Build Mine"
},
),
(
Action::Recruitment,
if states.skirmish.recruitment_view {
"Close recruitment"
} else {
"Recruit"
},
),
(
Action::Delete,
if states.skirmish.building_view {
"Cancel construction"
} else {
"Cancel marking"
},
),
(
Action::Space,
if board.marked_cells.marking_cells {
"Confirm route"
} else {
"Select miner"
},
),
(
Action::Esc,
if states.skirmish.recruitment_view {
"Close recruitment"
} else if states.skirmish.building_view {
"Close buildings"
} else 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: String = if let Structures::MineConstruction(site) = structure
&& site.get_owner() == Players::Player
{
format!(
"Construction: {}%\n{}\nDelete: cancel and refund full cost",
site.progress_percent(),
if board.has_adjacent_builder(coords, Players::Player) {
"Builder working"
} else {
"Paused: adjacent friendly builder required"
}
)
} else if matches!(structure, Structures::Mine(_)) && structure.get_owner() == Players::Player {
"Mine completed".to_string()
} else if states
.skirmish
.can_start_mine_construction(coords, Players::Player)
{
"Ready to build: Enter".to_string()
} else {
"Construction unavailable: check site, builder and resources".to_string()
};
Clear.render(popup, buf);
Paragraph::new(format!("Mine\nCost: {} Wood + {} Metal | Build time: 10s\nRequires: neutral Ore + adjacent friendly builder\nProduces: 1 Metal/s (2 with a Miner inside)\n{status}", Mine::COST.wood, Mine::COST.metal))
.block(Block::bordered().title("Buildings").padding(Padding::uniform(1)))
.render(popup, 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 text: String = 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: &'static str = if states.skirmish.can_recruit_miner(coords, Players::Player) {
"Enter: queue Miner"
} else {
"Insufficient resources or queue full"
};
format!(
"Miner\nCost: {} Wood + {} Metal | Recruit time: 10s\nQueued Miners: {} (includes active)\nCurrent progress: {}%\n{status}\n{action}",
MinerUnit::RECRUIT_COST.wood,
MinerUnit::RECRUIT_COST.metal,
base.queued_miners(),
base.recruitment_progress()
)
} else {
"Select your Base to recruit Miners".to_string()
};
Clear.render(popup, buf);
Paragraph::new(text)
.block(
Block::bordered()
.title("Recruitment")
.padding(Padding::uniform(1)),
)
.render(popup, buf);
} }
} }
+4
View File
@@ -133,6 +133,10 @@ impl CellWidget {
let middle = lines.len() / 2; let middle = lines.len() / 2;
lines[middle] = Line::from(format!("{percent}%")).cyan(); lines[middle] = Line::from(format!("{percent}%")).cyan();
} }
if let Structures::MineConstruction(site) = self.structure {
let middle: usize = lines.len() / 2;
lines[middle] = Line::from(format!("{}%", site.progress_percent())).cyan();
}
if let Some(unit) = &self.unit if let Some(unit) = &self.unit
&& let Some(Tasks::Moving(task)) = unit.get_ref_tasks().front() && let Some(Tasks::Moving(task)) = unit.get_ref_tasks().front()
&& let Some(percent) = task.get_movement_progress() && let Some(percent) = task.get_movement_progress()
+23 -5
View File
@@ -20,6 +20,7 @@ use std::collections::HashSet;
pub struct KeybindingsWidget { pub struct KeybindingsWidget {
/// Paragraphs, one per used `Group`, containing the formatted keybinding lines. /// Paragraphs, one per used `Group`, containing the formatted keybinding lines.
grouped: Vec<Paragraph<'static>>, grouped: Vec<Paragraph<'static>>,
widths: Vec<u16>,
} }
impl KeybindingsWidget { impl KeybindingsWidget {
@@ -42,6 +43,14 @@ impl KeybindingsWidget {
/// ///
/// A fully‑initialised `KeybindingsWidget` ready for rendering. /// A fully‑initialised `KeybindingsWidget` ready for rendering.
pub fn new(actions: &[Action], area_height: u16) -> Self { pub fn new(actions: &[Action], area_height: u16) -> Self {
Self::with_descriptions(actions, area_height, &[])
}
pub fn with_descriptions(
actions: &[Action],
area_height: u16,
descriptions: &[(Action, &'static str)],
) -> Self {
let keybindings: Vec<Option<&'static KeyBinding>> = let keybindings: Vec<Option<&'static KeyBinding>> =
actions.iter().map(|a| binding_for(*a)).collect(); actions.iter().map(|a| binding_for(*a)).collect();
@@ -51,6 +60,7 @@ impl KeybindingsWidget {
.collect(); .collect();
let mut grouped_keybindings: Vec<Paragraph<'static>> = Vec::new(); let mut grouped_keybindings: Vec<Paragraph<'static>> = Vec::new();
let mut widths: Vec<u16> = Vec::new();
for group in Group::value_variants().iter() { for group in Group::value_variants().iter() {
if !used_groups.contains(group) { if !used_groups.contains(group) {
@@ -62,11 +72,20 @@ impl KeybindingsWidget {
.filter(|b| b.as_ref().map_or(false, |kb| kb.group == *group)) .filter(|b| b.as_ref().map_or(false, |kb| kb.group == *group))
.filter_map(|b| { .filter_map(|b| {
b.as_ref().map(|kb| { b.as_ref().map(|kb| {
Line::from_iter([kb.symbol.red().bold(), " - ".into(), kb.description.into()]) 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.red().bold(), " - ".into(), description.into()])
}) })
}) })
.collect(); .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 = let height_diff: u16 =
area_height.saturating_sub(grouped_lines.len().saturating_add(2) as u16); area_height.saturating_sub(grouped_lines.len().saturating_add(2) as u16);
@@ -91,6 +110,7 @@ impl KeybindingsWidget {
Self { Self {
grouped: grouped_keybindings, grouped: grouped_keybindings,
widths,
} }
} }
} }
@@ -99,7 +119,7 @@ impl Widget for KeybindingsWidget {
/// Render the widget inside the given rectangular area. /// Render the widget inside the given rectangular area.
/// ///
/// The widget first draws a surrounding border with the title *Keybindings*. /// The widget first draws a surrounding border with the title *Keybindings*.
/// It then divides the inner area into equal horizontal slices—one per /// 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. /// used group—and renders each group's paragraph into its slice.
/// ///
/// If no groups are present, the function returns early without drawing /// If no groups are present, the function returns early without drawing
@@ -122,9 +142,7 @@ impl Widget for KeybindingsWidget {
block.render(area, buf); block.render(area, buf);
let base: u16 = if count == 0 { 0 } else { 100u16 / count }; let constraints: Vec<Constraint> = self.widths.into_iter().map(Constraint::Fill).collect();
let constraints: Vec<Constraint> = vec![Constraint::Percentage(base); count as usize];
let chunks: Vec<Rect> = Layout::horizontal(constraints).split(inner).to_vec(); let chunks: Vec<Rect> = Layout::horizontal(constraints).split(inner).to_vec();
+20 -1
View File
@@ -9,6 +9,7 @@ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Alignment, Constraint, Layout, Margin, Rect}, layout::{Alignment, Constraint, Layout, Margin, Rect},
style::Color, style::Color,
text::Line,
widgets::{Block, BorderType, Borders, Padding, Paragraph, Widget}, widgets::{Block, BorderType, Borders, Padding, Paragraph, Widget},
}; };
@@ -87,7 +88,25 @@ impl Widget for SidePanelWidget<'_> {
let [structure_area, unit_area] = let [structure_area, unit_area] =
Layout::vertical(self.get_area_constraints()).areas(inner_area); Layout::vertical(self.get_area_constraints()).areas(inner_area);
Paragraph::new(self.structure.text()) 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) .alignment(Alignment::Left)
.block(self.get_inner_block(self.structure.get_name().to_string(), Color::Cyan)) .block(self.get_inner_block(self.structure.get_name().to_string(), Color::Cyan))
.render(structure_area, buf); .render(structure_area, buf);
+4 -3
View File
@@ -108,16 +108,17 @@ pub struct Cli {
)] )]
pub starting_wood: u16, pub starting_wood: u16,
/// Starting amount of iron (1–150). /// Starting amount of metal (1–150).
#[arg( #[arg(
long, long,
short = 'i', short = 'i',
help = "Starting iron", help = "Starting metal",
alias = "starting-iron",
value_name = "Positive integer [1; 150]", value_name = "Positive integer [1; 150]",
default_value = "25", default_value = "25",
value_parser = value_parser!(u16).range(1..=150) value_parser = value_parser!(u16).range(1..=150)
)] )]
pub starting_iron: u16, pub starting_metal: u16,
/// Maximum amount of units (49–149). /// Maximum amount of units (49–149).
#[arg( #[arg(
+4
View File
@@ -26,6 +26,10 @@ fn map_has_opposing_bases_ore_and_two_miners() {
board.get_ref_cell(5, ore_col).get_structure(), board.get_ref_cell(5, ore_col).get_structure(),
Structures::Ore(_) Structures::Ore(_)
)); ));
assert_eq!(
board.get_ref_cell(5, ore_col).get_structure().get_owner(),
Players::Unclaimed
);
} }
assert_eq!( assert_eq!(
board board
+1 -1
View File
@@ -19,7 +19,7 @@ fn numeric_options_accept_boundaries_and_reject_out_of_range_values() {
("--map-width", 36, 108), ("--map-width", 36, 108),
("--map-height", 11, 39), ("--map-height", 11, 39),
("--starting-wood", 1, 150), ("--starting-wood", 1, 150),
("--starting-iron", 1, 150), ("--starting-metal", 1, 150),
("--supply-limit", 49, 149), ("--supply-limit", 49, 149),
("--skill-points-limit", 90, 690), ("--skill-points-limit", 90, 690),
] { ] {
+420
View File
@@ -0,0 +1,420 @@
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(' '), KeyModifiers::CONTROL),
);
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_and_delete_cancels_while_escape_only_closes_panel() -> () {
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(' '), KeyModifiers::CONTROL),
);
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!(matches!(
app.states_ref().unwrap().skirmish.board.cells[5][1].get_structure(),
Structures::MineConstruction(_)
));
handle_keybindings(
&mut app,
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL),
);
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);
}
+160
View File
@@ -0,0 +1,160 @@
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::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(' '), KeyModifiers::CONTROL);
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(' '), KeyModifiers::CONTROL);
assert!(app.states_ref().unwrap().skirmish.building_view);
assert_eq!(
available_skirmish_actions(&app.states_ref().unwrap().skirmish),
[Action::Building, Action::Esc, Action::Quit2]
);
press(&mut app, KeyCode::Left, 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"));
press(&mut app, KeyCode::Esc, 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 legacy_control_space_and_quit_work_in_building_view() -> () {
let mut app: App = app();
press(&mut app, KeyCode::Null, KeyModifiers::NONE);
assert!(app.states_ref().unwrap().skirmish.building_view);
press(&mut app, KeyCode::Null, KeyModifiers::CONTROL);
assert!(!app.states_ref().unwrap().skirmish.building_view);
press(&mut app, KeyCode::Null, 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("Ctrl + Space - 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("Ctrl + Space"));
assert!(!text.contains("Zoom in"));
}
+338
View File
@@ -0,0 +1,338 @@
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::{
MoveFocusedCell, Players, Resources,
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 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(' '), KeyModifiers::ALT);
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(' '), KeyModifiers::CONTROL);
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"));
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(' '), KeyModifiers::ALT);
press(&mut app, KeyCode::Char(' '), KeyModifiers::ALT);
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(' '), KeyModifiers::ALT);
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(' '), KeyModifiers::ALT);
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(' '), KeyModifiers::ALT);
press(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(app.exit);
}
#[test]
fn recruitment_fallback_opens_queues_and_closes_on_own_base() -> () {
let mut app: App = app();
press(&mut app, KeyCode::Left, KeyModifiers::NONE);
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)
);
}
+161
View File
@@ -0,0 +1,161 @@
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:"));
}