Add tab navigation for building and recruitment UI
Nix package / build (push) Successful in 7m59s
Linux package builds (test) / linux (push) Successful in 2m29s
Linux package builds (test) / aur (push) Successful in 7m18s
Rust checks / check (push) Successful in 3m21s

- Add selection indices and option lists to SkirmishState.
- Implement `cycle_popup_option` and expose Left/Right actions in
  keybindings.
- Introduce `cancel_focused_tasks` in BoardState for Delete handling.
- CellWidget now displays recruitment progress on Base structures.
- Update README and documentation to describe new tabs and arrow
  navigation.
- Adjust test counts and add extensive task‑cancellation tests.
This commit is contained in:
GarandPLG committed 2026-10-23 17:53:46 +02:00
1 parent 6c1141a711
commit 110d4c411a
16 files changed
+546 -48

No files matched your search

+12 -2
View File
@@ -103,11 +103,13 @@ Możesz zaznaczyć pracującego górnika ponownie: rysowanie kolejnego zadania z
## Zasoby i budowanie
Okna budowania (`b`) i rekrutacji (`r`) mają zakładki przełączane strzałkami ←/→. Zaznaczona pozycja jest wyróżniona jasną magentą. Obecnie dostępne są wyłącznie Mine i Miner, więc przełączanie pozostawia jedyną pozycję; strzałki w tych oknach nie przesuwają kursora mapy. Enter wykonuje dostępną akcję, a ponowne b/r zamyka odpowiednie okno.
Wood i Metal mają rzeczywisty stan w magazynie każdego gracza, inicjalizowany z `--starting-wood` i `--starting-metal`. Nagłówek pokazuje bieżące zasoby. Gracz otrzymuje 5 Wood co około 5 sekund symulacji; metal nie przyrasta bez kopalni. Złoża Ore są początkowo neutralne.
Ustaw kursor na neutralnym Ore i otwórz widok budowania przez `b`. Jeśli na jednej z czterech sąsiednich kratek stoi własny Miner z uprawnieniem budowania i masz 20 Wood oraz 10 Metal, Enter rozpoczyna budowę Mine. Koszt jest pobierany od razu, a budowa trwa około 10 sekund. Panel pozostaje otwarty i pokazuje postęp; procent widać też na kratce. Po odejściu budowniczego budowa zatrzymuje się do jego powrotu. Nie można budować zdalnie na rudzie bez sąsiedniego własnego budowniczego.
Delete w panelu budowy anuluje trwającą budowę, przywraca pierwotne neutralne Ore i zwraca cały koszt. b 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.
Delete w panelu budowy anuluje trwającą budowę, przywraca pierwotne neutralne Ore i zwraca cały koszt. b jedynie zamyka panel — budowa nadal postępuje, jeżeli budowniczy jest obecny. Niedokończona kopalnia nie produkuje metalu i nie przyjmuje jednostek. Na początkowej mapie złoże obok bazy spełnia warunek sąsiedztwa; kopalnie nie pojawiają się automatycznie.
Mine wydobywa 1 Metal co około sekundę, aż do wyczerpania złoża. Możesz wysłać do własnej kopalni górnika zwykłą trasą zatwierdzaną spacją. Wejście trwa około 3 sekund jako Moving; górnik pozostaje na kratce kopalni i zwiększa wydobycie do 2 Metal/s. Po jego wyjściu bonus znika. W kopalni mieści się jedna jednostka; obcy górnicy nie mogą do niej wejść. Panel Tab pokazuje aktualne tempo wydobycia.
@@ -115,9 +117,17 @@ Wspólna reguła zwykłych budynków wymaga pustego kamienia sąsiadującego z t
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.
## Odwoływanie zadań jednostki
Pozostałe ścieżki zadań własnych jednostek są podświetlane kolorem LightMagenta, a pole ścieżki pod kursorem — Magenta. Podświetlenie obejmuje wszystkie zadania w kolejce i znika wraz z wykonaniem lub odwołaniem odcinków.
Ustaw kursor na własnej jednostce i otwórz szczegóły przez Tab. Backspace odwołuje ostatnie zadanie (także aktywne, jeśli zostało tylko jedno). Delete odwołuje całą kolejkę i zatrzymuje jednostkę. Przerwany ruch pozostawia jednostkę na obecnej kratce; wykonane tunele i uszkodzenia kamienia pozostają na mapie. Podczas wytyczania trasy te klawisze nadal cofają i anulują wyłącznie rysowaną trasę. W oknie budowania Delete nadal anuluje budowę.
## Rekrutacja
Ustaw kursor na własnym budynku Base i naciśnij `r`. Enter dodaje jednego Minera do kolejki za **10 Wood i 15 Metal**, pobierane od razu. Każda rekrutacja trwa około **10 sekund**. Panel pokazuje liczbę zamówionych górników (włącznie z aktualnie rekrutowanym), procent bieżącej rekrutacji i powód oczekiwania. Esc lub `r` zamyka panel bez kasowania kolejki. Liczbę i postęp widać też pod Tab.
Na kratce bazy widać cyjanowy procent bieżącej rekrutacji. Wskaźnik pozostaje przy 100%, gdy gotowa jednostka czeka na wolne miejsce, i znika po opróżnieniu kolejki. W najmniejszym zoomie gotowość pokazuje `100` bez znaku procentu, aby zmieścić się w kratce.
Ustaw kursor na własnym budynku Base i naciśnij `r`. Enter dodaje jednego Minera do kolejki za **10 Wood i 15 Metal**, pobierane od razu. Każda rekrutacja trwa około **10 sekund**. Panel pokazuje liczbę zamówionych górników (włącznie z aktualnie rekrutowanym), procent bieżącej rekrutacji i powód oczekiwania. `r` zamyka panel bez kasowania kolejki. Liczbę i postęp widać też pod Tab.
Pierwszy górnik może zostać wyszkolony do 100%, nawet jeśli baza jest zajęta. Gotowy czeka, aż kratka będzie wolna, i pojawia się w bazie bez nadpisywania stojącej tam jednostki. Następna rekrutacja pozostaje na 0%, dopóki poprzedni zrekrutowany górnik nie opuści bazy. Wyprowadź go zwykłą trasą, aby wznowić kolejkę. Każda baza ma niezależną kolejkę; nowe jednostki otrzymują właściciela bazy i pełne uprawnienia Minera.
+2 -2
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.
Aktualny zestaw obejmuje 83 testy. 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.
Aktualny zestaw obejmuje 89 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ń.
@@ -42,7 +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.
- `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.
- Konteksty mapy, trasy i budowania określa `available_skirmish_actions`. Globalne wyjście i muzyka działają niezależnie od legendy. b otwiera budowanie dla wskazanego pola; Enter wymaga poprawnego miejsca, sąsiedniego własnego budowniczego i środków. Delete anuluje własną budowę i zwraca koszt, Esc tylko zamyka panel.
- Konteksty mapy, trasy i budowania określa `available_skirmish_actions`. Globalne wyjście i muzyka działają niezależnie od legendy. b otwiera budowanie dla wskazanego pola; Enter wymaga poprawnego miejsca, sąsiedniego własnego budowniczego i środków. Delete anuluje własną budowę i zwraca koszt, b tylko zamyka panel.
- Bieżące zasoby są w SkirmishState, nie w ustawieniach CLI. Gameplay rozpoczyna budowę przez SkirmishState::start_mine_construction, aby objąć ją opłatą; BoardState::can_build_at/start_mine_construction obsługują samą walidację terenu i zmianę struktury. MineConstruction zachowuje oryginalne Ore i postępuje tylko przy sąsiednim własnym budowniczym. Mine zachowuje rezerwę Ore i kredytuje wyłącznie właściciela. Unit::get_can_build deleguje uprawnienia przez Units i Option. Ruch do gotowej własnej Mine korzysta ze Structures::can_enter i pozostaje Moving. Górnik na kratce kopalni podwaja wydobycie; nie twórz odrębnej kopii obsadzonej jednostki. Zmiany sprawdzaj przez tests/resources.rs, tests/context_keybindings.rs i tests/construction.rs.
- Rekrutację opłacaj przez SkirmishState::recruit_miner, nie bezpośrednie BaseBuilding::queue_miner. r otwiera panel tylko przy własnej bazie i poza innym trybem modalnym. Pierwsza rekrutacja może dojść do 100% na zajętej bazie, ale nie może nadpisać jednostki; kolejne stoją na 0% do wyjścia poprzedniego rekruta. BaseBuilding zachowuje licznik, postęp i waiting_for_departure; BoardState uruchamia spawn po ruchu istniejących jednostek. Sprawdzaj tests/recruitment.rs.
- `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`.
+11 -3
View File
@@ -87,16 +87,20 @@ Mapa powstaje deterministycznie: kamień, baza gracza w `(map_height / 2, 2)`, b
MineConstruction zachowuje gotowy model Mine z oryginalnym Ore oraz licznik budowy. Po ruchu jednostek BoardState::advance_construction nalicza tick tylko przy sąsiednim własnym budowniczym; odejście pauzuje budowę, powrót wznawia. Po 304 tickach (10032 ms, około 10 sekund) struktura staje się Mine. Do tego czasu nie można do niej wejść ani wydobywać. Procenty są na kratce, w panelu Tab i widoku budowania. SkirmishState::cancel_mine_construction dopuszcza tylko własny aktywny plac, przywraca oryginalne neutralne Ore i jednokrotnie zwraca koszt; zakończonej kopalni nie da się w ten sposób sprzedać. Resources::spend sprawdza oba zasoby przed odjęciem.
`available_skirmish_actions` jest wspólne dla obsługi wejścia i legendy. Na mapie spacja jest dostępna przy własnym górniku, a zoom zależy od poziomu. Rysowanie trasy ukrywa zoom, przewijanie i budowanie; Esc anuluje trasę. b otwiera SkirmishState::building_view dla wskazanego pola. Poprawne pole z funduszami udostępnia Enter/Build Mine; panel pozostaje otwarty z procentami i Delete/Cancel construction dla własnej trwającej budowy. Esc lub b tylko zamyka panel, nie anuluje budowy. Muzyka i wyjście są globalne. Widget legendy stosuje opisy zależne od kontekstu i szerokości kolumn proporcjonalne do tekstu, zachowując stałą wysokość stopki i geometrię mapy.
`available_skirmish_actions` jest wspólne dla obsługi wejścia i legendy. Na mapie spacja jest dostępna przy własnym górniku, a zoom zależy od poziomu. Rysowanie trasy ukrywa zoom, przewijanie i budowanie; Esc anuluje trasę. b otwiera SkirmishState::building_view dla wskazanego pola. Poprawne pole z funduszami udostępnia Enter/Build Mine; panel pozostaje otwarty z procentami i Delete/Cancel construction dla własnej trwającej budowy. b tylko zamyka panel, nie anuluje budowy. Muzyka i wyjście są globalne. Widget legendy stosuje opisy zależne od kontekstu i szerokości kolumn proporcjonalne do tekstu, zachowując stałą wysokość stopki i geometrię mapy.
`Structures::can_enter` jest wspólną regułą dla planowania trasy i jej wykonywania: baza i tunel pozostają przechodnie; Mine przyjmuje własnego Minera, jeżeli kratka nie jest zajęta. Wejście do kopalni jest Moving (około 3 sekund), również gdy zadanie wcześniej miało typ Digging. Obsadzony górnik pozostaje jedyną jednostką na kratce, można go zaznaczyć i wyprowadzić zwykłą trasą. Bonus wylicza się z bieżącej obecności jednostki po ruchu; nie ma osobnej kopii pracownika ani osobnego zadania pracy.
BaseBuilding przechowuje queued_miners, recruitment_ticks i waiting_for_departure. Ponieważ kolejka obsługuje wyłącznie identycznych Minerów, wystarcza licznik pozycji zamiast listy obiektów. SkirmishState::recruit_miner sprawdza typ i właściciela bazy, granice, pojemność licznika i MinerUnit::RECRUIT_COST (10 Wood, 15 Metal), po czym opłaca dodanie pozycji. BoardState::advance_recruitment jest wywoływane po ruchu jednostek, przed budowaniem i wydobyciem. Pierwszy Miner rekrutuje się przez 304 ticki (10032 ms), nawet na zajętej bazie; przy 100% czeka na wolne pole. Spawn tworzy jednostkę z właścicielem i współrzędnymi bazy oraz pustą kolejką zadań. Po spawnie waiting_for_departure blokuje odliczanie kolejnych pozycji, dopóki baza jest zajęta. Nigdy nie nadpisujemy obecnej jednostki. Każda baza ma osobną kolejkę; przeciwnik korzysta z własnych zasobów.
r jest dostępne na własnej Base poza rysowaniem trasy i widokiem budowania. SkirmishState::recruitment_view otwiera modal z kosztem, liczbą pozycji, procentem i statusem Recruiting/Ready/Paused. Enter dodaje Minera tylko przy wystarczających zasobach. Esc lub r zamyka panel, zachowując kolejkę; ruch mapy i otwarcie innego modalu są wtedy zablokowane. Opisy w legendzie zależą od trybu, a postęp i liczba pozycji są też pod Tab.
r jest dostępne na własnej Base poza rysowaniem trasy i widokiem budowania. SkirmishState::recruitment_view otwiera modal z kosztem, liczbą pozycji, procentem i statusem Recruiting/Ready/Paused. Enter dodaje Minera tylko przy wystarczających zasobach. r zamyka panel, zachowując kolejkę; ruch mapy i otwarcie innego modalu są wtedy zablokowane. Opisy w legendzie zależą od trybu, a postęp i liczba pozycji są też pod Tab.
## Klawisze i zlecanie kopania
Okna budowania i rekrutacji mają Tabs oraz osobne indeksy selected_building/selected_recruit w SkirmishState. Listy BUILDING_OPTIONS/RECRUITMENT_OPTIONS zawierają obecnie wyłącznie Mine/Miner. cycle_popup_option zawija wybór strzałkami Left/Right w obrębie aktywnego okna, bez ruchu planszy. Przy dodawaniu kolejnego typu trzeba uzupełnić listę oraz powiązać szczegóły, walidację i akcje Enter/Delete z jego indeksem; obecna implementacja akcji obsługuje tylko istniejące typy.
CellWidget pokazuje cyjanowy postęp BaseBuilding::recruitment_progress na środku kratki, gdy queued_miners > 0. Wskaźnik obejmuje 0% i oczekiwanie przy 100%; po opróżnieniu kolejki znika. W najmniejszym zoomie 100 nie ma sufiksu %, aby zmieścić trzy znaki. Rekrutacja ma pierwszeństwo przed procentem ruchu jednostki opuszczającej bazę; w zwykłym i dużym zoomie zachowany jest znacznik jednostki.
1. `handle_ct_events` odbiera zdarzenie Crossterm i przekazuje je kanałem do `App`.
2. `handle_keybindings` wybiera obsługę menu, potyczki albo domyślną.
3. `event_to_action` porównuje kod, rodzaj i modyfikatory z `KEYBINDINGS`, następnie obsługuje znak wildcard.
@@ -117,6 +121,10 @@ Postęp bieżącego kroku jest udostępniany przez `MovingTask::get_movement_pro
`Task::get_progress()` oznacza postęp bieżącej kratki, a `get_total_progress()` uwzględnia wykonane kratki i bieżący etap. Oba typy zadań zapamiętują początkową długość trasy w `total_steps`; `from_digging` i `from_moving` zachowują ją przy zmianie typu. `MinerUnit::get_tasks_formatted()` prezentuje każde zadanie w dwóch wierszach: trasa oraz `Step: …% | Total: …%`. Wartość całkowita jest liczona według liczby kratek, nie szacowanego czasu pozostałego do końca.
Gdy panel Tab jest otwarty, fokus wskazuje własną jednostkę z zadaniami i nie jest rysowana trasa, legenda udostępnia Backspace/Cancel last task oraz Delete/Cancel all tasks. BoardState::cancel_focused_tasks usuwa ostatnie zadanie albo czyści kolejkę; waliduje właściciela, niepustą kolejkę i brak rysowania. Usunięcie zadania usuwa również jego timer. Teren i położenie jednostki pozostają bez zmian. Nie usuwamy zadań ze środka trasy, ponieważ dalsze odcinki zależą od końca poprzednich. Modal budowania zachowuje Delete do anulowania budowy.
BoardWidget podczas renderowania zbiera pozostałe współrzędne zadań własnych jednostek do HashSet. W widocznych kopiach CellWidget ustawia marked dla tych pól, korzystając z istniejącej pary LightMagenta/Magenta i obsługi zoomu. Nie modyfikuje flag modelu ani rysowanej trasy. Zakończenie lub anulowanie zadania usuwa podświetlenie przy kolejnym renderowaniu; trasy przeciwnika nie są pokazywane.
## Audio i pakowanie
Główny wątek wysyła `AudioCmd`; osobny wątek obsługuje głośność, mute, część i soundtrack. Cache wykorzystuje `Lazy`, `RwLock`, `Arc` i bajty OGG. Calm ładuje `calm.ogg`; Buildup, Assault i Outro korzystają obecnie z `test.ogg`. Błędy urządzenia, odczytu i dekodowania mogą zakończyć wątek przez `expect`.
@@ -133,7 +141,7 @@ To obserwacje statyczne, nie lista napraw wykonanych w tej sesji:
- **Zaznaczenie:** wybrany górnik czeka podczas rysowania trasy; inne jednostki wykonują zadania normalnie.
- **Audio:** usunięto aktywne odpytywanie; pomiar CPU na docelowym urządzeniu i odsłuch pozostają do wykonania. Test dekodera nie weryfikuje urządzenia audio.
- **Granice UI:** małe terminale, resize i trzy poziomy zoomu wymagają interaktywnego sprawdzenia. Indeksowanie planszy zakłada poprawne współrzędne.
- **Pokrycie:** 83 testy 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.
- **Pokrycie:** 89 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ę
+30
View File
@@ -1,5 +1,35 @@
# Dziennik prac
## 2026-10-23 — zakładki budowania i rekrutacji
Dodano paski Tabs z wyróżnieniem LightMagenta oraz strzałki Left/Right przełączające opcje w aktywnym oknie bez ruchu mapy. SkirmishState przechowuje osobne indeksy i listy opcji (obecnie wyłącznie Mine oraz Miner); wybór zawija na końcach listy. Przy jednej pozycji strzałki zachowują ten sam wybór. Nie dodano budynków ani jednostek. Enter, Delete i zamykanie b/r zachowują dotychczasowe działanie; Esc pozostaje ignorowany. Dodanie przyszłych typów wymaga powiązania ich szczegółów i akcji z wyborem, zgodnie z CODE_MAP.
Dodano test strzałek w obu oknach, niezmienności planszy i kolejek oraz zamykania właściwym skrótem. Dostosowano test kolorów zaznaczonej zakładki i legendy. Wszystkie 89 testów oraz cargo check --all-targets --locked przez nix develop przeszły. Zastosowano cargo fmt; git diff --check przeszedł. Zaktualizowano README, CODE_MAP i TESTING. Nie sprawdzano ręcznie terminala.
## 2026-10-23 — procent rekrutacji na kratce bazy
CellWidget wyświetla cyjanowy procent rekrutacji na środku Base z niepustą kolejką. Pokazuje także 0% i gotowe 100% czekające na wolne miejsce; znika po zakończeniu kolejki. W najmniejszym zoomie 100 jest bez sufiksu %, aby zmieścić się w trzech znakach wnętrza. Wskaźnik ma pierwszeństwo przed procentem ruchu jednostki opuszczającej bazę; w pozostałych zoomach zachowano znacznik jednostki. Brak zmian symulacji i czasu rekrutacji.
Dodano regresję wszystkich trzech zoomów: brak procentu bez kolejki, 0%, 50%, cyjanowy kolor, znacznik Minera, oczekiwanie przy 100% oraz usunięcie wskaźnika po spawnie. Wszystkie 88 testów i cargo check --all-targets --locked przez nix develop przeszły. Zastosowano cargo fmt, git diff --check przeszedł; zaktualizowano README i CODE_MAP. Nie sprawdzano ręcznie terminala.
## 2026-10-23 — podświetlenie ścieżek zadań
BoardWidget zbiera pozostałe pola wszystkich zadań własnych jednostek do HashSet i podświetla widoczne kopie komórek istniejącymi kolorami LightMagenta oraz Magenta pod kursorem. Obsługuje ruch i kopanie, także dalsze pozycje kolejki. Flagi marked modelu i wytyczanie nowych tras nie są zmieniane. Pola znikają z podświetlenia po wykonaniu lub odwołaniu zadań; przeciwnik nie ujawnia tras. Wykorzystano istniejące renderowanie CellWidget bez nowego stanu gry.
Dodano test renderowania w tests/task_cancellation.rs: kolor trasy, skracanie po przejściu, znikanie po anulowaniu i zakończeniu, zachowanie flag modelu oraz brak kolorowania tras przeciwnika. Wszystkie 87 testów i cargo check --all-targets --locked przez nix develop przeszły. Zastosowano cargo fmt; git diff --check przeszedł. Uzupełniono README, CODE_MAP i TESTING. Nie sprawdzano ręcznie terminala.
## 2026-10-23 — odwoływanie zadań jednostek
Pod Tab, z fokusem na własnej jednostce z zadaniami, Backspace odwołuje ostatnie zadanie, a Delete całą kolejkę. Jeśli zostało jedno zadanie, Backspace odwołuje także wykonywane. Legenda dopasowuje opisy do kontekstu; podczas rysowania trasy klawisze zachowują cofanie/anulowanie trasy, a Delete w budowaniu nadal anuluje budowę. BoardState::cancel_focused_tasks waliduje właściciela, kolejkę i brak rysowania. Nie dodano usuwania środkowej pozycji, ponieważ dalsze zadania bazują na końcu poprzednich odcinków trasy.
Anulowanie usuwa timer zadania, zachowując położenie jednostki, wykopane tunele i uszkodzenia kamienia. Dodano 3 testy integracyjne obejmujące opóźnione i aktywne zadania, przerwany ruch, ochronę przeciwnika i trybu trasy oraz klawisze pod Tab. Wszystkie 86 testów oraz cargo check --all-targets --locked przez nix develop przeszły. Zastosowano cargo fmt i zaktualizowano dokumentację. Nie sprawdzano ręcznej rozgrywki.
## 2026-10-23 — zamykanie paneli wyłącznie skrótem otwierającym
Odświeżono kontekst po zmianach z drugiego komputera: aktualne skróty to b (budowanie), r (rekrutacja), + i - (głośność), a rustfmt używa 4 spacji. Zachowano te ustawienia. Usunięto Esc z akcji i legendy obu paneli. Obsługa modalna jawnie ignoruje Esc, aby nie uruchamiał również globalnego powrotu do menu. b zamyka budowanie, r zamyka rekrutację; kolejki i budowy pozostają zachowane. Esc poza tymi panelami zachowuje dotychczasowe działanie.
Dostosowano testy kontekstu, rekrutacji i budowania: sprawdzają ignorowanie Esc bez zmiany widoku, brak Esc w legendzie oraz zamykanie odpowiednim skrótem. Zaktualizowano README, CODE_MAP, TESTING i instrukcje agenta. Wszystkie 83 testy oraz cargo check --all-targets --locked przez nix develop przeszły; zastosowano cargo fmt. Nie sprawdzano ręcznej rozgrywki w terminalu.
## 2026-10-21 — spójne skróty głośności
Przyciszanie muzyki przeniesiono z `n` na `-`, zgodnie z parą `+`/`-`. Zaktualizowano wspólną tabelę skrótów (także legendę), istniejący test komend audio i README. Kompilacja wszystkich targetów, testy `tests/app.rs` i kontrola formatowania przez Nix przeszły. Nie sprawdzano odsłuchu.
+6 -4
View File
@@ -18,12 +18,12 @@ Testy korzystają z wbudowanego mechanizmu Rust, Clap i `ratatui::backend::TestB
| `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/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` | 5 | Kontekst mapy i trasy, anulowanie przez Esc, blokada budowania na nieprawidłowym polu, b i odrzucenie starych skrótów, globalne wyjście i czytelność legendy przy 120 kolumnach i kolory tekstu obu okien |
| `tests/context_keybindings.rs` | 6 | Kontekst mapy i trasy, anulowanie przez Esc, blokada budowania na nieprawidłowym polu, b i odrzucenie starych skrótów, globalne wyjście i czytelność legendy przy 120 kolumnach i kolory tekstu obu okien |
| `tests/construction.rs` | 16 | Uprawnienia budowniczego i delegacja, sąsiedztwo i brak zdalnej budowy, właściciel, reguła kamienia i wyjątek Ore, ochrona zajętych/wyczerpanych/wrogich złóż, atomowa opłata, 10 sekund budowy, pauza i wznowienie, brak przedwczesnego wydobycia/wejścia, procenty i anulowanie Delete w TUI, jednokrotny zwrot i zachowanie Ore, ruch do/z Mine i bonus wydobycia, ochrona rezerwy |
| `tests/recruitment.rs` | 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, r/Enter/Esc i kontekstowa legenda |
| `tests/recruitment.rs` | 10 | Wyłącznie własna Base, atomowy koszt każdej pozycji i brak obciążenia przy błędzie, 10 sekund rekrutacji, oczekiwanie przy 100%, brak nadpisania jednostki, blokada kolejnych pozycji do wyjścia poprzedniego rekruta, wznowienie po ruchu, niezależne bazy i właściciele, r/Enter, ignorowanie Esc w obu panelach i kontekstowa legenda |
| `tests/digging.rs` | 18 | Wykonywanie kopania i ruchu, kolejki, 3 sekundy na krok, wejście po wykopaniu, blokady i walidacja, kolory i procenty w TUI, podział trasy Moving/Digging, ochrona struktur, zmiana rodzaju zadania, panel Tab z postępem etapu i całego zadania |
Łącznie: 83 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: 89 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:
@@ -47,6 +47,8 @@ nix develop -c cargo clippy --all-targets --locked
`shellHook` środowiska Nix wyświetla dostępne polecenia; nie kompiluje automatycznie programu.
Testy `tests/task_cancellation.rs` (4) sprawdzają usuwanie ostatniego zadania i całej kolejki pod Tab, zachowanie wykonanych uszkodzeń, przerwany ruch bez teleportacji i zachowania timera, ochronę przeciwnika i rysowania trasy. Dodatkowa regresja sprawdza kolory tras, znikanie po wykonaniu i odwołaniu, brak zmian flag modelu oraz ukrywanie tras przeciwnika.
## CI i profile kompilacji
Testowe paczki deb, rpm, AppImage, AUR i Flatpak sprawdza niezależny `.gitea/workflows/packages.yml`, bez publikowania wydań. Zasady budowania, artefakty i ograniczenia opisano w [PACKAGING.md](PACKAGING.md).
@@ -59,7 +61,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ę.
Release ma `overflow-checks = true`. Ten sam zestaw 83 testów można wykonać z kontrolą przepełnień w profilu produkcyjnym:
Release ma `overflow-checks = true`. Ten sam zestaw 89 testów można wykonać z kontrolą przepełnień w profilu produkcyjnym:
```sh
nix develop -c cargo test --all-targets --release --locked
+26 -5
View File
@@ -14,14 +14,20 @@ use ratatui::{crossterm::event::KeyEvent, layout::Rect};
pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
if state.recruitment_view {
let mut actions: Vec<Action> = vec![Action::Recruitment, Action::Esc, Action::Quit2];
let mut actions: Vec<Action> = vec![
Action::Recruitment,
Action::Left,
Action::Right,
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 mut actions: Vec<Action> =
vec![Action::Building, Action::Left, Action::Right, 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()
@@ -72,6 +78,9 @@ pub fn available_skirmish_actions(state: &SkirmishState) -> Vec<Action> {
if unit.get_owner() == Players::Player && unit.get_can_dig() {
actions.push(Action::Space);
}
if state.side_panel && unit.get_owner() == Players::Player && !unit.get_ref_tasks().is_empty() {
actions.extend([Action::Backspace, Action::Delete]);
}
actions
}
@@ -83,7 +92,10 @@ pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () {
};
if states.skirmish.recruitment_view {
match action {
Action::Recruitment | Action::Esc => states.skirmish.recruitment_view = false,
Action::Recruitment => states.skirmish.recruitment_view = false,
Action::Left => states.skirmish.cycle_popup_option(false),
Action::Right => states.skirmish.cycle_popup_option(true),
Action::Esc => (),
Action::Enter => {
let coords: (usize, usize) =
states.skirmish.board.get_focused_cell().get_coords();
@@ -95,7 +107,10 @@ pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () {
}
if states.skirmish.building_view {
match action {
Action::Building | Action::Esc => states.skirmish.building_view = false,
Action::Building => states.skirmish.building_view = false,
Action::Left => states.skirmish.cycle_popup_option(false),
Action::Right => states.skirmish.cycle_popup_option(true),
Action::Esc => (),
Action::Enter => {
let coords: (usize, usize) =
states.skirmish.board.get_focused_cell().get_coords();
@@ -201,7 +216,13 @@ pub fn skirmish_keybindings(app: &mut App, key_event: &KeyEvent) -> () {
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 if board.marked_cells.marking_cells => board.pop_marked_cell(),
Action::Backspace => {
board.cancel_focused_tasks(false);
}
Action::Delete if !board.marked_cells.marking_cells => {
board.cancel_focused_tasks(true);
}
Action::Delete => {
board.marked_cells.marking_cells = false;
board.end_marking_cells(true)
+2
View File
@@ -40,6 +40,8 @@ impl GameStates {
enemy_resources: Resources::new(args.starting_wood, args.starting_metal),
building_view: false,
recruitment_view: false,
selected_building: 0,
selected_recruit: 0,
},
perk_decks: PerkDecksState {
id: 2,
+20
View File
@@ -17,9 +17,29 @@ pub struct SkirmishState {
pub enemy_resources: Resources,
pub building_view: bool,
pub recruitment_view: bool,
pub selected_building: usize,
pub selected_recruit: usize,
}
impl SkirmishState {
pub const BUILDING_OPTIONS: &'static [&'static str] = &["Mine"];
pub const RECRUITMENT_OPTIONS: &'static [&'static str] = &["Miner"];
pub fn cycle_popup_option(&mut self, next: bool) -> () {
let (selected, count): (&mut usize, usize) = if self.recruitment_view {
(&mut self.selected_recruit, Self::RECRUITMENT_OPTIONS.len())
} else if self.building_view {
(&mut self.selected_building, Self::BUILDING_OPTIONS.len())
} else {
return;
};
*selected = if next {
(*selected + 1) % count
} else {
(*selected + count - 1) % count
};
}
pub fn can_recruit_miner(&self, (row, col): (usize, usize), owner: Players) -> bool {
let resources: &Resources = match owner {
Players::Player => &self.player_resources,
+19
View File
@@ -500,6 +500,25 @@ impl BoardState {
}
}
pub fn cancel_focused_tasks(&mut self, all: bool) -> bool {
if self.marked_cells.marking_cells {
return false;
}
let (row, col): (usize, usize) = self.focused_cell.get_coords();
let Some(unit) = self.cells[row][col].get_mut_option_unit().as_mut() else {
return false;
};
if unit.get_owner() != Players::Player || unit.get_ref_tasks().is_empty() {
return false;
}
if all {
unit.get_mut_tasks().clear();
} else {
unit.get_mut_tasks().pop_back();
}
true
}
pub fn advance_turn(&mut self) {
// Snapshot positions so moving to a later cell cannot grant another turn.
let active: Vec<(usize, usize)> = self
+53 -24
View File
@@ -2,6 +2,7 @@ use crate::app::{
App,
helpers::block_title_helper,
keybindings::{Action, available_skirmish_actions},
states::SkirmishState,
states::skirmish_states::{
BoardState, Players,
resources::{WOOD_DELIVERY_AMOUNT, WOOD_DELIVERY_INTERVAL_MS},
@@ -13,9 +14,9 @@ use crate::app::{
use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Margin, Rect},
style::{Color, Stylize},
style::{Color, Style, Stylize},
text::Line,
widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
widgets::{Block, Borders, Clear, Padding, Paragraph, Tabs, Widget},
};
pub fn skirmish_layout(area: Rect) -> [Rect; 3] {
@@ -137,7 +138,31 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
{
let actions: Vec<Action> = available_skirmish_actions(&states.skirmish);
let descriptions: [(Action, &'static str); 11] = [
let descriptions: [(Action, &'static str); 14] = [
(
Action::Left,
if states.skirmish.building_view || states.skirmish.recruitment_view {
"Previous option"
} else {
"Left"
},
),
(
Action::Right,
if states.skirmish.building_view || states.skirmish.recruitment_view {
"Next option"
} else {
"Right"
},
),
(
Action::Backspace,
if board.marked_cells.marking_cells {
"Undo last cell"
} else {
"Cancel last task"
},
),
(
Action::Enter,
if states.skirmish.recruitment_view {
@@ -158,6 +183,8 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
Action::Delete,
if states.skirmish.building_view {
"Cancel construction"
} else if !board.marked_cells.marking_cells {
"Cancel all tasks"
} else {
"Cancel marking"
},
@@ -172,11 +199,7 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
),
(
Action::Esc,
if states.skirmish.recruitment_view {
"Close recruitment"
} else if states.skirmish.building_view {
"Close buildings"
} else if board.marked_cells.marking_cells {
if board.marked_cells.marking_cells {
"Cancel route"
} else {
"Menu"
@@ -228,7 +251,6 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
vec![Line::from("Construction unavailable: check site, builder and resources").red()]
};
let mut lines: Vec<Line<'_>> = vec![
Line::from("Mine").yellow().bold(),
Line::from(format!(
"Cost: {} Wood + {} Metal | Build time: 10s",
Mine::COST.wood,
@@ -240,13 +262,17 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
];
lines.extend(status);
Clear.render(popup, buf);
Paragraph::new(lines)
.block(
Block::bordered()
.title(Line::from("Buildings").magenta().bold())
.padding(Padding::uniform(1)),
)
.render(popup, buf);
let block: Block<'_> = Block::bordered()
.title(Line::from("Buildings").magenta().bold())
.padding(Padding::uniform(1));
let [tabs_area, details_area]: [Rect; 2] =
Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(block.inner(popup));
block.render(popup, buf);
Tabs::new(SkirmishState::BUILDING_OPTIONS.iter().copied())
.select(states.skirmish.selected_building)
.highlight_style(Style::default().fg(Color::LightMagenta).bold())
.render(tabs_area, buf);
Paragraph::new(lines).render(details_area, buf);
}
if states.skirmish.recruitment_view {
let popup: Rect = main_area.centered(Constraint::Length(64), Constraint::Length(14));
@@ -263,7 +289,6 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
Line::from("Insufficient resources or queue full").red()
};
vec![
Line::from("Miner").yellow().bold(),
Line::from(format!(
"Cost: {} Wood + {} Metal | Recruit time: 10s",
MinerUnit::RECRUIT_COST.wood,
@@ -287,12 +312,16 @@ pub fn skirmish_view(app: &App, area: Rect, buf: &mut Buffer) {
vec![Line::from("Select your Base to recruit Miners").red()]
};
Clear.render(popup, buf);
Paragraph::new(lines)
.block(
Block::bordered()
.title(Line::from("Recruitment").magenta().bold())
.padding(Padding::uniform(1)),
)
.render(popup, buf);
let block: Block<'_> = Block::bordered()
.title(Line::from("Recruitment").magenta().bold())
.padding(Padding::uniform(1));
let [tabs_area, details_area]: [Rect; 2] =
Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(block.inner(popup));
block.render(popup, buf);
Tabs::new(SkirmishState::RECRUITMENT_OPTIONS.iter().copied())
.select(states.skirmish.selected_recruit)
.highlight_style(Style::default().fg(Color::LightMagenta).bold())
.render(tabs_area, buf);
Paragraph::new(lines).render(details_area, buf);
}
}
+26 -3
View File
@@ -1,10 +1,11 @@
use crate::app::states::skirmish_states::BoardState;
use crate::app::states::skirmish_states::{BoardState, Players, tasks::Task, units::Unit};
use crate::app::widgets::CellWidget;
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
widgets::Widget,
};
use std::rc::Rc;
use std::{collections::HashSet, rc::Rc};
pub struct BoardWidget<'a> {
state: &'a BoardState,
@@ -18,6 +19,18 @@ impl<'a> BoardWidget<'a> {
impl Widget for BoardWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let mut queued_path: HashSet<(usize, usize)> = HashSet::new();
for row in &self.state.cells {
for cell in row {
if let Some(unit) = cell.get_ref_option_unit()
&& unit.get_owner() == Players::Player
{
for task in unit.get_ref_tasks() {
queued_path.extend(task.get_path().iter().copied());
}
}
}
}
let horizontal: Rc<[Rect]> =
Layout::horizontal(vec![
Constraint::Length(self.state.cell_width as u16);
@@ -38,7 +51,17 @@ impl Widget for BoardWidget<'_> {
.cells
.get(row_idx + self.state.vertical_offset.get_value())
.and_then(|rows| rows.get(col_idx + self.state.horizontal_offset.get_value()))
.map(|cell| cell.clone().render(*cell_area, buf));
.map(|cell: &CellWidget| -> () {
let mut cell: CellWidget = cell.clone();
let coords: (usize, usize) = (
row_idx + self.state.vertical_offset.get_value(),
col_idx + self.state.horizontal_offset.get_value(),
);
if queued_path.contains(&coords) {
cell.set_marked(true);
}
cell.render(*cell_area, buf);
});
}
}
}
+12
View File
@@ -143,6 +143,18 @@ impl CellWidget {
let middle = lines.len() / 2;
lines[middle] = Line::from(format!("{percent}%")).fg(unit.get_owner().get_color());
}
if let Structures::Base(base) = self.structure
&& base.queued_miners() > 0
{
let middle: usize = lines.len() / 2;
let percent: u16 = base.recruitment_progress();
let label: String = if self.zoom_level == ZoomLevel::ZoomedOut && percent == 100 {
percent.to_string()
} else {
format!("{percent}%")
};
lines[middle] = Line::from(label).cyan();
}
lines
}
+7 -1
View File
@@ -354,7 +354,7 @@ fn cancellation_restores_original_ore_and_refunds_cost_exactly_once() -> () {
}
#[test]
fn panel_shows_progress_and_delete_cancels_while_escape_only_closes_panel() -> () {
fn panel_shows_progress_delete_cancels_and_escape_is_ignored() -> () {
let mut app: App = app();
for _ in 0..2 {
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
@@ -389,6 +389,7 @@ fn panel_shows_progress_and_delete_cancels_while_escape_only_closes_panel() -> (
assert!(text.contains("Construction: 0%"));
assert!(text.contains("Delete - Cancel construction"));
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(app.states_ref().unwrap().skirmish.building_view);
assert!(matches!(
app.states_ref().unwrap().skirmish.board.cells[5][1].get_structure(),
Structures::MineConstruction(_)
@@ -397,6 +398,11 @@ fn panel_shows_progress_and_delete_cancels_while_escape_only_closes_panel() -> (
&mut app,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
);
assert!(!app.states_ref().unwrap().skirmish.building_view);
handle_keybindings(
&mut app,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
);
handle_keybindings(&mut app, KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
assert!(matches!(
app.states_ref().unwrap().skirmish.board.cells[5][1].get_structure(),
+34 -2
View File
@@ -77,7 +77,7 @@ fn building_view_has_its_own_help_and_does_not_build_on_invalid_tile() -> () {
assert!(app.states_ref().unwrap().skirmish.building_view);
assert_eq!(
available_skirmish_actions(&app.states_ref().unwrap().skirmish),
[Action::Building, Action::Esc, Action::Quit2]
[Action::Building, Action::Left, Action::Right, Action::Quit2]
);
press(&mut app, KeyCode::Left, KeyModifiers::NONE);
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
@@ -99,7 +99,11 @@ fn building_view_has_its_own_help_and_does_not_build_on_invalid_tile() -> () {
assert!(text.contains("Construction unavailable"));
assert!(text.contains("Close buildings"));
assert!(!text.contains("Marking cells"));
assert!(!text.contains("Esc -"));
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert!(app.states_ref().unwrap().skirmish.building_view);
assert_eq!(app.view, View::Skirmish);
press(&mut app, KeyCode::Char('b'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.building_view);
assert_eq!(app.view, View::Skirmish);
for row in &app.states_ref().unwrap().skirmish.board.cells {
@@ -183,7 +187,7 @@ fn construction_and_recruitment_popups_render_semantic_text_colors() -> () {
.unwrap();
let cells: &[Cell] = &terminal.backend().buffer().content;
for (text, color) in [
(name, Color::Yellow),
(name, Color::LightMagenta),
("Cost:", Color::Yellow),
(status, status_color),
] {
@@ -203,3 +207,31 @@ fn construction_and_recruitment_popups_render_semantic_text_colors() -> () {
}
}
}
#[test]
fn popup_tabs_cycle_without_moving_map_or_changing_queues() -> () {
for key in ['b', 'r'] {
let mut app: App = app();
if key == 'r' {
press(&mut app, KeyCode::Left, KeyModifiers::NONE);
}
let before: war_in_tunnels::app::states::skirmish_states::BoardState =
app.states_ref().unwrap().skirmish.board.clone();
press(&mut app, KeyCode::Char(key), KeyModifiers::NONE);
for code in [KeyCode::Left, KeyCode::Right, KeyCode::Right, KeyCode::Left] {
press(&mut app, code, KeyModifiers::NONE);
let state: &war_in_tunnels::app::states::SkirmishState =
&app.states_ref().unwrap().skirmish;
assert_eq!(state.board, before);
assert_eq!(state.selected_building, 0);
assert_eq!(state.selected_recruit, 0);
assert!(available_skirmish_actions(state).contains(&Action::Left));
assert!(available_skirmish_actions(state).contains(&Action::Right));
}
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.view, View::Skirmish);
press(&mut app, KeyCode::Char(key), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.building_view);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
}
}
+79 -2
View File
@@ -2,9 +2,11 @@ use clap::Parser;
use ratatui::{
Terminal,
backend::TestBackend,
buffer::Cell,
buffer::{Buffer, Cell},
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect,
style::Color,
widgets::Widget,
};
use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{
@@ -13,7 +15,7 @@ use war_in_tunnels::{
states::{
SkirmishState,
skirmish_states::{
MoveFocusedCell, Players, Resources,
CellSizes, MoveFocusedCell, Players, Resources, ZoomLevel,
structures::{BaseBuilding, Structures, Tunnel},
units::{MinerUnit, Unit, Units},
},
@@ -44,6 +46,74 @@ fn press(app: &mut App, code: KeyCode, modifiers: KeyModifiers) -> () {
handle_keybindings(app, KeyEvent::new(code, modifiers));
}
#[test]
fn base_cell_shows_recruitment_progress_until_spawn_at_every_zoom() -> () {
for zoom in [
ZoomLevel::ZoomedIn,
ZoomLevel::Default,
ZoomLevel::ZoomedOut,
] {
let mut app: App = app();
let game: &mut SkirmishState = &mut app.states_mut().unwrap().skirmish;
let render = |game: &SkirmishState| -> Buffer {
let area: Rect = Rect::new(
0,
0,
zoom.get_cell_size(CellSizes::Width) as u16,
zoom.get_cell_size(CellSizes::Height) as u16,
);
let mut buffer: Buffer = Buffer::empty(area);
game.board.cells[5][2]
.clone()
.set_zoom_level(zoom)
.clone()
.render(area, &mut buffer);
buffer
};
let text = |buffer: &Buffer| -> String {
buffer
.content
.iter()
.map(|cell: &Cell| -> &str { cell.symbol() })
.collect()
};
assert!(!text(&render(game)).contains('%'));
assert!(game.recruit_miner((5, 2), Players::Player));
assert!(text(&render(game)).contains("0%"));
for _ in 0..BaseBuilding::RECRUITMENT_TICKS / 2 {
game.tick_update();
}
let buffer: Buffer = render(game);
assert!(text(&buffer).contains("50%"));
assert_eq!(
buffer
.content
.iter()
.find(|cell: &&Cell| -> bool { cell.symbol() == "%" })
.unwrap()
.fg,
Color::Cyan
);
if zoom != ZoomLevel::ZoomedOut {
assert!(text(&buffer).contains('M'));
}
for _ in 0..BaseBuilding::RECRUITMENT_TICKS {
game.tick_update();
}
assert!(
text(&render(game)).contains(if zoom == ZoomLevel::ZoomedOut {
"100"
} else {
"100%"
})
);
game.board.cells[5][2].get_mut_option_unit().take();
game.tick_update();
assert!(!text(&render(game)).contains('%'));
assert!(game.board.cells[5][2].get_ref_option_unit().is_some());
}
}
#[test]
fn recruitment_is_owned_by_base_and_invalid_requests_do_not_charge() -> () {
let mut app: App = app();
@@ -278,7 +348,14 @@ fn recruitment_panel_queues_with_enter_shows_ready_state_and_closes_without_losi
assert!(text.contains("Current progress: 100%"));
assert!(text.contains("Ready: waiting for free base cell"));
assert!(text.contains("Close recruitment"));
assert!(!text.contains("Esc -"));
assert!(
!available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Esc)
);
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert!(app.states_ref().unwrap().skirmish.recruitment_view);
assert_eq!(app.view, View::Skirmish);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert!(!app.states_ref().unwrap().skirmish.recruitment_view);
assert_eq!(app.view, View::Skirmish);
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
+207
View File
@@ -0,0 +1,207 @@
use clap::Parser;
use ratatui::{
Terminal,
backend::TestBackend,
buffer::Cell,
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect,
style::Color,
};
use std::sync::mpsc::{Receiver, Sender, channel};
use war_in_tunnels::{
app::{
Action, App, GameStates, View, available_skirmish_actions, handle_keybindings,
states::skirmish_states::{
BoardState, MoveFocusedCell,
structures::{Structure, Structures, Tunnel},
tasks::{DiggingTask, MovingTask, Task, Tasks},
units::Unit,
},
threads::AudioCmd,
},
cli::Cli,
};
fn app() -> App {
let args: Cli = Cli::try_parse_from(["game", "--disable-audio"]).unwrap();
let (tx, _rx): (Sender<AudioCmd>, Receiver<AudioCmd>) = channel();
let mut app: App = App::new(args, tx);
app.view = View::Skirmish;
app.window_area = Rect::new(0, 0, 160, 50);
app.states = Some(GameStates::new(&app.args, &app.window_area));
app.states_mut()
.unwrap()
.skirmish
.board
.change_focused_cell(MoveFocusedCell::Left);
app
}
fn press(app: &mut App, code: KeyCode) -> () {
handle_keybindings(app, KeyEvent::new(code, KeyModifiers::NONE));
}
fn route_color_count(app: &mut App) -> usize {
let mut terminal: Terminal<TestBackend> = Terminal::new(TestBackend::new(160, 50)).unwrap();
terminal
.draw(|frame: &mut ratatui::Frame<'_>| -> () {
frame.render_widget(app, frame.area());
})
.unwrap();
terminal
.backend()
.buffer()
.content
.iter()
.filter(|cell: &&Cell| -> bool { matches!(cell.fg, Color::Magenta | Color::LightMagenta) })
.count()
}
#[test]
fn queued_route_colors_follow_progress_and_cancellation_without_marking_model() -> () {
let mut app: App = app();
let baseline: usize = route_color_count(&mut app);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][3].set_structure(Structures::Tunnel(Tunnel::new()));
board.cells[5][4].set_structure(Structures::Tunnel(Tunnel::new()));
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 4)].into())));
let queued: usize = route_color_count(&mut app);
assert!(queued > baseline);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
assert!(!board.cells[5][3].get_marked());
assert!(!board.cells[5][4].get_marked());
for _ in 0..91 {
board.advance_turn();
}
let remaining: usize = route_color_count(&mut app);
assert!(remaining > baseline && remaining < queued);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.change_focused_cell(MoveFocusedCell::Right);
assert!(board.cancel_focused_tasks(true));
assert_eq!(route_color_count(&mut app), baseline);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][3]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 4)].into())));
for _ in 0..91 {
board.advance_turn();
}
assert_eq!(route_color_count(&mut app), baseline);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][39]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 4)].into())));
assert_eq!(route_color_count(&mut app), baseline);
}
#[test]
fn cancelling_last_preserves_active_work_and_delete_stops_without_restoring_stone() -> () {
let mut app: App = app();
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
let power: f32 = board.cells[5][2].get_ref_option_unit().get_digging_power();
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Digging(DiggingTask::new([(5, 3)].into(), power)));
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Digging(DiggingTask::new([(5, 4)].into(), power)));
board.advance_turn();
let stone: Structures = board.cells[5][3].get_structure();
assert!(stone.get_durability() < stone.get_max_durability());
let active: Tasks = board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0].clone();
press(&mut app, KeyCode::Delete);
assert_eq!(
app.states_ref().unwrap().skirmish.board.cells[5][2]
.get_ref_option_unit()
.get_ref_tasks()
.len(),
2
);
press(&mut app, KeyCode::Tab);
press(&mut app, KeyCode::Backspace);
let board: &BoardState = &app.states_ref().unwrap().skirmish.board;
assert_eq!(
board.cells[5][2]
.get_ref_option_unit()
.get_ref_tasks()
.len(),
1
);
assert_eq!(
board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0],
active
);
press(&mut app, KeyCode::Delete);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.advance_turn();
assert!(
board.cells[5][2]
.get_ref_option_unit()
.get_ref_tasks()
.is_empty()
);
assert_eq!(board.cells[5][3].get_structure(), stone);
assert!(!board.cancel_focused_tasks(true));
}
#[test]
fn cancelling_active_movement_keeps_unit_on_origin_and_discards_progress() -> () {
let mut app: App = app();
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][3].set_structure(Structures::Tunnel(Tunnel::new()));
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
for _ in 0..40 {
board.advance_turn();
}
assert!(board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0].get_progress() > 0.0);
assert!(board.cancel_focused_tasks(false));
for _ in 0..100 {
board.advance_turn();
}
assert!(board.cells[5][2].get_ref_option_unit().is_some());
assert!(board.cells[5][3].get_ref_option_unit().is_none());
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
board.advance_turn();
assert!(board.cells[5][2].get_ref_option_unit().get_ref_tasks()[0].get_progress() < 2.0);
}
#[test]
fn cancellation_rejects_enemy_empty_cells_and_route_drawing() -> () {
let mut app: App = app();
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
board.cells[5][2]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 3)].into())));
press(&mut app, KeyCode::Tab);
assert!(
available_skirmish_actions(&app.states_ref().unwrap().skirmish).contains(&Action::Delete)
);
press(&mut app, KeyCode::Char(' '));
assert!(
!app.states_mut()
.unwrap()
.skirmish
.board
.cancel_focused_tasks(true)
);
press(&mut app, KeyCode::Esc);
let board: &mut BoardState = &mut app.states_mut().unwrap().skirmish.board;
while board.get_focused_cell().get_coords().1 < 39 {
board.change_focused_cell(MoveFocusedCell::Right);
}
board.cells[5][39]
.get_mut_option_unit()
.set_task(Tasks::Moving(MovingTask::new([(5, 38)].into())));
assert!(!board.cancel_focused_tasks(true));
board.change_focused_cell(MoveFocusedCell::Up);
assert!(!board.cancel_focused_tasks(true));
}