generated from GarandPLG/rust-flake-template
Add MovingTask and progress tracking
Introduce a new MovingTask type for movement segments and extend the Tasks enum to include it. Add total‑step tracking and a get_total_progress default in the Task trait, and implement the corresponding methods for MovingTask. Update units to allow coordinate changes via set_coords, add stone damage handling, and adjust board advancement logic to use the new task system. Update UI code to display step and total percentages in the Tab panel. Expand documentation (README, AGENTS, CODE_MAP, PROGRESS, TESTING) to describe digging and moving, and add extensive tests (including tests/digging.rs) for the new behaviour.
This commit is contained in:
1 parent
fcdbbaf74c
commit
9e09aaf75e
21 files changed
+1043
-138
No files matched your search
@@ -87,6 +87,20 @@ cargo run -- -h
|
||||
- `Cargo.toml` – zależności Rust (ratatui, clap, log, simplelog, rodio, once_cell).
|
||||
- `flake.nix` – opis flake, zależności Nix, profile build i develop.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
Podczas przechodzenia na kratce jednostki pojawia się procent ukończenia bieżącego kroku, np. `50%`. Wskaźnik resetuje się po przejściu i znika, gdy jednostka nie podróżuje. Jest widoczny na wszystkich poziomach zoomu, w kolorze właściciela jednostki.
|
||||
|
||||
Zatwierdzona ścieżka dzieli się automatycznie na zadania `Moving` (`→`, gotowe tunele i bazy) oraz `Digging` (`⛏`, kamień). Jednostka wykonuje je kolejno; po wykopaniu kamienia wejście do nowego tunelu pozostaje częścią kopania.
|
||||
|
||||
W kolejce pod Tab każde zadanie pokazuje `Step` (postęp bieżącej kratki) oraz `Total` (postęp całego zadania według liczby kratek, z uwzględnieniem częściowo wykonanego etapu). Zadania oczekujące mają oba wskaźniki na 0%.
|
||||
|
||||
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ń.
|
||||
|
||||
## 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.
|
||||
|
||||
+3
-2
@@ -25,15 +25,16 @@ nix develop -c cargo run -- --disable-audio --view skirmish
|
||||
|
||||
Ostatnie polecenie wymaga interaktywnego terminala. `shellHook` sam wykonuje `cargo run -- -h`, więc wejście do środowiska także kompiluje program i drukuje pomoc. Formatowanie stosuj wyłącznie do uzgodnionych zmian; nie używaj automatycznego `clippy --fix` do porządkowania całego repozytorium.
|
||||
|
||||
Stan bazowy po dodaniu zestawu testów: 23 testy i formatowanie przechodzą; Clippy zgłasza 51 ostrzeżeń dla biblioteki. 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.
|
||||
Stan bazowy po dodaniu postępu kolejki: 45 testów i formatowanie przechodzą; Clippy zgłasza 50 ostrzeżeń dla biblioteki i 4 ostrzeżenia testów o jawnych `-> ()`, zgodnych z poniższą zasadą typowania. 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.
|
||||
|
||||
Audio wymaga ALSA i urządzenia odtwarzania; do pracy nad TUI używaj `--disable-audio`. `--mute` nadal uruchamia wątek audio. Ścieżki zasobów i pakowanie sprawdzaj osobno przy zmianach audio/Nix. Rozróżniaj lokalny build Cargo, pakiet `.#develop` i domyślny pakiet Nix.
|
||||
|
||||
## Konwencje kodu
|
||||
|
||||
- Jawne adnotacje typów są OBOWIĄZKOWE w nowym i zmienianym kodzie: przy zmiennych i stałych, polach struktur, parametrach funkcji i domknięć oraz typach zwracanych (także `-> ()` dla funkcji bez wyniku). Nie polegaj na inferencji typu tam, gdzie można podać typ jawnie. Dotyczy to również testów. Jest to zasada jawnego typowania; rzutowania wartości (`as`, `From`, `TryFrom`) stosuj tylko wtedy, gdy potrzebna jest rzeczywista konwersja.
|
||||
- Rust 2024, jedna biblioteka i jeden binarny punkt wejścia; bez workspace.
|
||||
- Wykorzystuj istniejące `App`, `GameStates`, `BoardState`, akcje i widgety.
|
||||
- Współrzędne planszy to `(row, col)`, indeksowanie `cells[row][col]`; rozmiar mapy i rozmiar widocznego obszaru to różne wartości.
|
||||
- `CellWidget` przechowuje także dane gry. Zmiana modelu komórki dotyka planszy, jednostek, struktur i panelu bocznego.
|
||||
- Lista skrótów w widokach oraz tabela `KEYBINDINGS` muszą odpowiadać obsłudze akcji.
|
||||
- Nie włączaj `advance_turn` bez sprawdzenia opisanych w mapie problemów symulacji.
|
||||
- `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`.
|
||||
+19
-11
@@ -1,6 +1,6 @@
|
||||
# Mapa kodu i przepływu
|
||||
|
||||
Migawka: 2026-10-18. Projekt `war_in_tunnels` 0.1.0 to prototyp terminalowego RTS. Działają menu, prezentacja planszy, nawigacja, zaznaczanie ścieżki i obsługa audio; pełna symulacja nie jest podłączona do zegara.
|
||||
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.
|
||||
|
||||
## Moduły i odpowiedzialność
|
||||
|
||||
@@ -14,7 +14,7 @@ Wszystkie ścieżki poniżej są względem katalogu głównego repozytorium.
|
||||
| Zbiór stanów | `src/app/state.rs`, `src/app/states/` | `GameStates`, menu, potyczka, ustawienia, perki, umiejętności |
|
||||
| Routing widoku | `src/app/view.rs`, `src/app/views/` | `View`, implementacja `Widget for &mut App`, layouty i ekrany |
|
||||
| Wejście | `src/app/keybind.rs`, `src/app/keybindings/` | Routing według widoku, `KEYBINDINGS`, `Action`, `Group`, mapowanie zdarzeń |
|
||||
| Plansza | `src/app/states/skirmish_states/board.rs` | Tworzenie mapy, kamera, zoom, focus, zaznaczenia, zlecanie i szkic wykonywania 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 |
|
||||
| Obiekty gry | `skirmish_states/structures/`, `units/`, `tasks/` | Traity `Structure`, `Unit`, `Task` i delegujące enumy `Structures`, `Units`, `Tasks` |
|
||||
| Widgety | `src/app/widgets/` | Plansza, komórka, panel boczny i legenda klawiszy |
|
||||
@@ -47,7 +47,7 @@ flowchart TD
|
||||
LOOP -->|exit lub błąd| RESTORE[ratatui::restore]
|
||||
```
|
||||
|
||||
Kanały to standardowe `std::sync::mpsc`. Stan gry mutuje główny wątek. Każdy obrót pętli najpierw rysuje, następnie próbuje odebrać po jednym zdarzeniu z każdego kanału. Aktualizacja potyczki odbywa się niezależnie od wybranego ekranu. `tick_update` obecnie tylko zwiększa `turn_counter`; `self.board.advance_turn()` jest zakomentowane.
|
||||
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`.
|
||||
|
||||
@@ -70,7 +70,7 @@ flowchart LR
|
||||
|
||||
`BoardState.cells: Vec<Vec<CellWidget>>` łączy model i prezentację: komórka zawiera strukturę, opcjonalną jednostkę, pozycję i flagi UI. `BoardWidget` wybiera widoczne komórki przez offsety i layout; `CellWidget` odpowiada za ich tekst i styl. Panel boczny prezentuje dane zaznaczenia. Zoom wpływa na wielkość komórek i liczbę widocznych wierszy/kolumn; resize i przełączanie panelu zmieniają granice offsetów.
|
||||
|
||||
Mapa powstaje deterministycznie: kamień, baza gracza w `(map_height / 2, 2)`, baza przeciwnika w `(map_height / 2, map_width - 3)`, sąsiadujące złoża i po jednym górniku w bazie. CLI domyślnie wybiera mapę 42 × 11; dopuszczalne zakresy to szerokość 36–108 i wysokość 11–39. Dostępne struktury to Base, Ore, Stone, Tunnel; jedyna jednostka to Miner, jedyne zadanie to Digging.
|
||||
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.
|
||||
|
||||
## Klawisze i zlecanie kopania
|
||||
|
||||
@@ -80,9 +80,19 @@ Mapa powstaje deterministycznie: kamień, baza gracza w `(map_height / 2, 2)`, b
|
||||
4. Akcje wspólne realizują wyjście, powrót do menu i komendy audio.
|
||||
5. W potyczce ruch zmienia focus i ewentualnie kamerę; zoom przelicza rozmiary; Tab przełącza panel.
|
||||
6. Spacja przełącza zaznaczanie. `start_marking_cells` zapamiętuje jednostkę z komórki początkowej; ruch dopisuje współrzędne do kolejki. Backspace cofa ścieżkę, Delete ją anuluje.
|
||||
7. `end_marking_cells(false)` pomija pierwsze pole, tworzy `DiggingTask` z pozostałej ścieżki i siły kopania oraz przekazuje zadanie jednostce.
|
||||
7. `end_marking_cells(false)` pomija pierwsze pole i dzieli trasę na kolejne jednorodne odcinki: `MovingTask` dla tuneli/baz i `DiggingTask` dla kamienia. Dodaje je do kolejki w kolejności ścieżki; w panelu zadania mają ikony `→` i `⛏`.
|
||||
|
||||
Projektowane wykonanie: `advance_turn` skanuje komórki, wyjmuje jednostkę przez `take()`, obsługuje pierwsze zadanie i przenosi jednostkę na cel, zastępując strukturę tunelem. Ten przepływ istnieje w kodzie, ale nie jest obecnie wywoływany przez tick.
|
||||
`start_marking_cells` dopuszcza wyłącznie własnego górnika. Jeśli ma już zadania, nowa ścieżka zaczyna się na końcu ostatniego zadania. Jednostka wybrana do rysowania wstrzymuje wykonywanie zadań do zatwierdzenia lub anulowania trasy; powtarzany ruch przy granicy nie dopisuje kolejnych kopii tej samej kratki.
|
||||
|
||||
`advance_turn` najpierw zbiera pozycje pracujących jednostek, aby każda była aktualizowana tylko raz. Wyjmuje jednostkę i pierwsze zadanie, sprawdza granice, sąsiedztwo i zajętość celu. Przy kamieniu różnica skumulowanego postępu zadania daje obrażenia: `digging_power` 0.55 oznacza około 5.5 durability na tick przy maksymalnym durability 1000. Zaokrąglenie skumulowanej wartości zachowuje część ułamkową. Pełne kopanie trwa około 182 ticków (6 sekund). `Stone::take_damage` nie pozwala zejść poniżej zera.
|
||||
|
||||
Po zniszczeniu kamienia cel staje się tunelem i jednostka wchodzi do niego w tym samym ticku w ramach `Digging`. `MovingTask` obsługuje przejście do istniejącego tunelu lub pustej bazy: wymaga 91 ticków (3003 ms przy `TICK_MS = 33`), nie zmienia durability. Jego licznik `ticks` resetuje się dla każdego kroku; blokada lub rysowanie trasy wstrzymuje naliczanie. Jeśli teren zmieni się od zaplanowania trasy (np. kamień został już wykopany albo ścieżka wraca przez nowy tunel), `advance_turn` zmienia typ bieżącego zadania według aktualnego celu, zachowując pozostałą ścieżkę i kolejkę.
|
||||
|
||||
Ruch aktualizuje współrzędne jednostki i usuwa krok ścieżki. Zakończone zadanie znika, następne może wykonać się od kolejnego ticka. Zajęty cel lub ruda blokuje pracę bez niszczenia obiektu. Pusta ścieżka jest usuwana, a cel poza mapą lub niesąsiadujący anuluje bieżące zadanie bez teleportacji. Uszkodzony kamień pokazuje pozostałą wytrzymałość w procentach na planszy; panel boczny pokazuje dokładne durability. Znacznik jednostki jest osobnym stylowanym `Span` z kolorem `Players::get_color()`, niezależnym od koloru tunelu i zaznaczenia komórki.
|
||||
|
||||
Postęp bieżącego kroku jest udostępniany przez `MovingTask::get_movement_progress()`: brak wartości przed rozpoczęciem ruchu, procent obliczany z tego samego `MOVEMENT_TICKS`, który wyznacza czas przejścia. `CellWidget` odczytuje go wyłącznie z aktywnego `Tasks::Moving` i pokazuje na środku kratki jednostki w kolorze jej właściciela. W normalnym i powiększonym widoku pozostaje widoczny osobny znacznik jednostki; w pomniejszonym procent zastępuje znak struktury na czas ruchu. Zakończenie kroku resetuje wskaźnik. `DiggingTask` przechowuje wyłącznie postęp kopania, bez licznika podróży.
|
||||
|
||||
`Task::get_progress()` oznacza postęp bieżącej kratki, a `get_total_progress()` uwzględnia wykonane kratki i bieżący etap. Oba typy zadań zapamiętują początkową długość trasy w `total_steps`; `from_digging` i `from_moving` zachowują ją przy zmianie typu. `MinerUnit::get_tasks_formatted()` prezentuje każde zadanie w dwóch wierszach: trasa oraz `Step: …% | Total: …%`. Wartość całkowita jest liczona według liczby kratek, nie szacowanego czasu pozostałego do końca.
|
||||
|
||||
## Audio i pakowanie
|
||||
|
||||
@@ -96,13 +106,11 @@ Rodio zapętla źródło przez `Decoder::new_looped`. Wątek sterujący blokuje
|
||||
|
||||
To obserwacje statyczne, nie lista napraw wykonanych w tej sesji:
|
||||
|
||||
- **Symulacja wyłączona:** `SkirmishState::tick_update` nie wywołuje `advance_turn`.
|
||||
- **Postęp zadania:** naprawiono zapisywanie przyrostu i ograniczenie do 0–100; zachowanie jest objęte testami w `tests/tasks.rs`. Nie oznacza to uruchomienia symulacji.
|
||||
- **Ruch w szkicu symulacji:** `advance_turn` pobiera cel bez warunku osiągnięcia postępu, nie aktualizuje współrzędnych jednostki i nie usuwa zakończonego zadania z kolejki. Jednostka przeniesiona na późniejszą komórkę może zostać obsłużona ponownie w tym samym skanowaniu. Zapis celu może nadpisać inną jednostkę i strukturę. Przed aktywacją potrzebne są reguły kolizji i testy pojedynczej tury.
|
||||
- **Zaznaczenie:** jednostka startowa jest zapamiętywana oddzielnie; przy uruchomieniu ruchu trzeba sprawdzić ważność jej pozycji i zachowanie dla pustej komórki oraz jednostki przeciwnika.
|
||||
- **Blokady:** jednostki czekają przed zajętym polem i rudą. Nie ma automatycznego omijania przeszkód ani rozwiązywania sytuacji, w której dwie jednostki wzajemnie blokują przejście.
|
||||
- **Zaznaczenie:** wybrany górnik czeka podczas rysowania trasy; inne jednostki wykonują zadania normalnie.
|
||||
- **Audio:** usunięto aktywne odpytywanie; pomiar CPU na docelowym urządzeniu i odsłuch pozostają do wykonania. Test dekodera nie weryfikuje urządzenia audio.
|
||||
- **Granice UI:** małe terminale, resize i trzy poziomy zoomu wymagają interaktywnego sprawdzenia. Indeksowanie planszy zakłada poprawne współrzędne.
|
||||
- **Pokrycie:** 23 testy CLI, sterowania, renderowania, planszy, zadań i dekodowania audio; szczegóły w [TESTING.md](TESTING.md). Brak testów pełnej symulacji i konfiguracji CI. Clippy zgłasza 51 ostrzeżeń; nie uruchamiać automatycznych poprawek poza zakresem zadania.
|
||||
- **Pokrycie:** 45 testów CLI, sterowania, renderowania, planszy, wykonywania zadań i dekodowania audio; szczegóły w [TESTING.md](TESTING.md). Brak testów pełnej rozgrywki i konfiguracji CI. Clippy zgłasza 50 ostrzeżeń biblioteki oraz 4 ostrzeżenia o wymaganych jawnych `-> ()` w testach; nie uruchamiać automatycznych poprawek poza zakresem zadania.
|
||||
|
||||
## Gdzie zacząć konkretną zmianę
|
||||
|
||||
|
||||
@@ -1,5 +1,73 @@
|
||||
# Dziennik prac
|
||||
|
||||
## 2026-10-18 — przekazanie kontekstu na kolejną sesję
|
||||
|
||||
Użytkownik zaakceptował aktualny stan. Nie ma ustalonego kolejnego zadania. Na start kolejnej sesji przeczytaj `docs/AGENTS.md`, `docs/CODE_MAP.md` i `docs/TESTING.md`; instrukcje w `docs/` mogą wymagać jawnego wskazania agentowi.
|
||||
|
||||
Stan końcowy: zoptymalizowane audio, wykonywanie kopania i ruchu w osobnych zadaniach, 3-sekundowy krok, kolor jednostki według właściciela, procent ruchu na kratce oraz postęp etapu i całości zadania pod Tab. Ostatni pełny zestaw: 45 testów zaliczonych, kompilacja i formatowanie poprawne. Ostrzeżenia Clippy: 50 biblioteki oraz 4 stylistyczne uwagi o wymaganym jawnym `-> ()` w testach.
|
||||
|
||||
Uwagi do dalszej pracy:
|
||||
|
||||
- `Total` jest postępem według liczby kratek, nie przewidywanym czasem zakończenia. Przy zmianie rodzaju zadania zachowuje liczbę wykonanych etapów, ale postęp bieżącego etapu jest resetowany.
|
||||
- Zajęte pole lub ruda zatrzymuje jednostkę; brak omijania przeszkód. Rysowanie trasy pauzuje wybraną jednostkę. Potyczka nadal otrzymuje ticki także poza jej ekranem.
|
||||
- Kolejka w panelu zajmuje dwa wiersze na zadanie. Długie kolejki w małym terminalu mogą zostać ucięte; przewijania panelu nie dodano.
|
||||
- Przed wydaniem pozostają ręczne sprawdzenie TUI na różnych rozmiarach terminala, odsłuch i pomiar CPU audio oraz budowa pakietów Nix. Zmiany zależności z tej sesji mogą wymagać aktualizacji `cargoHash` w `default.nix`; nie było to weryfikowane.
|
||||
- Zmiany pozostają niezatwierdzone w Git, w tym nowe `tasks/moving.rs` i `tests/digging.rs`. Przed commitem sprawdź pełne `git status`, aby uwzględnić także pliki nieśledzone. Nie wykonano commita ani publikacji.
|
||||
|
||||
W tym kroku zmieniono wyłącznie dokumentację; nie rozpoczynano nowych poprawek ani refaktoryzacji. Końcowe `nix develop -c cargo check --all-targets` zakończone sukcesem; testów nie powtarzano po zmianie samej dokumentacji.
|
||||
|
||||
## 2026-10-18 — postęp zadań w panelu i obowiązkowe adnotacje typów
|
||||
|
||||
Kolejka pod Tab pokazuje osobno postęp bieżącej kratki (`Step`) i całego zadania (`Total`). Całość jest liczona według początkowej liczby kratek: wykonane etapy plus ułamek bieżącego. Oba typy zadań przechowują `total_steps`, zachowywane także przy zmianie Moving/Digging wskutek zmiany terenu. Zadania oczekujące pokazują 0%, zakończone osiągają 100%; pusta trasa nie powoduje dzielenia przez zero.
|
||||
|
||||
Dodano do `docs/AGENTS.md` obowiązek jawnych adnotacji typów w nowym i zmienianym kodzie, włącznie z testami, parametrami domknięć i wynikami funkcji. Rozróżniono adnotacje typów od rzeczywistych konwersji wartości; nie wykonywano masowego przepisywania wcześniejszego kodu.
|
||||
|
||||
Dodano 4 testy: postęp całego kopania między etapami, zachowanie początkowej liczby etapów przy konwersji typu, formatowanie kopania i oczekującego ruchu oraz renderowanie panelu Tab z aktywnym ruchem (`Step: 50% | Total: 75%`) i kolejnym zadaniem na 0%. Zaktualizowano README i dokumentację.
|
||||
|
||||
Weryfikacja przez `nix develop -c`: `cargo check --all-targets`, `cargo test --all-targets` (45 zaliczonych), `cargo fmt --all -- --check` i `cargo clippy --all-targets` zakończone sukcesem. Clippy raportuje 50 dotychczasowych ostrzeżeń biblioteki i 4 ostrzeżenia `unused_unit` w testach za jawne `-> ()`, wymagane nową konwencją. Nie wykonywano ręcznej sesji terminala ani budowy pakietów Nix.
|
||||
|
||||
## 2026-10-18 — osobne zadanie Moving
|
||||
|
||||
Dodano `MovingTask` i wariant `Tasks::Moving`. Przeniesiono do niego licznik 3-sekundowego przejścia, procentowy postęp i reset dla każdej kratki. `DiggingTask` zawiera teraz tylko stan kopania. Eksporty i delegowanie wspólnego traitu `Task` obsługują oba warianty.
|
||||
|
||||
Zatwierdzona spacją ścieżka jest dzielona na odcinki `Moving` (tunele/bazy) i `Digging` (kamień), z zachowaniem kolejności. Kolejka pokazuje odpowiednio ikony `→` i `⛏`. Wykonywanie ruchu nie uszkadza struktur; wejście do świeżo wykopanego tunelu nadal następuje w ramach kopania. Gdy teren zmieni się od planowania lub jednostka wraca przez właśnie wykopane kratki, typ aktywnego zadania dopasowuje się do aktualnego celu bez utraty reszty trasy. TUI pobiera timer tylko z `Moving`.
|
||||
|
||||
Dostosowano test timera do `MovingTask`. Dodano regresje podziału mieszanej ścieżki i wykonania całej kolejki, zachowania durability podczas ruchu, powrotu przez świeżo wykopany tunel i zmiany typu zadania po zmianie terenu. Zaktualizowano README, mapę i instrukcje agentów.
|
||||
|
||||
Weryfikacja przez `nix develop -c`: `cargo check --all-targets`, `cargo test --all-targets` (41 zaliczonych), `cargo fmt --all -- --check` i `cargo clippy --all-targets` zakończone sukcesem. Clippy nadal zgłasza 50 ostrzeżeń biblioteki. Bez nowych zależności. Nie wykonywano ręcznej sesji TUI ani budowy pakietów Nix.
|
||||
|
||||
## 2026-10-18 — procentowy wskaźnik przejścia
|
||||
|
||||
Dodano na kratce poruszającej się jednostki procent ukończenia bieżącego kroku, w kolorze właściciela. Wskaźnik korzysta z istniejącego licznika ruchu, a czas przejścia i wyświetlane procenty mają wspólną stałą `MOVEMENT_TICKS`. Po wejściu na następną kratkę wskaźnik resetuje się; bez ruchu nie jest wyświetlany. W pomniejszonym widoku na czas ruchu zastępuje znak struktury, w pozostałych nie zasłania znacznika jednostki. Pauza ruchu zatrzymuje również postęp wskaźnika.
|
||||
|
||||
Dodano test rozdzielenia postępu kopania i ruchu oraz resetu po kroku. Test TUI sprawdza 50% w połowie przejścia na wszystkich poziomach zoomu, zachowanie koloru `M`, reset na kolejnym polu i zniknięcie wskaźnika po ukończeniu zadania. Zaktualizowano README i dokumentację.
|
||||
|
||||
Weryfikacja przez `nix develop -c`: `cargo check --all-targets`, `cargo test --all-targets` (37 zaliczonych), `cargo fmt --all -- --check` i `cargo clippy --all-targets` zakończone sukcesem. Clippy nadal zgłasza 50 ostrzeżeń biblioteki. Nie wykonywano ręcznych oględzin terminala; renderowanie sprawdzono buforem Ratatui.
|
||||
|
||||
## 2026-10-18 — czas podróży i kolor znacznika jednostki
|
||||
|
||||
Przejście do istniejącego tunelu lub pustej bazy trwa teraz 91 ticków, czyli około 3 sekund przy ticku 33 ms. Licznik ruchu znajduje się w zadaniu i resetuje po każdym wykonanym kroku. Zajęty cel oraz rysowanie trasy wstrzymują naliczanie. Wejście do świeżo wykopanego tunelu nadal odbywa się w ticku zniszczenia kamienia.
|
||||
|
||||
Znacznik jednostki w normalnym i powiększonym widoku jest osobnym `Span` w kolorze właściciela (`Players::get_color`), również na tunelu oraz przy zaznaczeniu lub oznaczeniu komórki. Dotychczasowy układ komórki i kolor jej obramowania pozostają zachowane.
|
||||
|
||||
Dostosowano testy ruchu, kolejek i blokad do nowego czasu. Dodano regresję natychmiastowego wejścia po kopaniu oraz test koloru znacznika dla obu graczy, dwóch poziomów zoomu i wszystkich kombinacji zaznaczenia/oznaczenia. Łącznie 35 testów przechodzi. Zaktualizowano README oraz dokumentację agentów.
|
||||
|
||||
Weryfikacja przez `nix develop -c`: `cargo check --all-targets`, `cargo test --all-targets`, `cargo fmt --all -- --check` i `cargo clippy --all-targets` zakończone sukcesem. Clippy nadal raportuje 50 ostrzeżeń biblioteki. Nie wykonywano ręcznego pomiaru czasu ani oględzin terminala; czas zweryfikowano tickami, kolory buforem Ratatui. W pomniejszonym widoku nadal wyświetlany jest wyłącznie znacznik struktury.
|
||||
|
||||
## 2026-10-18 — wykonywanie kopania przez jednostki
|
||||
|
||||
Podłączono `BoardState::advance_turn` do ticków potyczki. Zastąpiono szkic symulacji przetwarzaniem jednej aktualizacji każdej aktywnej jednostki na tick. Górnik stopniowo uszkadza kamień, po osiągnięciu zera tworzy tunel i przechodzi do niego. Ruch aktualizuje współrzędne jednostki, usuwa wykonany krok i po zakończeniu ścieżki usuwa zadanie. Kolejne zadania wykonują się w kolejności; tunele i niezajęte bazy można przechodzić bez kopania.
|
||||
|
||||
Kamień pokazuje pozostałą wytrzymałość procentowo na wszystkich poziomach zoomu, a istniejący panel boczny pod Tab odświeża dokładne durability. Użyto istniejącej siły kopania 0.55% maksymalnej wytrzymałości na tick, z zachowaniem części ułamkowej obrażeń (około 6 sekund na pełną kratkę).
|
||||
|
||||
Zaznaczenie spacją działa tylko dla własnego górnika. Podczas rysowania wybrany górnik czeka; nowa ścieżka przedłuża koniec jego kolejki. Pominięto powtarzanie tej samej kratki przy ruchu na granicy mapy. Zajęte pola i ruda blokują ruch, nie są nadpisywane. Błędne cele spoza mapy lub niesąsiadujące anulują bieżące zadanie bez utraty jednostki.
|
||||
|
||||
Dodano 10 testów w `tests/digging.rs`: rzeczywisty przepływ klawiszy i ticków, utrata durability i ukończenie kopania, ruch w obu kierunkach tylko raz na tick, kolejki i ich przedłużanie przez zaznaczenie, aktualność współrzędnych, zajęte pola, ochrona rudy, błędne/puste ścieżki, własność jednostek, pauza przy rysowaniu i renderowanie procentów oraz dokładnych wartości. Dostosowano test cofania ścieżki do wyboru rzeczywistego górnika.
|
||||
|
||||
Weryfikacja przez `nix develop -c`: `cargo check --all-targets`, `cargo test --all-targets` (33 zaliczone), `cargo fmt --all -- --check` i `cargo clippy --all-targets` zakończone sukcesem. Clippy zgłasza 50 ostrzeżeń dla biblioteki. Zaktualizowano README, mapę, instrukcje agentów i opis testów. Nie dodano zależności.
|
||||
|
||||
Nie wykonywano ręcznej sesji w terminalu, odsłuchu ani budowy pakietów Nix; renderowanie sprawdzono przez TestBackend. Omijanie przeszkód, wydobycie rudy, ekonomia i walka pozostają poza zakresem tej zmiany.
|
||||
|
||||
## 2026-10-18 — przeniesienie testu audio
|
||||
|
||||
Przeniesiono `audio_repeats_after_end_of_file` z `src/app/threads/audio.rs` do `tests/audio.rs`, bez zmiany jego asercji. Udostępniono `load_audio` przez `app::threads`, aby test integracyjny nadal sprawdzał produkcyjną funkcję dekodowania. Zaktualizowano `docs/TESTING.md`.
|
||||
|
||||
+5
-3
@@ -15,10 +15,11 @@ Testy korzystają z wbudowanego mechanizmu Rust, Clap i `ratatui::backend::TestB
|
||||
| `tests/cli.rs` | 5 | Domyślne ustawienia, granice wszystkich opcji liczbowych, XP (także NaN i infinity), błędne enumy, flagi audio, uruchomienie binarki z help/version i błędnym argumentem |
|
||||
| `tests/app.rs` | 7 | Nawigacja menu, wybór ekranów, wyjście, ignorowanie zwolnienia klawisza, komendy audio i zamknięty kanał, panel, zoom, ruch, renderowanie wszystkich widoków bez mutacji stanu, wejście przed inicjalizacją |
|
||||
| `tests/board.rs` | 7 | Rozmieszczenie baz/złóż/jednostek, granice kursora i kamery, zatwierdzanie/anulowanie ścieżki, cofanie z powtórzonym polem, duży viewport, resize i zoom |
|
||||
| `tests/tasks.rs` | 3 | Przyrost i limit postępu, reset postępu przy zmianie celu, pusta ścieżka, kolejność zadań górnika |
|
||||
| `tests/tasks.rs` | 7 | Przyrost i limit kopania, reset etapu, pusta ścieżka, kolejność zadań, timer MovingTask, całkowity postęp, zachowanie liczby etapów przy konwersji, formatowanie postępu aktywnych i oczekujących zadań |
|
||||
| `tests/audio.rs` | 1 | Zapętlanie rzeczywistego pliku OGG po jego końcu |
|
||||
| `tests/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: 23 testy. 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: 45 testów. Wszystkie testy znajdują się w `tests/` i sprawdzają publiczne interfejsy biblioteki lub binarkę. Funkcja `load_audio` jest eksportowana przez `app::threads`, dzięki czemu test korzysta z tego samego dekodera co odtwarzanie.
|
||||
|
||||
Wybrane zestawy i pojedynczy przypadek:
|
||||
|
||||
@@ -28,6 +29,7 @@ nix develop -c cargo test --test app
|
||||
nix develop -c cargo test --test board
|
||||
nix develop -c cargo test --test tasks
|
||||
nix develop -c cargo test --test audio
|
||||
nix develop -c cargo test --test digging
|
||||
nix develop -c cargo test --test board large_viewport_and_zoom_keep_camera_inside_map
|
||||
```
|
||||
|
||||
@@ -43,4 +45,4 @@ nix develop -c cargo clippy --all-targets
|
||||
|
||||
## Ograniczenia
|
||||
|
||||
Zestaw nie weryfikuje pełnej rozgrywki ani `advance_turn`, którego wywołanie nadal jest zakomentowane. 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. Brak konfiguracji CI; polecenia należy wykonywać lokalnie przed zakończeniem zmian.
|
||||
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. Brak konfiguracji CI; polecenia należy wykonywać lokalnie przed zakończeniem zmian.
|
||||
@@ -11,7 +11,7 @@ pub struct SkirmishState {
|
||||
|
||||
impl SkirmishState {
|
||||
pub fn tick_update(&mut self) {
|
||||
// self.board.advance_turn();
|
||||
self.board.advance_turn();
|
||||
|
||||
// if self.board.is_victory() {}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ use crate::app::{
|
||||
FocusedCell, Offset,
|
||||
skirmish_states::{
|
||||
CellSizes, MarkedCells, MoveFocusedCell, Players, ZoomLevel,
|
||||
structures::{BaseBuilding, Ore, Stone, Structures, Tunnel},
|
||||
tasks::{DiggingTask, Task, Tasks},
|
||||
structures::{BaseBuilding, Ore, Stone, Structure, Structures, Tunnel},
|
||||
tasks::{DiggingTask, MovingTask, Task, Tasks},
|
||||
units::{MinerUnit, Unit, Units},
|
||||
},
|
||||
},
|
||||
@@ -160,6 +160,9 @@ impl BoardState {
|
||||
}
|
||||
|
||||
pub fn push_marked_cell(&mut self, new_cell: (usize, usize)) {
|
||||
if self.marked_cells.marked_cells.back() == Some(&new_cell) {
|
||||
return;
|
||||
}
|
||||
let cell: &mut CellWidget = self.get_mut_cell(new_cell.0, new_cell.1);
|
||||
|
||||
if !cell.get_marked() {
|
||||
@@ -203,15 +206,36 @@ impl BoardState {
|
||||
pub fn start_marking_cells(&mut self) {
|
||||
let (row, col) = self.focused_cell.get_coords();
|
||||
|
||||
let cell: &mut CellWidget = self.get_mut_cell(row, col);
|
||||
cell.set_marked(true);
|
||||
|
||||
self.marked_cells.selected_unit = cell.get_option_unit();
|
||||
self.marked_cells.marked_cells.push_back((row, col));
|
||||
let selected_unit: Option<Units> = self.get_ref_cell(row, col).get_option_unit();
|
||||
if selected_unit.get_owner() != Players::Player || !selected_unit.get_can_dig() {
|
||||
self.marked_cells.marking_cells = false;
|
||||
return;
|
||||
}
|
||||
// Extend queued work from its final destination, not the miner's current cell.
|
||||
let start: (usize, usize) = selected_unit
|
||||
.get_ref_tasks()
|
||||
.back()
|
||||
.and_then(|task| task.get_path().back())
|
||||
.copied()
|
||||
.unwrap_or((row, col));
|
||||
self.focused_cell.set_focused_cell(start);
|
||||
let start: (usize, usize) = self.focused_cell.get_coords();
|
||||
self.get_mut_cell(row, col).set_selected(false);
|
||||
self.get_mut_cell(start.0, start.1)
|
||||
.set_selected(true)
|
||||
.set_marked(true);
|
||||
if !self.is_focused_cell_visible() {
|
||||
self.vertical_offset
|
||||
.set_value(start.0.saturating_sub(self.rows / 2));
|
||||
self.horizontal_offset
|
||||
.set_value(start.1.saturating_sub(self.cols / 2));
|
||||
}
|
||||
self.marked_cells.selected_unit = selected_unit;
|
||||
self.marked_cells.marked_cells.push_back(start);
|
||||
}
|
||||
|
||||
pub fn end_marking_cells(&mut self, del: bool) {
|
||||
// let todo_taks_tyle = Tasks::Digging(DiggingTask::);
|
||||
self.marked_cells.marking_cells = false;
|
||||
for (row, col) in self.marked_cells.marked_cells.clone() {
|
||||
self.get_mut_cell(row, col).set_marked(false);
|
||||
}
|
||||
@@ -219,7 +243,7 @@ impl BoardState {
|
||||
let (row, col) = self.marked_cells.selected_unit.get_coords();
|
||||
|
||||
if self.marked_cells.marked_cells.len() > 1 && !del {
|
||||
let path: VecDeque<(usize, usize)> = self
|
||||
let path: Vec<(usize, usize)> = self
|
||||
.marked_cells
|
||||
.marked_cells
|
||||
.iter()
|
||||
@@ -229,11 +253,20 @@ impl BoardState {
|
||||
|
||||
let tick_increase: f32 = self.marked_cells.selected_unit.get_digging_power();
|
||||
|
||||
let task: Tasks = Tasks::Digging(DiggingTask::new(path, tick_increase));
|
||||
|
||||
self.get_mut_cell(row, col)
|
||||
.get_mut_option_unit()
|
||||
.set_task(task);
|
||||
let tasks: VecDeque<Tasks> = path
|
||||
.chunk_by(|a, b| self.is_walkable(*a) == self.is_walkable(*b))
|
||||
.map(|segment| {
|
||||
let path = segment.iter().copied().collect();
|
||||
if self.is_walkable(segment[0]) {
|
||||
Tasks::Moving(MovingTask::new(path))
|
||||
} else {
|
||||
Tasks::Digging(DiggingTask::new(path, tick_increase))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if let Some(unit) = self.get_mut_cell(row, col).get_mut_option_unit().as_mut() {
|
||||
unit.get_mut_tasks().extend(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
self.marked_cells.selected_unit = None;
|
||||
@@ -244,6 +277,18 @@ impl BoardState {
|
||||
if map_size > size { map_size - size } else { 0 }
|
||||
}
|
||||
|
||||
fn is_walkable(&self, (row, col): (usize, usize)) -> bool {
|
||||
self.cells
|
||||
.get(row)
|
||||
.and_then(|cells| cells.get(col))
|
||||
.is_some_and(|cell| {
|
||||
matches!(
|
||||
cell.get_structure(),
|
||||
Structures::Tunnel(_) | Structures::Base(_)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_focused_cell(&self) -> &FocusedCell {
|
||||
&self.focused_cell
|
||||
}
|
||||
@@ -325,101 +370,94 @@ impl BoardState {
|
||||
}
|
||||
}
|
||||
|
||||
// fn get_active_tasks(&self) -> Vec<&Tasks> {
|
||||
// self.cells
|
||||
// .iter()
|
||||
// .flat_map(|row| row.iter())
|
||||
// .filter_map(|cell| {
|
||||
// cell.get_ref_option_unit()
|
||||
// .as_ref()
|
||||
// .and_then(|unit| unit.get_ref_tasks().front())
|
||||
// })
|
||||
// .collect()
|
||||
// // for row in self.marked_cells.marked_cells {
|
||||
|
||||
// // }
|
||||
// }
|
||||
|
||||
pub fn advance_turn(&mut self) {
|
||||
for row in 0..self.map_height {
|
||||
for col in 0..self.map_width {
|
||||
/* ---------------------------------------------------------
|
||||
* 1️⃣ Take the unit out of the current cell (if any). This
|
||||
* gives us an *owned* `Units`, so we no longer hold a
|
||||
* mutable reference into `self`.
|
||||
* ------------------------------------------------------- */
|
||||
let maybe_unit: Option<Units> =
|
||||
self.get_mut_cell(row, col).get_mut_option_unit().take();
|
||||
// Snapshot positions so moving to a later cell cannot grant another turn.
|
||||
let active: Vec<(usize, usize)> = self
|
||||
.cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(row, cells)| {
|
||||
cells.iter().enumerate().filter_map(move |(col, cell)| {
|
||||
cell.get_ref_option_unit()
|
||||
.as_ref()
|
||||
.filter(|unit| !unit.get_ref_tasks().is_empty())
|
||||
.map(|_| (row, col))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let Some(mut unit) = maybe_unit else {
|
||||
continue; // no unit in this cell
|
||||
for (row, col) in active {
|
||||
// Keep the route's origin stable while the player is drawing it.
|
||||
if self.marked_cells.marking_cells
|
||||
&& self.marked_cells.selected_unit.get_coords() == (row, col)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut unit: Units = self
|
||||
.get_mut_cell(row, col)
|
||||
.get_mut_option_unit()
|
||||
.take()
|
||||
.unwrap();
|
||||
let mut task: Tasks = unit.get_mut_tasks().pop_front().unwrap();
|
||||
let mut destination = (row, col);
|
||||
|
||||
if let Some((target_row, target_col)) = task.get_path().front().copied() {
|
||||
let adjacent: bool = row
|
||||
.abs_diff(target_row)
|
||||
.saturating_add(col.abs_diff(target_col))
|
||||
== 1;
|
||||
if target_row >= self.map_height || target_col >= self.map_width || !adjacent {
|
||||
// Discard invalid routes rather than teleporting or indexing outside the map.
|
||||
*self.get_mut_cell(row, col).get_mut_option_unit() = Some(unit);
|
||||
continue;
|
||||
}
|
||||
|
||||
let cell: &mut CellWidget = self.get_mut_cell(target_row, target_col);
|
||||
if cell.get_ref_option_unit().is_none() {
|
||||
let structure: Structures = cell.get_structure();
|
||||
// Previously planned stone may already be a tunnel when this task starts.
|
||||
task = match (task, structure) {
|
||||
(Tasks::Digging(dig), Structures::Tunnel(_) | Structures::Base(_)) => {
|
||||
Tasks::Moving(MovingTask::from_digging(dig))
|
||||
}
|
||||
(Tasks::Moving(moving), Structures::Stone(_)) => Tasks::Digging(
|
||||
DiggingTask::from_moving(moving, unit.get_digging_power()),
|
||||
),
|
||||
(task, _) => task,
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
* 2️⃣ Look at the unit’s task queue. We need a mutable
|
||||
* reference to the *front* task only while we are
|
||||
* processing it. The mutable borrow is confined to a
|
||||
* small inner block so that it ends before we move the
|
||||
* unit itself.
|
||||
* ------------------------------------------------------- */
|
||||
if let Some(task) = unit.get_mut_tasks().front_mut() {
|
||||
// `Tasks` currently only has the Digging variant.
|
||||
// If new variants appear later, you can handle them here.
|
||||
let maybe_target: Option<(usize, usize)> = {
|
||||
// -------------------------------------------------
|
||||
// Mutable borrow of the task lives only inside this
|
||||
// block. When the block ends, `dig` is dropped.
|
||||
// -------------------------------------------------
|
||||
let dig: &mut DiggingTask = match task {
|
||||
Tasks::Digging(d) => d,
|
||||
};
|
||||
|
||||
match (&mut task, structure) {
|
||||
(Tasks::Digging(dig), Structures::Stone(mut stone)) => {
|
||||
let max: f32 = f32::from(stone.get_max_durability());
|
||||
let before: u16 = (dig.get_progress() * max / 100.0) as u16;
|
||||
dig.progress();
|
||||
|
||||
// Remember the coordinates of the next tunnel cell.
|
||||
let target: Option<(usize, usize)> = dig.current_target();
|
||||
|
||||
// If we actually moved, the current step of the
|
||||
// digging task is finished – pop it from the queue.
|
||||
if target.is_some() {
|
||||
let after: u16 = (dig.get_progress() * max / 100.0) as u16;
|
||||
stone.take_damage(after.saturating_sub(before));
|
||||
if stone.get_durability() == 0 {
|
||||
cell.set_structure(Structures::Tunnel(Tunnel::new()));
|
||||
destination = (target_row, target_col);
|
||||
dig.pop_front();
|
||||
}
|
||||
|
||||
target
|
||||
}; // ← `dig` (and the mutable borrow) ends here
|
||||
|
||||
if let Some((t_row, t_col)) = maybe_target {
|
||||
/* -------------------------------------------------
|
||||
* 3️⃣ We have a target – create the tunnel, move the
|
||||
* unit, and clean up the source cell.
|
||||
* ------------------------------------------------- */
|
||||
|
||||
// a) create the tunnel in the target cell
|
||||
self.get_mut_cell(t_row, t_col)
|
||||
.set_structure(Structures::Tunnel(Tunnel::new()));
|
||||
|
||||
// b) (optional) keep the old coordinates – they are
|
||||
// no longer needed but kept for possible future use
|
||||
let _old_coords: (usize, usize) = unit.get_coords();
|
||||
|
||||
// c) move the unit into the target cell
|
||||
*self.get_mut_cell(t_row, t_col).get_mut_option_unit() = Some(unit);
|
||||
|
||||
// d) the source cell is already empty because of the
|
||||
// earlier `take()`. Nothing else to do.
|
||||
} else {
|
||||
// No reachable target – put the unit back where it was.
|
||||
self.get_mut_cell(row, col)
|
||||
.get_mut_option_unit()
|
||||
.replace(unit);
|
||||
}
|
||||
} else {
|
||||
// The unit has no tasks – simply put it back.
|
||||
self.get_mut_cell(row, col)
|
||||
.get_mut_option_unit()
|
||||
.replace(unit);
|
||||
}
|
||||
cell.set_structure(Structures::Stone(stone));
|
||||
}
|
||||
}
|
||||
(Tasks::Moving(moving), Structures::Tunnel(_) | Structures::Base(_)) => {
|
||||
moving.progress();
|
||||
if moving.is_finished() {
|
||||
destination = (target_row, target_col);
|
||||
moving.pop_front();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !task.get_path().is_empty() {
|
||||
unit.get_mut_tasks().push_front(task);
|
||||
}
|
||||
}
|
||||
unit.set_coords(destination);
|
||||
*self
|
||||
.get_mut_cell(destination.0, destination.1)
|
||||
.get_mut_option_unit() = Some(unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ pub struct Stone {
|
||||
}
|
||||
|
||||
impl Stone {
|
||||
pub fn take_damage(&mut self, damage: u16) {
|
||||
self.durability = self.durability.saturating_sub(damage);
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
durability: 1000,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::app::states::skirmish_states::tasks::Task;
|
||||
use crate::app::states::skirmish_states::tasks::{MovingTask, Task};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -6,11 +6,13 @@ pub struct DiggingTask {
|
||||
path: VecDeque<(usize, usize)>,
|
||||
progress: f32,
|
||||
tick_increase: f32,
|
||||
total_steps: usize,
|
||||
}
|
||||
|
||||
impl DiggingTask {
|
||||
pub fn new(path: VecDeque<(usize, usize)>, tick_increase: f32) -> Self {
|
||||
Self {
|
||||
total_steps: path.len(),
|
||||
path,
|
||||
progress: 0.0,
|
||||
tick_increase,
|
||||
@@ -22,6 +24,15 @@ impl DiggingTask {
|
||||
self.progress = 0.0;
|
||||
}
|
||||
|
||||
pub fn from_moving(task: MovingTask, tick_increase: f32) -> Self {
|
||||
Self {
|
||||
path: task.get_path().clone(),
|
||||
progress: 0.0,
|
||||
tick_increase,
|
||||
total_steps: task.get_total_steps(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_target(&self) -> Option<(usize, usize)> {
|
||||
self.path.front().copied()
|
||||
}
|
||||
@@ -48,6 +59,10 @@ impl Task for DiggingTask {
|
||||
self.progress
|
||||
}
|
||||
|
||||
fn get_total_steps(&self) -> usize {
|
||||
self.total_steps
|
||||
}
|
||||
|
||||
fn progress(&mut self) {
|
||||
self.progress = (self.progress + self.tick_increase).clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
mod digging;
|
||||
mod moving;
|
||||
mod task_trait;
|
||||
mod tasks_enum;
|
||||
|
||||
pub use digging::DiggingTask;
|
||||
pub use moving::MovingTask;
|
||||
pub use task_trait::Task;
|
||||
pub use tasks_enum::Tasks;
|
||||
@@ -0,0 +1,75 @@
|
||||
use crate::app::{
|
||||
states::skirmish_states::tasks::{DiggingTask, Task},
|
||||
threads::TICK_MS,
|
||||
};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
const MOVEMENT_TICKS: u16 = 3000_u16.div_ceil(TICK_MS as u16);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MovingTask {
|
||||
path: VecDeque<(usize, usize)>,
|
||||
ticks: u16,
|
||||
total_steps: usize,
|
||||
}
|
||||
|
||||
impl MovingTask {
|
||||
pub fn new(path: VecDeque<(usize, usize)>) -> Self {
|
||||
Self {
|
||||
total_steps: path.len(),
|
||||
path,
|
||||
ticks: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_digging(task: DiggingTask) -> Self {
|
||||
Self {
|
||||
total_steps: task.get_total_steps(),
|
||||
path: task.get_path().clone(),
|
||||
ticks: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pop_front(&mut self) {
|
||||
self.path.pop_front();
|
||||
self.ticks = 0;
|
||||
}
|
||||
|
||||
pub fn get_movement_progress(&self) -> Option<u16> {
|
||||
(self.ticks > 0).then(|| self.ticks * 100 / MOVEMENT_TICKS)
|
||||
}
|
||||
}
|
||||
|
||||
impl Task for MovingTask {
|
||||
fn get_icon(&self) -> char {
|
||||
'→'
|
||||
}
|
||||
|
||||
fn get_path(&self) -> &VecDeque<(usize, usize)> {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn get_path_front_coords(&self) -> &(usize, usize) {
|
||||
self.path.front().unwrap_or(&(0, 0))
|
||||
}
|
||||
|
||||
fn get_path_back_coords(&self) -> &(usize, usize) {
|
||||
self.path.back().unwrap_or(&(0, 0))
|
||||
}
|
||||
|
||||
fn get_progress(&self) -> f32 {
|
||||
f32::from(self.ticks) * 100.0 / f32::from(MOVEMENT_TICKS)
|
||||
}
|
||||
|
||||
fn get_total_steps(&self) -> usize {
|
||||
self.total_steps
|
||||
}
|
||||
|
||||
fn progress(&mut self) {
|
||||
self.ticks = (self.ticks + 1).min(MOVEMENT_TICKS);
|
||||
}
|
||||
|
||||
fn is_finished(&self) -> bool {
|
||||
self.ticks == MOVEMENT_TICKS
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,17 @@ pub trait Task {
|
||||
|
||||
fn get_progress(&self) -> f32;
|
||||
|
||||
fn get_total_steps(&self) -> usize;
|
||||
|
||||
fn get_total_progress(&self) -> f32 {
|
||||
let total: usize = self.get_total_steps();
|
||||
if total == 0 {
|
||||
return 100.0;
|
||||
}
|
||||
let completed: usize = total.saturating_sub(self.get_path().len());
|
||||
((completed as f32 * 100.0 + self.get_progress()) / total as f32).clamp(0.0, 100.0)
|
||||
}
|
||||
|
||||
fn progress(&mut self);
|
||||
|
||||
fn is_finished(&self) -> bool;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::app::states::skirmish_states::tasks::{DiggingTask, Task};
|
||||
use crate::app::states::skirmish_states::tasks::{DiggingTask, MovingTask, Task};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Tasks {
|
||||
Digging(DiggingTask),
|
||||
Moving(MovingTask),
|
||||
}
|
||||
|
||||
impl Tasks {
|
||||
@@ -11,6 +12,7 @@ impl Tasks {
|
||||
fn t(&self) -> &dyn Task {
|
||||
match self {
|
||||
Self::Digging(d) => d,
|
||||
Self::Moving(m) => m,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +20,7 @@ impl Tasks {
|
||||
fn t_mut(&mut self) -> &mut dyn Task {
|
||||
match self {
|
||||
Self::Digging(d) => d,
|
||||
Self::Moving(m) => m,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +46,10 @@ impl Task for Tasks {
|
||||
self.t().get_progress()
|
||||
}
|
||||
|
||||
fn get_total_steps(&self) -> usize {
|
||||
self.t().get_total_steps()
|
||||
}
|
||||
|
||||
fn is_finished(&self) -> bool {
|
||||
self.t().is_finished()
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ impl Unit for MinerUnit {
|
||||
self.coords
|
||||
}
|
||||
|
||||
fn set_coords(&mut self, coords: (usize, usize)) {
|
||||
self.coords = coords;
|
||||
}
|
||||
|
||||
fn get_ref_tasks(&self) -> &VecDeque<Tasks> {
|
||||
&self.tasks
|
||||
}
|
||||
@@ -92,7 +96,8 @@ impl Unit for MinerUnit {
|
||||
self.tasks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, task)| {
|
||||
.flat_map(|(i, task): (usize, &Tasks)| -> [Line<'_>; 2] {
|
||||
[
|
||||
Line::from_iter([
|
||||
format!("{}. ", i + 1).gray(),
|
||||
task.get_icon().gray(),
|
||||
@@ -100,7 +105,14 @@ impl Unit for MinerUnit {
|
||||
self.display_coords(task.get_path_front_coords()).green(),
|
||||
" -> ".gray(),
|
||||
self.display_coords(task.get_path_back_coords()).yellow(),
|
||||
])
|
||||
]),
|
||||
Line::from(format!(
|
||||
" Step: {}% | Total: {}%",
|
||||
task.get_progress() as u16,
|
||||
task.get_total_progress() as u16,
|
||||
))
|
||||
.cyan(),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<Line<'_>>>(),
|
||||
)
|
||||
|
||||
@@ -26,6 +26,8 @@ pub trait Unit {
|
||||
|
||||
fn get_coords(&self) -> (usize, usize);
|
||||
|
||||
fn set_coords(&mut self, coords: (usize, usize));
|
||||
|
||||
fn get_ref_tasks(&self) -> &VecDeque<Tasks>;
|
||||
|
||||
fn get_mut_tasks(&mut self) -> &mut VecDeque<Tasks>;
|
||||
|
||||
@@ -60,6 +60,10 @@ impl Unit for Units {
|
||||
self.u().get_coords()
|
||||
}
|
||||
|
||||
fn set_coords(&mut self, coords: (usize, usize)) {
|
||||
self.u_mut().set_coords(coords);
|
||||
}
|
||||
|
||||
fn get_ref_tasks(&self) -> &VecDeque<Tasks> {
|
||||
self.u().get_ref_tasks()
|
||||
}
|
||||
@@ -114,6 +118,12 @@ impl Unit for Option<Units> {
|
||||
self.as_ref().map_or((0, 0), |u| u.get_coords())
|
||||
}
|
||||
|
||||
fn set_coords(&mut self, coords: (usize, usize)) {
|
||||
if let Some(unit) = self.as_mut() {
|
||||
unit.set_coords(coords);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_ref_tasks(&self) -> &VecDeque<Tasks> {
|
||||
self.as_ref()
|
||||
.expect("attempted to get referenced tasks from `None`")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use clap::ValueEnum;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::{Line, Span};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
|
||||
pub enum ZoomLevel {
|
||||
@@ -25,22 +25,30 @@ impl ZoomLevel {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cell_text_area(
|
||||
pub fn get_cell_text_area<'a>(
|
||||
&self,
|
||||
tag: char,
|
||||
structure_level: char,
|
||||
unit: char,
|
||||
) -> Vec<Line<'_>> {
|
||||
unit: Span<'a>,
|
||||
) -> Vec<Line<'a>> {
|
||||
match self {
|
||||
ZoomLevel::ZoomedIn => vec![
|
||||
Line::from(format!(" {} {} ", structure_level, unit)),
|
||||
Line::from(vec![
|
||||
Span::raw(format!(" {} ", structure_level)),
|
||||
unit,
|
||||
Span::raw(" "),
|
||||
]),
|
||||
Line::from(format!(" ")),
|
||||
Line::from(format!(" {} ", tag)),
|
||||
Line::from(format!(" ")),
|
||||
Line::from(format!(" ")),
|
||||
],
|
||||
ZoomLevel::Default => vec![
|
||||
Line::from(format!(" {} {} ", structure_level, unit)),
|
||||
Line::from(vec![
|
||||
Span::raw(format!(" {} ", structure_level)),
|
||||
unit,
|
||||
Span::raw(" "),
|
||||
]),
|
||||
Line::from(format!(" {} ", tag)),
|
||||
Line::from(format!(" ")),
|
||||
],
|
||||
|
||||
+23
-3
@@ -1,6 +1,7 @@
|
||||
use crate::app::states::skirmish_states::{
|
||||
ZoomLevel,
|
||||
structures::{Stone, Structure, Structures},
|
||||
tasks::Tasks,
|
||||
units::{Unit, Units},
|
||||
};
|
||||
use ratatui::{
|
||||
@@ -115,11 +116,30 @@ impl CellWidget {
|
||||
}
|
||||
|
||||
fn get_text_area(&self) -> Vec<Line<'_>> {
|
||||
self.zoom_level.get_cell_text_area(
|
||||
let mut lines: Vec<Line<'_>> = self.zoom_level.get_cell_text_area(
|
||||
self.structure.get_tag(),
|
||||
self.structure.get_level(),
|
||||
self.unit.get_tag(),
|
||||
)
|
||||
self.unit
|
||||
.get_tag()
|
||||
.to_string()
|
||||
.fg(self.unit.get_owner().get_color()),
|
||||
);
|
||||
if let Structures::Stone(stone) = self.structure
|
||||
&& stone.get_durability() < stone.get_max_durability()
|
||||
{
|
||||
let percent: u32 =
|
||||
u32::from(stone.get_durability()) * 100 / u32::from(stone.get_max_durability());
|
||||
let middle = lines.len() / 2;
|
||||
lines[middle] = Line::from(format!("{percent}%")).cyan();
|
||||
}
|
||||
if let Some(unit) = &self.unit
|
||||
&& let Some(Tasks::Moving(task)) = unit.get_ref_tasks().front()
|
||||
&& let Some(percent) = task.get_movement_progress()
|
||||
{
|
||||
let middle = lines.len() / 2;
|
||||
lines[middle] = Line::from(format!("{percent}%")).fg(unit.get_owner().get_color());
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn get_block(&self) -> Block<'_> {
|
||||
|
||||
+6
-5
@@ -118,17 +118,18 @@ fn cancelling_path_clears_marks_without_queuing_task() {
|
||||
#[test]
|
||||
fn backtracking_revisited_cell_preserves_earlier_mark() {
|
||||
let mut board = board();
|
||||
board.change_focused_cell(MoveFocusedCell::Left);
|
||||
board.toggle_marking();
|
||||
board.change_focused_cell(MoveFocusedCell::Right);
|
||||
board.change_focused_cell(MoveFocusedCell::Left);
|
||||
board.pop_marked_cell();
|
||||
assert_eq!(board.get_focused_cell().get_coords(), (5, 4));
|
||||
assert!(board.get_ref_cell(5, 3).get_marked());
|
||||
board.pop_marked_cell();
|
||||
board.pop_marked_cell();
|
||||
assert_eq!(board.get_focused_cell().get_coords(), (5, 3));
|
||||
assert!(board.get_ref_cell(5, 2).get_marked());
|
||||
board.pop_marked_cell();
|
||||
board.pop_marked_cell();
|
||||
assert_eq!(board.get_focused_cell().get_coords(), (5, 2));
|
||||
assert_eq!(board.get_marked_cells().len(), 1);
|
||||
assert!(!board.get_ref_cell(5, 4).get_marked());
|
||||
assert!(!board.get_ref_cell(5, 3).get_marked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
use clap::Parser;
|
||||
use ratatui::{
|
||||
Terminal,
|
||||
backend::TestBackend,
|
||||
buffer::Buffer,
|
||||
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
|
||||
layout::Rect,
|
||||
widgets::Widget,
|
||||
};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use war_in_tunnels::{
|
||||
app::{
|
||||
App, GameStates, View, handle_keybindings,
|
||||
states::{
|
||||
SkirmishState,
|
||||
skirmish_states::{
|
||||
BoardState, CellSizes, MoveFocusedCell, Players, ZoomLevel,
|
||||
structures::{Structure, Structures, Tunnel},
|
||||
tasks::{DiggingTask, MovingTask, Task, Tasks},
|
||||
units::{MinerUnit, Unit, Units},
|
||||
},
|
||||
},
|
||||
threads::{AudioCmd, TICK_MS},
|
||||
},
|
||||
cli::Cli,
|
||||
};
|
||||
|
||||
fn game() -> SkirmishState {
|
||||
let args = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
|
||||
GameStates::new(&args, &Rect::new(0, 0, 120, 40)).skirmish
|
||||
}
|
||||
|
||||
fn queue(board: &mut BoardState, origin: (usize, usize), path: &[(usize, usize)]) {
|
||||
let unit = board.cells[origin.0][origin.1]
|
||||
.get_mut_option_unit()
|
||||
.as_mut()
|
||||
.unwrap();
|
||||
let power = unit.get_digging_power();
|
||||
unit.set_task(Tasks::Digging(DiggingTask::new(
|
||||
path.iter().copied().collect(),
|
||||
power,
|
||||
)));
|
||||
}
|
||||
|
||||
fn tunnel(board: &mut BoardState, row: usize, col: usize) {
|
||||
board.cells[row][col].set_structure(Structures::Tunnel(Tunnel::new()));
|
||||
}
|
||||
|
||||
fn unit(board: &BoardState, row: usize, col: usize) -> &Units {
|
||||
board.cells[row][col]
|
||||
.get_ref_option_unit()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn travel_ticks() -> u16 {
|
||||
3000_u16.div_ceil(u16::from(TICK_MS))
|
||||
}
|
||||
|
||||
fn advance(game: &mut SkirmishState, ticks: u16) {
|
||||
for _ in 0..ticks {
|
||||
game.tick_update();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_damages_stone_before_moving_and_finishes_entire_route() {
|
||||
let mut game = game();
|
||||
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
|
||||
game.tick_update();
|
||||
assert_eq!(game.turn_counter, 1);
|
||||
let durability = game.board.cells[5][3].get_structure().get_durability();
|
||||
assert!(durability < 1000 && durability > 0);
|
||||
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
|
||||
assert!(game.board.cells[5][3].get_ref_option_unit().is_none());
|
||||
for _ in 0..500 {
|
||||
game.tick_update();
|
||||
}
|
||||
for col in [3, 4] {
|
||||
assert!(matches!(
|
||||
game.board.cells[5][col].get_structure(),
|
||||
Structures::Tunnel(_)
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
game.board.cells[5][2].get_structure(),
|
||||
Structures::Base(_)
|
||||
));
|
||||
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
|
||||
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
|
||||
assert!(game.board.cells[5][2].get_ref_option_unit().is_none());
|
||||
assert!(game.board.cells[5][3].get_ref_option_unit().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_tunnel_step_takes_three_seconds_in_both_directions() {
|
||||
for path in [[(5, 3), (5, 4)], [(4, 2), (3, 2)]] {
|
||||
let mut game = game();
|
||||
for (row, col) in path {
|
||||
tunnel(&mut game.board, row, col);
|
||||
}
|
||||
queue(&mut game.board, (5, 2), &path);
|
||||
advance(&mut game, travel_ticks() - 1);
|
||||
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
|
||||
game.tick_update();
|
||||
assert_eq!(
|
||||
unit(&game.board, path[0].0, path[0].1).get_coords(),
|
||||
path[0]
|
||||
);
|
||||
assert_eq!(
|
||||
unit(&game.board, path[0].0, path[0].1).get_ref_tasks()[0]
|
||||
.get_path()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
advance(&mut game, travel_ticks() - 1);
|
||||
assert_eq!(
|
||||
unit(&game.board, path[0].0, path[0].1).get_coords(),
|
||||
path[0]
|
||||
);
|
||||
game.tick_update();
|
||||
assert!(
|
||||
unit(&game.board, path[1].0, path[1].1)
|
||||
.get_ref_tasks()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_tasks_execute_in_order_and_keep_unit_coordinates_current() {
|
||||
let mut game = game();
|
||||
tunnel(&mut game.board, 5, 3);
|
||||
tunnel(&mut game.board, 5, 4);
|
||||
queue(&mut game.board, (5, 2), &[(5, 3)]);
|
||||
queue(&mut game.board, (5, 2), &[(5, 4)]);
|
||||
advance(&mut game, travel_ticks());
|
||||
assert_eq!(unit(&game.board, 5, 3).get_ref_tasks().len(), 1);
|
||||
advance(&mut game, travel_ticks());
|
||||
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
|
||||
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
|
||||
queue(&mut game.board, (5, 4), &[(5, 3), (5, 2)]);
|
||||
advance(&mut game, 2 * travel_ticks() - 1);
|
||||
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
|
||||
game.tick_update();
|
||||
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
|
||||
assert!(matches!(
|
||||
game.board.cells[5][2].get_structure(),
|
||||
Structures::Base(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occupied_tunnel_waits_without_losing_units_or_task() {
|
||||
let mut game = game();
|
||||
tunnel(&mut game.board, 5, 3);
|
||||
*game.board.cells[5][3].get_mut_option_unit() =
|
||||
Some(Units::Miner(MinerUnit::new(Players::Player, (5, 3))));
|
||||
queue(&mut game.board, (5, 2), &[(5, 3)]);
|
||||
for _ in 0..3 {
|
||||
game.tick_update();
|
||||
}
|
||||
assert_eq!(unit(&game.board, 5, 2).get_ref_tasks().len(), 1);
|
||||
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
|
||||
game.board.cells[5][3].get_mut_option_unit().take();
|
||||
advance(&mut game, travel_ticks() - 1);
|
||||
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
|
||||
game.tick_update();
|
||||
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
|
||||
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ore_is_not_destroyed_by_digging_task() {
|
||||
let mut game = game();
|
||||
let ore = game.board.cells[5][1].get_structure();
|
||||
queue(&mut game.board, (5, 2), &[(5, 1)]);
|
||||
for _ in 0..200 {
|
||||
game.tick_update();
|
||||
}
|
||||
assert_eq!(game.board.cells[5][1].get_structure(), ore);
|
||||
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_or_empty_routes_do_not_teleport_or_remove_miner() {
|
||||
for path in [vec![], vec![(usize::MAX, usize::MAX)], vec![(0, 0)]] {
|
||||
let mut game = game();
|
||||
queue(&mut game.board, (5, 2), &path);
|
||||
game.tick_update();
|
||||
assert_eq!(unit(&game.board, 5, 2).get_coords(), (5, 2));
|
||||
assert!(unit(&game.board, 5, 2).get_ref_tasks().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_player_miner_can_start_drawing_route() {
|
||||
let mut game = game();
|
||||
game.board.toggle_marking();
|
||||
assert!(!game.board.marked_cells.marking_cells);
|
||||
for _ in 0..36 {
|
||||
game.board.change_focused_cell(MoveFocusedCell::Right);
|
||||
}
|
||||
assert_eq!(game.board.get_focused_cell().get_coords(), (5, 39));
|
||||
game.board.toggle_marking();
|
||||
assert!(!game.board.marked_cells.marking_cells);
|
||||
assert!(game.board.get_marked_cells().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_route_pauses_selected_miner_and_boundary_moves_do_not_repeat_cells() {
|
||||
let mut game = game();
|
||||
game.board.change_focused_cell(MoveFocusedCell::Left);
|
||||
queue(&mut game.board, (5, 2), &[(5, 3)]);
|
||||
game.board.toggle_marking();
|
||||
game.tick_update();
|
||||
assert_eq!(
|
||||
game.board.cells[5][3].get_structure().get_durability(),
|
||||
1000
|
||||
);
|
||||
for _ in 0..10 {
|
||||
game.board.change_focused_cell(MoveFocusedCell::Up);
|
||||
}
|
||||
let coords: Vec<_> = game.board.get_marked_cells().iter().copied().collect();
|
||||
assert!(coords.windows(2).all(|pair| pair[0] != pair[1]));
|
||||
game.board.end_marking_cells(true);
|
||||
game.tick_update();
|
||||
assert!(game.board.cells[5][3].get_structure().get_durability() < 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_another_task_extends_the_end_of_queued_work() {
|
||||
let mut game = game();
|
||||
for col in [3, 4, 5] {
|
||||
tunnel(&mut game.board, 5, col);
|
||||
}
|
||||
game.board.change_focused_cell(MoveFocusedCell::Left);
|
||||
game.board.toggle_marking();
|
||||
game.board.change_focused_cell(MoveFocusedCell::Right);
|
||||
game.board.change_focused_cell(MoveFocusedCell::Right);
|
||||
game.board.toggle_marking();
|
||||
game.board.change_focused_cell(MoveFocusedCell::Left);
|
||||
game.board.change_focused_cell(MoveFocusedCell::Left);
|
||||
game.board.toggle_marking();
|
||||
assert_eq!(game.board.get_focused_cell().get_coords(), (5, 4));
|
||||
game.board.change_focused_cell(MoveFocusedCell::Right);
|
||||
game.board.toggle_marking();
|
||||
assert_eq!(unit(&game.board, 5, 2).get_ref_tasks().len(), 2);
|
||||
advance(&mut game, 3 * travel_ticks());
|
||||
assert_eq!(unit(&game.board, 5, 5).get_coords(), (5, 5));
|
||||
assert!(unit(&game.board, 5, 5).get_ref_tasks().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freshly_dug_cell_is_entered_immediately_then_tunnel_travel_takes_three_seconds() {
|
||||
let mut game = game();
|
||||
tunnel(&mut game.board, 5, 4);
|
||||
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
|
||||
for _ in 0..200 {
|
||||
game.tick_update();
|
||||
if matches!(
|
||||
game.board.cells[5][3].get_structure(),
|
||||
Structures::Tunnel(_)
|
||||
) {
|
||||
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
|
||||
advance(&mut game, travel_ticks() - 1);
|
||||
assert_eq!(unit(&game.board, 5, 3).get_coords(), (5, 3));
|
||||
game.tick_update();
|
||||
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("miner did not finish digging the stone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miner_symbol_in_tunnel_uses_owner_color_even_when_selected_or_marked() {
|
||||
for owner in [Players::Player, Players::Enemy] {
|
||||
for zoom in [ZoomLevel::Default, ZoomLevel::ZoomedIn] {
|
||||
for (selected, marked) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
let mut game = game();
|
||||
let cell = &mut game.board.cells[5][3];
|
||||
cell.set_structure(Structures::Tunnel(Tunnel::new()));
|
||||
cell.set_selected(selected)
|
||||
.set_marked(marked)
|
||||
.set_zoom_level(zoom);
|
||||
*cell.get_mut_option_unit() = Some(Units::Miner(MinerUnit::new(owner, (5, 3))));
|
||||
let area = Rect::new(
|
||||
0,
|
||||
0,
|
||||
zoom.get_cell_size(CellSizes::Width) as u16,
|
||||
zoom.get_cell_size(CellSizes::Height) as u16,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
cell.clone().render(area, &mut buffer);
|
||||
let marker = buffer
|
||||
.content
|
||||
.iter()
|
||||
.find(|cell| cell.symbol() == "M")
|
||||
.unwrap();
|
||||
assert_eq!(marker.fg, owner.get_color(), "{owner:?} {zoom:?}");
|
||||
assert!(buffer.content.iter().any(|cell| cell.symbol() == "T"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_shows_movement_percentage_and_clears_it_after_each_step() {
|
||||
for zoom in [
|
||||
ZoomLevel::ZoomedIn,
|
||||
ZoomLevel::Default,
|
||||
ZoomLevel::ZoomedOut,
|
||||
] {
|
||||
let mut game = game();
|
||||
for col in [2, 3, 4] {
|
||||
tunnel(&mut game.board, 5, col);
|
||||
}
|
||||
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4)]);
|
||||
let render = |board: &BoardState, col: usize| {
|
||||
let area = Rect::new(
|
||||
0,
|
||||
0,
|
||||
zoom.get_cell_size(CellSizes::Width) as u16,
|
||||
zoom.get_cell_size(CellSizes::Height) as u16,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
let mut cell = board.get_ref_cell(5, col).clone();
|
||||
cell.set_zoom_level(zoom);
|
||||
cell.render(area, &mut buffer);
|
||||
buffer
|
||||
};
|
||||
let text = |buffer: &Buffer| -> String {
|
||||
buffer.content.iter().map(|cell| cell.symbol()).collect()
|
||||
};
|
||||
assert!(!text(&render(&game.board, 2)).contains('%'));
|
||||
advance(&mut game, 46);
|
||||
let buffer = render(&game.board, 2);
|
||||
assert!(text(&buffer).contains("50%"), "{zoom:?}");
|
||||
if zoom != ZoomLevel::ZoomedOut {
|
||||
let marker = buffer
|
||||
.content
|
||||
.iter()
|
||||
.find(|cell| cell.symbol() == "M")
|
||||
.unwrap();
|
||||
assert_eq!(marker.fg, Players::Player.get_color());
|
||||
}
|
||||
advance(&mut game, travel_ticks() - 46);
|
||||
assert!(!text(&render(&game.board, 2)).contains('%'));
|
||||
assert!(!text(&render(&game.board, 3)).contains('%'));
|
||||
game.tick_update();
|
||||
assert!(text(&render(&game.board, 3)).contains("1%"));
|
||||
advance(&mut game, travel_ticks() - 1);
|
||||
assert!(!text(&render(&game.board, 4)).contains('%'));
|
||||
assert!(unit(&game.board, 5, 4).get_ref_tasks().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_route_splits_into_moving_and_digging_segments() {
|
||||
let mut game = game();
|
||||
for col in [3, 4, 7] {
|
||||
tunnel(&mut game.board, 5, col);
|
||||
}
|
||||
game.board.change_focused_cell(MoveFocusedCell::Left);
|
||||
game.board.toggle_marking();
|
||||
for _ in 0..5 {
|
||||
game.board.change_focused_cell(MoveFocusedCell::Right);
|
||||
}
|
||||
game.board.toggle_marking();
|
||||
let tasks = unit(&game.board, 5, 2).get_ref_tasks();
|
||||
assert_eq!(tasks.len(), 3);
|
||||
assert!(matches!(tasks[0], Tasks::Moving(_)));
|
||||
assert_eq!(
|
||||
tasks[0].get_path().iter().copied().collect::<Vec<_>>(),
|
||||
[(5, 3), (5, 4)]
|
||||
);
|
||||
assert!(matches!(tasks[1], Tasks::Digging(_)));
|
||||
assert_eq!(
|
||||
tasks[1].get_path().iter().copied().collect::<Vec<_>>(),
|
||||
[(5, 5), (5, 6)]
|
||||
);
|
||||
assert!(matches!(tasks[2], Tasks::Moving(_)));
|
||||
assert_eq!(tasks[0].get_icon(), '→');
|
||||
assert_eq!(tasks[1].get_icon(), '⛏');
|
||||
advance(&mut game, 2 * travel_ticks());
|
||||
assert!(matches!(
|
||||
unit(&game.board, 5, 4).get_ref_tasks()[0],
|
||||
Tasks::Digging(_)
|
||||
));
|
||||
advance(&mut game, 2 * 182);
|
||||
assert!(matches!(
|
||||
unit(&game.board, 5, 6).get_ref_tasks()[0],
|
||||
Tasks::Moving(_)
|
||||
));
|
||||
advance(&mut game, travel_ticks());
|
||||
assert!(unit(&game.board, 5, 7).get_ref_tasks().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_task_does_not_damage_walkable_structure() {
|
||||
let mut game = game();
|
||||
tunnel(&mut game.board, 5, 3);
|
||||
let structure = game.board.cells[5][3].get_structure();
|
||||
game.board.cells[5][2]
|
||||
.get_mut_option_unit()
|
||||
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
|
||||
advance(&mut game, travel_ticks());
|
||||
assert_eq!(game.board.cells[5][3].get_structure(), structure);
|
||||
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previously_dug_route_becomes_moving_without_instant_steps() {
|
||||
let mut game = game();
|
||||
queue(&mut game.board, (5, 2), &[(5, 3), (5, 4), (5, 3)]);
|
||||
advance(&mut game, 2 * 182);
|
||||
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
|
||||
game.tick_update();
|
||||
assert!(matches!(
|
||||
unit(&game.board, 5, 4).get_ref_tasks()[0],
|
||||
Tasks::Moving(_)
|
||||
));
|
||||
advance(&mut game, travel_ticks() - 2);
|
||||
assert_eq!(unit(&game.board, 5, 4).get_coords(), (5, 4));
|
||||
game.tick_update();
|
||||
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_reclassifies_as_digging_if_target_is_stone() {
|
||||
let mut game = game();
|
||||
game.board.cells[5][2]
|
||||
.get_mut_option_unit()
|
||||
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
|
||||
game.tick_update();
|
||||
assert!(matches!(
|
||||
unit(&game.board, 5, 2).get_ref_tasks()[0],
|
||||
Tasks::Digging(_)
|
||||
));
|
||||
assert!(game.board.cells[5][3].get_structure().get_durability() < 1000);
|
||||
advance(&mut game, 181);
|
||||
assert!(unit(&game.board, 5, 3).get_ref_tasks().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_panel_shows_current_step_and_total_for_active_and_pending_tasks() -> () {
|
||||
let args: Cli = Cli::try_parse_from(["game", "--disable-audio", "--view", "skirmish"]).unwrap();
|
||||
let channels: (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
|
||||
let mut app: App = App::new(args, channels.0);
|
||||
app.window_area = Rect::new(0, 0, 120, 60);
|
||||
app.states = Some(GameStates::new(&app.args, &app.window_area));
|
||||
let state: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
|
||||
tunnel(&mut state.board, 5, 3);
|
||||
tunnel(&mut state.board, 5, 4);
|
||||
state.board.cells[5][2]
|
||||
.get_mut_option_unit()
|
||||
.set_task(Tasks::Moving(MovingTask::new([(5, 3), (5, 4)].into())));
|
||||
state.board.cells[5][2]
|
||||
.get_mut_option_unit()
|
||||
.set_task(Tasks::Digging(DiggingTask::new([(5, 5)].into(), 0.55)));
|
||||
advance(state, travel_ticks() + 46);
|
||||
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(120, 60)).unwrap();
|
||||
terminal
|
||||
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
|
||||
frame.render_widget(&mut app, frame.area());
|
||||
})
|
||||
.unwrap();
|
||||
let text: String = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.iter()
|
||||
.map(|cell: &ratatui::buffer::Cell| -> &str { cell.symbol() })
|
||||
.collect();
|
||||
assert!(text.contains("Step: 50% | Total: 75%"));
|
||||
assert!(text.contains("Step: 0% | Total: 0%"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_route_tick_and_tui_show_remaining_durability_at_every_zoom() {
|
||||
let args = Cli::try_parse_from(["game", "--disable-audio", "--view", "skirmish"]).unwrap();
|
||||
let (tx, _) = channel();
|
||||
let mut app = App::new(args, tx);
|
||||
app.window_area = Rect::new(0, 0, 120, 40);
|
||||
app.states = Some(GameStates::new(&app.args, &app.window_area));
|
||||
for code in [
|
||||
KeyCode::Left,
|
||||
KeyCode::Char(' '),
|
||||
KeyCode::Right,
|
||||
KeyCode::Char(' '),
|
||||
] {
|
||||
handle_keybindings(&mut app, KeyEvent::new(code, KeyModifiers::NONE));
|
||||
}
|
||||
app.states_mut().unwrap().skirmish.tick_update();
|
||||
let durability = app.states_ref().unwrap().skirmish.board.cells[5][3]
|
||||
.get_structure()
|
||||
.get_durability();
|
||||
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
|
||||
for zoom in [
|
||||
ZoomLevel::ZoomedIn,
|
||||
ZoomLevel::Default,
|
||||
ZoomLevel::ZoomedOut,
|
||||
] {
|
||||
let state = &mut app.states_mut().unwrap().skirmish;
|
||||
state.board.change_zoom(zoom);
|
||||
state.board.vertical_offset.set_value(5);
|
||||
terminal
|
||||
.draw(|frame| frame.render_widget(&mut app, frame.area()))
|
||||
.unwrap();
|
||||
let text: String = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect();
|
||||
assert!(text.contains("99%"), "{zoom:?}");
|
||||
}
|
||||
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
terminal
|
||||
.draw(|frame| frame.render_widget(&mut app, frame.area()))
|
||||
.unwrap();
|
||||
let text: String = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect();
|
||||
assert!(text.contains(&format!("{durability}/1000")));
|
||||
assert_eq!(app.view, View::Skirmish);
|
||||
}
|
||||
+74
-1
@@ -1,6 +1,6 @@
|
||||
use war_in_tunnels::app::states::skirmish_states::{
|
||||
Players,
|
||||
tasks::{DiggingTask, Task, Tasks},
|
||||
tasks::{DiggingTask, MovingTask, Task, Tasks},
|
||||
units::{MinerUnit, Unit},
|
||||
};
|
||||
|
||||
@@ -48,3 +48,76 @@ fn miners_keep_tasks_in_submission_order() {
|
||||
);
|
||||
assert_eq!(*miner.get_ref_tasks()[0].get_path_front_coords(), (6, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_progress_is_separate_from_digging_and_resets_for_next_cell() {
|
||||
let mut task = MovingTask::new([(5, 3), (5, 4)].into());
|
||||
assert_eq!(task.get_movement_progress(), None);
|
||||
assert_eq!(task.get_progress(), 0.0);
|
||||
assert!(!task.is_finished());
|
||||
for _ in 0..46 {
|
||||
task.progress();
|
||||
}
|
||||
assert_eq!(task.get_movement_progress(), Some(50));
|
||||
for _ in 46..91 {
|
||||
task.progress();
|
||||
}
|
||||
assert_eq!(task.get_movement_progress(), Some(100));
|
||||
assert_eq!(task.get_progress(), 100.0);
|
||||
assert!(task.is_finished());
|
||||
task.progress();
|
||||
assert_eq!(task.get_progress(), 100.0);
|
||||
task.pop_front();
|
||||
assert_eq!(task.get_movement_progress(), None);
|
||||
assert_eq!(task.get_progress(), 0.0);
|
||||
assert!(!task.is_finished());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overall_digging_progress_counts_completed_cells_and_current_step() -> () {
|
||||
let mut task: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
|
||||
assert_eq!(task.get_total_progress(), 0.0);
|
||||
task.progress();
|
||||
assert_eq!(task.get_progress(), 25.0);
|
||||
assert_eq!(task.get_total_progress(), 12.5);
|
||||
task.pop_front();
|
||||
assert_eq!(task.get_progress(), 0.0);
|
||||
assert_eq!(task.get_total_progress(), 50.0);
|
||||
task.progress();
|
||||
assert_eq!(task.get_total_progress(), 62.5);
|
||||
task.pop_front();
|
||||
assert_eq!(task.get_total_progress(), 100.0);
|
||||
let empty: DiggingTask = DiggingTask::new([].into(), 1.0);
|
||||
assert_eq!(empty.get_total_progress(), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_conversion_keeps_completed_steps_and_original_length() -> () {
|
||||
let mut digging: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
|
||||
digging.pop_front();
|
||||
let mut moving: MovingTask = MovingTask::from_digging(digging);
|
||||
assert_eq!(moving.get_total_steps(), 2);
|
||||
assert_eq!(moving.get_total_progress(), 50.0);
|
||||
for _ in 0..91 {
|
||||
moving.progress();
|
||||
}
|
||||
assert_eq!(moving.get_total_progress(), 100.0);
|
||||
let mut digging: DiggingTask = DiggingTask::from_moving(moving, 25.0);
|
||||
assert_eq!(digging.get_total_steps(), 2);
|
||||
assert_eq!(digging.get_total_progress(), 50.0);
|
||||
digging.progress();
|
||||
assert_eq!(digging.get_total_progress(), 62.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digging_queue_formats_step_total_and_pending_progress() -> () {
|
||||
let mut miner: MinerUnit = MinerUnit::new(Players::Player, (5, 2));
|
||||
let mut task: DiggingTask = DiggingTask::new([(5, 3), (5, 4)].into(), 25.0);
|
||||
task.pop_front();
|
||||
task.progress();
|
||||
miner.set_task(Tasks::Digging(task));
|
||||
miner.set_task(Tasks::Moving(MovingTask::new([(5, 5)].into())));
|
||||
let lines: Vec<ratatui::text::Line<'_>> = miner.get_tasks_formatted();
|
||||
assert!(lines[1].to_string().contains("Step: 25% | Total: 62%"));
|
||||
assert!(lines[3].to_string().contains("Step: 0% | Total: 0%"));
|
||||
}
|
||||
Reference in new issue
Block a user